Skip to content

Commit 575d59e

Browse files
authored
Merge pull request #27 from camunda/feat/consolidate-endpoint-metadata
feat(metadata): consolidate endpoint metadata into spec-metadata.json (closes #21)
2 parents 1c05bb7 + 82d2adf commit 575d59e

8 files changed

Lines changed: 642 additions & 17 deletions

File tree

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ This utility solves all of these problems and produces three outputs:
1717

1818
1. **Bundled spec** (`rest-api.bundle.json`) — A single, clean OpenAPI 3 JSON file with all schemas as proper `#/components/schemas/...` refs
1919
2. **Metadata IR** (`spec-metadata.json`) — A structured intermediate representation of domain-specific information extracted from the spec
20-
3. **Endpoint map** (`endpoint-map.json`) — A mapping of each API operation (method + path) to its source YAML file, useful for tracing endpoints back to their origin
20+
3. **Endpoint map** (`endpoint-map.json`) — _Deprecated, removed in 3.0.0._ Each `OperationSummary` in `spec-metadata.json` now carries `sourceFile` directly, so consumers no longer need to read this file or join on `"METHOD /path"`. Still emitted (with a deprecation warning) when `--output-endpoint-map` is set, for one minor cycle.
2121

2222
## Installation
2323

@@ -103,7 +103,7 @@ camunda-schema-bundler --version
103103
| `--entry-file <name>` | Entry YAML file name (default: `rest-api.yaml`) |
104104
| `--output-spec <path>` | Output path for the bundled JSON spec |
105105
| `--output-metadata <path>` | Output path for the metadata IR JSON |
106-
| `--output-endpoint-map <path>` | Output path for the endpoint map JSON (method + path → source file) |
106+
| `--output-endpoint-map <path>` | _Deprecated, removed in 3.0.0._ Output path for the endpoint map JSON (method + path → source file). Use `OperationSummary.sourceFile` in `spec-metadata.json` instead. |
107107
| `--deref-path-local` | Inline remaining path-local `$ref`s (needed for Microsoft.OpenApi) |
108108
| `--allow-like-refs` | Don't fail on surviving path-local `$like` refs |
109109
| **General** | |
@@ -259,19 +259,30 @@ Operations marked with `x-eventually-consistent: true`:
259259

260260
### Operation Summaries
261261

262-
Every operation with metadata about body shape and union variants:
262+
Every operation with metadata about body shape, union variants, source file, and response shape:
263263

264264
```json
265265
{
266266
"operationId": "createProcessInstance",
267267
"path": "/process-instances",
268268
"method": "post",
269+
"sourceFile": "process-instance/process-instance.yaml",
269270
"eventuallyConsistent": false,
270271
"hasRequestBody": true,
271-
"requestBodyUnion": false
272+
"requestBodyUnion": false,
273+
"requestBodyContentTypes": ["application/json"],
274+
"requestBodySchemaRef": "CreateProcessInstanceRequest",
275+
"successStatus": 200,
276+
"successResponseSchemaRef": "CreateProcessInstanceResult",
277+
"vendorExtensions": { "x-ergonomic-helper": "createProcessInstanceFromBpmnFile" }
272278
}
273279
```
274280

281+
The `sourceFile`, `requestBodyContentTypes`, `requestBodySchemaRef`,
282+
`successResponseSchemaRef`, `successStatus`, and `vendorExtensions` fields
283+
were added in `spec-metadata.json` schemaVersion `2.0.0`. Together they
284+
remove the need to read `endpoint-map.json` and join on `"METHOD /path"`.
285+
275286
## Per-SDK Configuration
276287

277288
| SDK | `--deref-path-local` | Notes |

src/bundle.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,10 @@ export async function bundle(options: BundleOptions): Promise<BundleResult> {
294294
'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace',
295295
]);
296296
const endpointMap: Record<string, string> = {};
297+
// Per-operation source file map keyed by `${methodLower} ${path}` (e.g.
298+
// "get /process-instances"). Used to populate `OperationSummary.sourceFile`
299+
// in the metadata IR. See https://github.com/camunda/camunda-schema-bundler/issues/21
300+
const sourceFileByOp = new Map<string, string>();
297301
const opsSeen = new Set<string>();
298302
// `bundled.paths` is constant for the whole run; resolve it once and
299303
// pre-compute the set of bundled (path, method) pairs so the per-file
@@ -355,6 +359,7 @@ export async function bundle(options: BundleOptions): Promise<BundleResult> {
355359
if (opsSeen.has(op)) continue;
356360
opsSeen.add(op);
357361
endpointMap[op] = relFile;
362+
sourceFileByOp.set(`${key} ${apiPath}`, relFile);
358363
}
359364
}
360365
}
@@ -610,7 +615,7 @@ export async function bundle(options: BundleOptions): Promise<BundleResult> {
610615
// ── Step 6: Extract metadata IR ───────────────────────────────────────────
611616

612617
const specHash = hashDirectoryTree(options.specDir);
613-
const metadata = extractMetadata(bundled, schemas, specHash);
618+
const metadata = extractMetadata(bundled, schemas, specHash, sourceFileByOp);
614619

615620
// ── Step 7: Write outputs ─────────────────────────────────────────────────
616621

@@ -635,6 +640,13 @@ export async function bundle(options: BundleOptions): Promise<BundleResult> {
635640
}
636641

637642
if (options.outputEndpointMap) {
643+
stats.endpointMapDeprecated = true;
644+
console.warn(
645+
'[camunda-schema-bundler] WARNING: endpoint-map.json is deprecated and ' +
646+
'will be removed in 3.0.0. The same per-operation source file is now ' +
647+
'available as `sourceFile` on each entry in `spec-metadata.json`\'s ' +
648+
'`operations[]`. See https://github.com/camunda/camunda-schema-bundler/issues/21'
649+
);
638650
const dir = path.dirname(options.outputEndpointMap);
639651
fs.mkdirSync(dir, { recursive: true });
640652
fs.writeFileSync(

src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Bundle options:
138138
--entry-file <name> Entry YAML file name (default: rest-api.yaml)
139139
--output-spec <path> Output path for bundled JSON spec
140140
--output-metadata <path> Output path for metadata IR JSON
141-
--output-endpoint-map <path> Output path for endpoint map JSON
141+
--output-endpoint-map <path> Output path for endpoint map JSON [DEPRECATED — removed in 3.0.0; use OperationSummary.sourceFile in spec-metadata.json]
142142
--deref-path-local Inline remaining path-local $refs
143143
--allow-like-refs Don't fail on surviving path-local $like refs
144144
--help, -h Show this help

src/metadata.ts

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,21 @@ const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'
3131
export function extractMetadata(
3232
spec: Record<string, unknown>,
3333
schemas: Record<string, unknown>,
34-
specHash: string
34+
specHash: string,
35+
sourceFileByOp?: Map<string, string>
3536
): SpecMetadata {
3637
const semanticKeys = extractSemanticKeys(schemas);
3738
const unions = extractUnions(schemas);
3839
const arrays = extractArraySchemas(schemas);
39-
const { eventuallyConsistentOps, operations } = extractOperations(spec);
40+
const { eventuallyConsistentOps, operations } = extractOperations(
41+
spec,
42+
sourceFileByOp
43+
);
4044
const deprecatedEnumMembers = extractDeprecatedEnumMembers(schemas);
4145
const semanticProviders = extractSemanticProviders(schemas);
4246

4347
return {
44-
schemaVersion: '1.0.0',
48+
schemaVersion: '2.0.0',
4549
specHash,
4650
semanticKeys: semanticKeys.sort((a, b) => a.name.localeCompare(b.name)),
4751
unions: unions.sort((a, b) => a.name.localeCompare(b.name)),
@@ -317,7 +321,10 @@ function extractSemanticProviders(
317321
return results;
318322
}
319323

320-
function extractOperations(spec: Record<string, unknown>): {
324+
function extractOperations(
325+
spec: Record<string, unknown>,
326+
sourceFileByOp?: Map<string, string>
327+
): {
321328
eventuallyConsistentOps: EventuallyConsistentOp[];
322329
operations: OperationSummary[];
323330
} {
@@ -332,6 +339,16 @@ function extractOperations(spec: Record<string, unknown>): {
332339
| Record<string, unknown>
333340
| undefined
334341
) ?? {};
342+
const componentRequestBodies = (
343+
(spec['components'] as Record<string, unknown> | undefined)?.[
344+
'requestBodies'
345+
] as Record<string, unknown> | undefined
346+
) ?? {};
347+
const componentResponses = (
348+
(spec['components'] as Record<string, unknown> | undefined)?.[
349+
'responses'
350+
] as Record<string, unknown> | undefined
351+
) ?? {};
335352

336353
for (const [pathStr, pathItem] of Object.entries(paths)) {
337354
if (!pathItem || typeof pathItem !== 'object') continue;
@@ -365,11 +382,33 @@ function extractOperations(spec: Record<string, unknown>): {
365382
let requestBodyUnion = false;
366383
const requestBodyUnionRefs: string[] = [];
367384
let optionalTenantIdInBody = false;
385+
const requestBodyContentTypes: string[] = [];
386+
let requestBodySchemaRef: string | undefined;
368387

369388
if (hasRequestBody) {
370-
const rb = op['requestBody'] as Record<string, unknown>;
371-
const content = rb['content'] as Record<string, unknown> | undefined;
389+
// Resolve `requestBody` itself if it points at
390+
// `#/components/requestBodies/...` so that the rest of this block
391+
// sees the actual content map.
392+
const rb = resolveComponentRef(
393+
op['requestBody'] as Record<string, unknown>,
394+
componentRequestBodies
395+
);
396+
const content = rb?.['content'] as Record<string, unknown> | undefined;
372397
if (content) {
398+
for (const ct of Object.keys(content)) {
399+
requestBodyContentTypes.push(ct);
400+
}
401+
// First content entry whose schema is a $ref wins. Inline schemas
402+
// do not block later $ref entries.
403+
for (const mediaObj of Object.values(content)) {
404+
const media = mediaObj as Record<string, unknown> | undefined;
405+
const rawSchema = media?.['schema'] as Record<string, unknown> | undefined;
406+
const ref = rawSchema?.['$ref'];
407+
if (typeof ref === 'string') {
408+
requestBodySchemaRef = ref.split('/').pop();
409+
break;
410+
}
411+
}
373412
// Check all JSON-like content types
374413
for (const [contentType, mediaObj] of Object.entries(content)) {
375414
if (!/json|octet|multipart|text\//i.test(contentType)) continue;
@@ -408,6 +447,58 @@ function extractOperations(spec: Record<string, unknown>): {
408447

409448
const bodyOnly = hasRequestBody && pathParams.length === 0 && queryParams.length === 0;
410449

450+
// Success response: pick the lowest 2xx status, then look for an
451+
// application/json schema $ref under it.
452+
let successStatus: number | undefined;
453+
let successResponseSchemaRef: string | undefined;
454+
const responses = op['responses'] as Record<string, unknown> | undefined;
455+
if (responses) {
456+
const statuses = Object.keys(responses)
457+
.map((k) => ({ key: k, num: Number(k) }))
458+
.filter(({ num }) => Number.isInteger(num) && num >= 200 && num < 300)
459+
.sort((a, b) => a.num - b.num);
460+
if (statuses.length > 0) {
461+
successStatus = statuses[0].num;
462+
// Resolve `responses[<status>]` if it points at
463+
// `#/components/responses/...` so a factored-out response still
464+
// exposes its content schema $ref.
465+
const resp = resolveComponentRef(
466+
responses[statuses[0].key] as Record<string, unknown> | undefined,
467+
componentResponses
468+
);
469+
const respContent = resp?.['content'] as
470+
| Record<string, unknown>
471+
| undefined;
472+
if (respContent) {
473+
const json = respContent['application/json'] as
474+
| Record<string, unknown>
475+
| undefined;
476+
const schema = json?.['schema'] as
477+
| Record<string, unknown>
478+
| undefined;
479+
const ref = schema?.['$ref'];
480+
if (typeof ref === 'string') {
481+
successResponseSchemaRef = ref.split('/').pop();
482+
}
483+
}
484+
}
485+
}
486+
487+
// Vendor extensions: pass through every x-* key on the operation.
488+
// Deep-clone object/array values so the metadata IR stays independent
489+
// of the bundled spec (mutating one must not affect the other).
490+
let vendorExtensions: Record<string, unknown> | undefined;
491+
for (const k of Object.keys(op)) {
492+
if (k.startsWith('x-')) {
493+
if (!vendorExtensions) vendorExtensions = {};
494+
const v = op[k];
495+
vendorExtensions[k] =
496+
v !== null && typeof v === 'object' ? structuredClone(v) : v;
497+
}
498+
}
499+
500+
const sourceFile = sourceFileByOp?.get(`${method} ${pathStr}`) ?? '';
501+
411502
operations.push({
412503
operationId,
413504
path: pathStr,
@@ -423,6 +514,12 @@ function extractOperations(spec: Record<string, unknown>): {
423514
queryParams,
424515
requestBodyUnionRefs,
425516
optionalTenantIdInBody,
517+
sourceFile,
518+
requestBodyContentTypes,
519+
requestBodySchemaRef,
520+
successResponseSchemaRef,
521+
successStatus,
522+
vendorExtensions,
426523
});
427524

428525
if (eventuallyConsistent) {
@@ -451,6 +548,23 @@ function resolveSchemaRef(
451548
return schema;
452549
}
453550

551+
/**
552+
* Resolve a `$ref` that targets a `#/components/<bucket>/<name>` entry
553+
* (e.g. `requestBodies`, `responses`). Returns the original object when it
554+
* is not a `$ref`, or `undefined` when the ref cannot be resolved against
555+
* the supplied component bucket.
556+
*/
557+
function resolveComponentRef(
558+
obj: Record<string, unknown> | undefined,
559+
componentBucket: Record<string, unknown>
560+
): Record<string, unknown> | undefined {
561+
if (!obj) return undefined;
562+
const ref = obj['$ref'];
563+
if (typeof ref !== 'string') return obj;
564+
const name = ref.split('/').pop()!;
565+
return componentBucket[name] as Record<string, unknown> | undefined;
566+
}
567+
454568
/** Check if a resolved schema has an optional tenantId property. */
455569
function hasOptionalTenantId(schema: Record<string, unknown>): boolean {
456570
if (schema['type'] !== 'object') return false;

src/types.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@ export interface FetchAndBundleOptions {
2626
/** Output path for the metadata IR JSON. */
2727
outputMetadata?: string;
2828

29-
/** Output path for the endpoint map JSON (method + path → source file). */
29+
/**
30+
* Output path for the endpoint map JSON (method + path → source file).
31+
*
32+
* @deprecated Use `OperationSummary.sourceFile` in `spec-metadata.json`.
33+
* `endpoint-map.json` will be removed in 3.0.0. See
34+
* https://github.com/camunda/camunda-schema-bundler/issues/21
35+
*/
3036
outputEndpointMap?: string;
3137

3238
/** Manual ref overrides. */
@@ -55,7 +61,13 @@ export interface BundleOptions {
5561
/** Output path for the metadata IR JSON. */
5662
outputMetadata?: string;
5763

58-
/** Output path for the endpoint map JSON (method + path → source file). */
64+
/**
65+
* Output path for the endpoint map JSON (method + path → source file).
66+
*
67+
* @deprecated Use `OperationSummary.sourceFile` in `spec-metadata.json`.
68+
* `endpoint-map.json` will be removed in 3.0.0. See
69+
* https://github.com/camunda/camunda-schema-bundler/issues/21
70+
*/
5971
outputEndpointMap?: string;
6072

6173
/**
@@ -103,6 +115,14 @@ export interface BundleStats {
103115
freshDedupCount: number;
104116
dereferencedPathLocalRefCount: number;
105117
pathLocalLikeRefCount: number;
118+
119+
/**
120+
* `true` when the caller requested `outputEndpointMap` (or `--output-endpoint-map`).
121+
* `endpoint-map.json` is deprecated and slated for removal in 3.0.0; the same
122+
* information is now carried per-operation on `OperationSummary` (`sourceFile`)
123+
* in `spec-metadata.json`. See https://github.com/camunda/camunda-schema-bundler/issues/21
124+
*/
125+
endpointMapDeprecated?: boolean;
106126
}
107127

108128
// ── Metadata IR ──────────────────────────────────────────────────────────────
@@ -222,6 +242,48 @@ export interface OperationSummary {
222242

223243
/** Whether the (resolved) request body has an optional tenantId property. */
224244
optionalTenantIdInBody: boolean;
245+
246+
/**
247+
* Source YAML file the operation came from, relative to the spec dir.
248+
* Replaces the join previously required against `endpoint-map.json`.
249+
* Empty string when the source file is unknown (e.g. when `extractMetadata`
250+
* is called directly without a source-file map).
251+
*/
252+
sourceFile: string;
253+
254+
/**
255+
* Content-type keys declared on `requestBody.content` (e.g.
256+
* `['application/json']`, `['multipart/form-data']`). Empty when there is
257+
* no request body.
258+
*/
259+
requestBodyContentTypes: string[];
260+
261+
/**
262+
* Component schema name referenced by the request body (`$ref` short name,
263+
* no `#/components/schemas/` prefix). Resolved by scanning every entry under
264+
* `requestBody.content` and returning the first one whose `schema` is a
265+
* `$ref` — inline schemas are skipped, so a later media type with a `$ref`
266+
* can win over an earlier one with an inline schema. `undefined` when no
267+
* content entry has a `$ref` schema, or when there is no request body.
268+
*/
269+
requestBodySchemaRef?: string;
270+
271+
/**
272+
* Component schema name referenced by the chosen 2xx response's
273+
* `application/json` content. `undefined` when the success response is
274+
* empty, inline, or non-JSON.
275+
*/
276+
successResponseSchemaRef?: string;
277+
278+
/** The chosen 2xx status code (200/201/204/…), if any. */
279+
successStatus?: number;
280+
281+
/**
282+
* Pass-through of all `x-*` keys declared on the operation. Promoted
283+
* extensions (`x-eventually-consistent`) keep their first-class fields and
284+
* are also included here for completeness.
285+
*/
286+
vendorExtensions?: Record<string, unknown>;
225287
}
226288

227289
export interface OperationQueryParam {

0 commit comments

Comments
 (0)