@atolis-hq/corum 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/adapters/openapi/mapper.js +235 -34
- package/dist/src/graph/index.js +30 -7
- package/dist/src/import/runner.js +12 -0
- package/package.json +1 -1
- package/web/app.jsx +26 -1
- package/web/primitives.jsx +36 -16
|
@@ -18,6 +18,16 @@ export function deriveScalarType(type, format, scalarTypes) {
|
|
|
18
18
|
export function isRefSchema(schema) {
|
|
19
19
|
return typeof schema === 'object' && schema !== null && '$ref' in schema;
|
|
20
20
|
}
|
|
21
|
+
// Unwrap allOf: [{$ref}] — the OpenAPI 3.0 pattern for nullable/described refs
|
|
22
|
+
function resolveAllOfRef(schema) {
|
|
23
|
+
if (isRefSchema(schema))
|
|
24
|
+
return schema;
|
|
25
|
+
const s = schema;
|
|
26
|
+
if (Array.isArray(s.allOf) && s.allOf.length === 1 && isRefSchema(s.allOf[0])) {
|
|
27
|
+
return s.allOf[0];
|
|
28
|
+
}
|
|
29
|
+
return schema;
|
|
30
|
+
}
|
|
21
31
|
export function deriveNodeId(kind, component, name, parentId, section) {
|
|
22
32
|
if (kind === 'operation')
|
|
23
33
|
return `${component}.APIEndpoint.${name}`;
|
|
@@ -26,24 +36,56 @@ export function deriveNodeId(kind, component, name, parentId, section) {
|
|
|
26
36
|
export function refName(ref) {
|
|
27
37
|
return ref.split('/').pop() ?? ref;
|
|
28
38
|
}
|
|
39
|
+
function emitReadsEdge(from, to, edges) {
|
|
40
|
+
const id = `${from}__reads__${to}`;
|
|
41
|
+
if (!edges.some(e => e.id === id)) {
|
|
42
|
+
edges.push({ id, from, to, type: 'reads', state: 'implemented', stability: 'unstable' });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
29
45
|
export function mapDocument(document, entry, packConfig) {
|
|
30
46
|
const nodes = [];
|
|
31
47
|
const edges = [];
|
|
32
48
|
const diagnostics = [];
|
|
33
49
|
const sharedSchemas = new Map();
|
|
50
|
+
const sourceSchemas = new Map();
|
|
51
|
+
for (const [name, schema] of Object.entries(document.components?.schemas ?? {})) {
|
|
52
|
+
if (!isRefSchema(schema))
|
|
53
|
+
sourceSchemas.set(name, schema);
|
|
54
|
+
}
|
|
55
|
+
// Schemas referenced by 2+ operations become shared files; single-use schemas are inlined
|
|
56
|
+
const opCounts = countSchemaOperationUsage(document);
|
|
57
|
+
const sharedSchemaNames = collectAllSharedSchemaNames(document, opCounts);
|
|
58
|
+
// Pass 1: register all enum and shared-schema IDs upfront so cross-references
|
|
59
|
+
// resolve correctly regardless of definition order in the spec.
|
|
60
|
+
if (document.components?.schemas) {
|
|
61
|
+
for (const [name, schema] of Object.entries(document.components.schemas)) {
|
|
62
|
+
if (isRefSchema(schema))
|
|
63
|
+
continue;
|
|
64
|
+
const s = schema;
|
|
65
|
+
const component = deriveComponentForSchema(name, document, entry);
|
|
66
|
+
if (!component)
|
|
67
|
+
continue;
|
|
68
|
+
if (s.type !== 'object' && s.enum) {
|
|
69
|
+
sharedSchemas.set(name, `${component}.EnumDefinition.${name}`);
|
|
70
|
+
}
|
|
71
|
+
else if (sharedSchemaNames.has(name)) {
|
|
72
|
+
sharedSchemas.set(name, `${component}.Schema.${name}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Pass 2: emit nodes and fields now that all IDs are registered.
|
|
34
77
|
if (document.components?.schemas) {
|
|
35
78
|
for (const [name, schema] of Object.entries(document.components.schemas)) {
|
|
36
79
|
if (isRefSchema(schema))
|
|
37
80
|
continue;
|
|
38
81
|
const s = schema;
|
|
39
82
|
if (s.type !== 'object' && s.enum) {
|
|
40
|
-
const
|
|
41
|
-
if (!
|
|
83
|
+
const enumId = sharedSchemas.get(name);
|
|
84
|
+
if (!enumId)
|
|
42
85
|
continue;
|
|
43
|
-
const
|
|
86
|
+
const [component] = enumId.split('.');
|
|
44
87
|
const enumNode = makeNode(packConfig.constructs.enumDefinition?.template ?? 'EnumDefinition', component, entry.spec, enumId);
|
|
45
88
|
nodes.push(enumNode);
|
|
46
|
-
sharedSchemas.set(name, enumId);
|
|
47
89
|
s.enum.forEach((value) => {
|
|
48
90
|
const valueId = deriveNodeId('enumValue', undefined, String(value), enumId, 'values');
|
|
49
91
|
const valueNode = makeNode(packConfig.constructs.enumValue?.template ?? 'EnumValue', component, entry.spec, valueId);
|
|
@@ -53,16 +95,17 @@ export function mapDocument(document, entry, packConfig) {
|
|
|
53
95
|
});
|
|
54
96
|
continue;
|
|
55
97
|
}
|
|
56
|
-
|
|
57
|
-
|
|
98
|
+
if (!sharedSchemaNames.has(name))
|
|
99
|
+
continue;
|
|
100
|
+
const schemaId = sharedSchemas.get(name);
|
|
101
|
+
if (!schemaId) {
|
|
58
102
|
diagnostics.push({ severity: 'warning', file: entry.spec, message: `Cannot derive component for schema ${name}, skipping` });
|
|
59
103
|
continue;
|
|
60
104
|
}
|
|
61
|
-
const
|
|
62
|
-
sharedSchemas.set(name, schemaId);
|
|
105
|
+
const [component] = schemaId.split('.');
|
|
63
106
|
const node = makeNode(packConfig.constructs.requestSchema?.template ?? 'Schema', component, entry.spec, schemaId);
|
|
64
107
|
nodes.push(node);
|
|
65
|
-
emitFields(s, schemaId, 'fields', packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas);
|
|
108
|
+
emitFields(s, schemaId, 'fields', undefined, packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, new Map());
|
|
66
109
|
}
|
|
67
110
|
}
|
|
68
111
|
for (const [urlPath, pathItem] of Object.entries(document.paths ?? {})) {
|
|
@@ -92,11 +135,12 @@ export function mapDocument(document, entry, packConfig) {
|
|
|
92
135
|
const parameters = extractParameters(pathItem, operation, packConfig, entry.spec, diagnostics);
|
|
93
136
|
if (parameters)
|
|
94
137
|
endpointNode.properties.parameters = parameters;
|
|
138
|
+
const localSchemas = new Map();
|
|
95
139
|
const requestBody = operation.requestBody;
|
|
96
140
|
if (requestBody?.content) {
|
|
97
141
|
const jsonContent = requestBody.content['application/json'];
|
|
98
142
|
if (jsonContent?.schema) {
|
|
99
|
-
const ref = emitSchemaNode(jsonContent.schema, `${operationId}-request`, endpointId, 'schemas', packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas);
|
|
143
|
+
const ref = emitSchemaNode(jsonContent.schema, `${operationId}-request`, endpointId, 'schemas', endpointId, packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
|
|
100
144
|
if (ref)
|
|
101
145
|
endpointNode.properties.request = ref;
|
|
102
146
|
}
|
|
@@ -106,7 +150,7 @@ export function mapDocument(document, entry, packConfig) {
|
|
|
106
150
|
const responseObj = response;
|
|
107
151
|
const jsonContent = responseObj.content?.['application/json'];
|
|
108
152
|
if (jsonContent?.schema) {
|
|
109
|
-
const ref = emitSchemaNode(jsonContent.schema, `${operationId}-response-${status}`, endpointId, 'schemas', packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas);
|
|
153
|
+
const ref = emitSchemaNode(jsonContent.schema, `${operationId}-response-${status}`, endpointId, 'schemas', endpointId, packConfig, entry.spec, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
|
|
110
154
|
if (ref)
|
|
111
155
|
responses[status] = ref;
|
|
112
156
|
}
|
|
@@ -117,6 +161,54 @@ export function mapDocument(document, entry, packConfig) {
|
|
|
117
161
|
}
|
|
118
162
|
return { nodes, edges, diagnostics };
|
|
119
163
|
}
|
|
164
|
+
function countSchemaOperationUsage(document) {
|
|
165
|
+
const counts = new Map();
|
|
166
|
+
const schemaNames = Object.keys(document.components?.schemas ?? {});
|
|
167
|
+
for (const [, pathItem] of Object.entries(document.paths ?? {})) {
|
|
168
|
+
if (!pathItem)
|
|
169
|
+
continue;
|
|
170
|
+
const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
171
|
+
for (const method of methods) {
|
|
172
|
+
const operation = pathItem[method];
|
|
173
|
+
if (!operation)
|
|
174
|
+
continue;
|
|
175
|
+
const opJson = JSON.stringify(operation);
|
|
176
|
+
for (const name of schemaNames) {
|
|
177
|
+
if (opJson.includes(`"#/components/schemas/${name}"`)) {
|
|
178
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return counts;
|
|
184
|
+
}
|
|
185
|
+
// Schemas referenced by 2+ operations are shared. Schemas referenced by shared schemas are also shared.
|
|
186
|
+
function collectAllSharedSchemaNames(document, opCounts) {
|
|
187
|
+
const shared = new Set();
|
|
188
|
+
for (const [name, count] of opCounts) {
|
|
189
|
+
if (count > 1)
|
|
190
|
+
shared.add(name);
|
|
191
|
+
}
|
|
192
|
+
let changed = true;
|
|
193
|
+
while (changed) {
|
|
194
|
+
changed = false;
|
|
195
|
+
for (const [candidateName] of Object.entries(document.components?.schemas ?? {})) {
|
|
196
|
+
if (shared.has(candidateName))
|
|
197
|
+
continue;
|
|
198
|
+
for (const sharedName of shared) {
|
|
199
|
+
const sharedSchema = document.components?.schemas?.[sharedName];
|
|
200
|
+
if (isRefSchema(sharedSchema))
|
|
201
|
+
continue;
|
|
202
|
+
if (JSON.stringify(sharedSchema).includes(`"#/components/schemas/${candidateName}"`)) {
|
|
203
|
+
shared.add(candidateName);
|
|
204
|
+
changed = true;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return shared;
|
|
211
|
+
}
|
|
120
212
|
function extractParameters(pathItem, operation, packConfig, specPath, diagnostics) {
|
|
121
213
|
const pathItemParams = (pathItem.parameters ?? []);
|
|
122
214
|
const operationParams = (operation.parameters ?? []);
|
|
@@ -137,18 +229,16 @@ function extractParameters(pathItem, operation, packConfig, specPath, diagnostic
|
|
|
137
229
|
if (!schema)
|
|
138
230
|
continue;
|
|
139
231
|
let type;
|
|
140
|
-
let
|
|
232
|
+
let collection;
|
|
141
233
|
if (schema.type === 'array') {
|
|
142
|
-
|
|
234
|
+
collection = 'array';
|
|
143
235
|
const items = schema.items;
|
|
144
236
|
type = deriveScalarType(items?.type ?? 'string', items?.format, packConfig.scalarTypes) ?? 'string';
|
|
145
237
|
}
|
|
146
238
|
else if (schema.enum) {
|
|
147
|
-
cardinality = 'one';
|
|
148
239
|
type = 'string';
|
|
149
240
|
}
|
|
150
241
|
else {
|
|
151
|
-
cardinality = 'one';
|
|
152
242
|
const derived = deriveScalarType(schema.type ?? 'string', schema.format, packConfig.scalarTypes);
|
|
153
243
|
if (!derived) {
|
|
154
244
|
diagnostics.push({ severity: 'warning', file: specPath, message: `Unknown type for parameter ${name}: ${schema.type}/${schema.format}, defaulting to string` });
|
|
@@ -162,7 +252,7 @@ function extractParameters(pathItem, operation, packConfig, specPath, diagnostic
|
|
|
162
252
|
location: param.in,
|
|
163
253
|
type,
|
|
164
254
|
required: param.required ?? false,
|
|
165
|
-
|
|
255
|
+
...(collection ? { collection } : {}),
|
|
166
256
|
};
|
|
167
257
|
}
|
|
168
258
|
return Object.keys(parameters).length > 0 ? parameters : undefined;
|
|
@@ -182,52 +272,165 @@ function makeNode(template, component, specPath, id) {
|
|
|
182
272
|
properties: {},
|
|
183
273
|
};
|
|
184
274
|
}
|
|
185
|
-
function emitSchemaNode(schema, name, parentId, section, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas) {
|
|
275
|
+
function emitSchemaNode(schema, name, parentId, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas) {
|
|
276
|
+
schema = resolveAllOfRef(schema);
|
|
186
277
|
if (isRefSchema(schema)) {
|
|
187
|
-
|
|
278
|
+
const schemaName = refName(schema.$ref);
|
|
279
|
+
const globalId = sharedSchemas.get(schemaName);
|
|
280
|
+
if (globalId) {
|
|
281
|
+
if (rootId)
|
|
282
|
+
emitReadsEdge(rootId, globalId, edges);
|
|
283
|
+
return globalId;
|
|
284
|
+
}
|
|
285
|
+
if (localSchemas.has(schemaName))
|
|
286
|
+
return localSchemas.get(schemaName);
|
|
287
|
+
const sourceSchema = sourceSchemas.get(schemaName);
|
|
288
|
+
if (sourceSchema) {
|
|
289
|
+
const effectiveParent = rootId ?? parentId;
|
|
290
|
+
return createInlineSchema(sourceSchema, schemaName, effectiveParent, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
|
|
291
|
+
}
|
|
292
|
+
return undefined;
|
|
188
293
|
}
|
|
294
|
+
if (localSchemas.has(name))
|
|
295
|
+
return localSchemas.get(name);
|
|
296
|
+
return createInlineSchema(schema, name, parentId, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
|
|
297
|
+
}
|
|
298
|
+
function createInlineSchema(schema, name, parentId, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas) {
|
|
189
299
|
const schemaId = deriveNodeId('schema', undefined, name, parentId, section);
|
|
190
300
|
const [component] = parentId.split('.');
|
|
191
301
|
const node = makeNode(packConfig.constructs.requestSchema?.template ?? 'Schema', component, specPath, schemaId);
|
|
192
302
|
nodes.push(node);
|
|
193
303
|
edges.push({ id: `${parentId}__has-field__${schemaId}`, from: parentId, to: schemaId, type: 'has-field', state: 'implemented', stability: 'unstable' });
|
|
194
|
-
|
|
195
|
-
|
|
304
|
+
const localRef = `#/${section}/${name}`;
|
|
305
|
+
localSchemas.set(name, localRef);
|
|
306
|
+
emitFields(schema, schemaId, 'fields', rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
|
|
307
|
+
return localRef;
|
|
196
308
|
}
|
|
197
|
-
function
|
|
198
|
-
|
|
309
|
+
function resolveFieldRef(schemaName, collection, required, rootId, readsSource, refSchema, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas) {
|
|
310
|
+
const extra = { nullable: !required };
|
|
311
|
+
if (collection !== 'one')
|
|
312
|
+
extra.collection = collection;
|
|
313
|
+
const globalId = sharedSchemas.get(schemaName);
|
|
314
|
+
if (globalId) {
|
|
315
|
+
emitReadsEdge(readsSource, globalId, edges);
|
|
316
|
+
return { $ref: globalId, ...extra };
|
|
317
|
+
}
|
|
318
|
+
if (localSchemas.has(schemaName))
|
|
319
|
+
return { $ref: localSchemas.get(schemaName), ...extra };
|
|
320
|
+
if (rootId) {
|
|
321
|
+
const localRef = emitSchemaNode(refSchema, schemaName, rootId, 'schemas', rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
|
|
322
|
+
if (localRef)
|
|
323
|
+
return { $ref: localRef, ...extra };
|
|
324
|
+
}
|
|
325
|
+
return { $ref: schemaName, ...extra };
|
|
326
|
+
}
|
|
327
|
+
function emitFields(schema, parentId, section, rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas) {
|
|
328
|
+
// readsSource: when in endpoint context use rootId, otherwise use the schema itself
|
|
329
|
+
const readsSource = rootId ?? parentId;
|
|
330
|
+
for (const [fieldName, rawFieldSchema] of Object.entries(schema.properties ?? {})) {
|
|
331
|
+
const fieldSchema = resolveAllOfRef(rawFieldSchema);
|
|
199
332
|
const fieldId = deriveNodeId('field', undefined, fieldName, parentId, section);
|
|
200
333
|
const [component] = parentId.split('.');
|
|
201
334
|
const fieldNode = makeNode(packConfig.constructs.schemaProperty?.template ?? 'Field', component, specPath, fieldId);
|
|
202
335
|
const required = Array.isArray(schema.required) && schema.required.includes(fieldName);
|
|
203
336
|
if (isRefSchema(fieldSchema)) {
|
|
204
|
-
|
|
205
|
-
fieldNode.properties = { $ref: sharedSchemas.get(ref) ?? ref, nullable: !required, cardinality: 'one' };
|
|
337
|
+
fieldNode.properties = resolveFieldRef(refName(fieldSchema.$ref), 'one', required, rootId, readsSource, fieldSchema, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
|
|
206
338
|
}
|
|
207
339
|
else {
|
|
208
340
|
const fs = fieldSchema;
|
|
209
341
|
if (fs.enum && fs.type !== 'object') {
|
|
210
342
|
const enumRef = sharedSchemas.get(fieldName);
|
|
211
|
-
fieldNode.properties = { ...(enumRef ? { $ref: enumRef } : { type: 'string' }), nullable: !required
|
|
343
|
+
fieldNode.properties = { ...(enumRef ? { $ref: enumRef } : { type: 'string' }), nullable: !required };
|
|
212
344
|
}
|
|
213
345
|
else if (fs.type === 'array') {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
346
|
+
const rawItems = fs.items;
|
|
347
|
+
const items = rawItems ? resolveAllOfRef(rawItems) : undefined;
|
|
348
|
+
if (!items) {
|
|
349
|
+
fieldNode.properties = { type: 'string', nullable: !required, collection: 'array' };
|
|
350
|
+
}
|
|
351
|
+
else if (isRefSchema(items)) {
|
|
352
|
+
fieldNode.properties = resolveFieldRef(refName(items.$ref), 'array', required, rootId, readsSource, items, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
const itemType = deriveScalarType(items.type ?? 'string', items.format, packConfig.scalarTypes);
|
|
356
|
+
fieldNode.properties = { type: itemType ?? 'string', nullable: !required, collection: 'array' };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
else if (fs.type === 'object' && fs.properties) {
|
|
360
|
+
// Anonymous object with named properties → inline as sibling schema
|
|
361
|
+
if (rootId) {
|
|
362
|
+
const localRef = emitSchemaNode(fs, fieldName, rootId, 'schemas', rootId, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
|
|
363
|
+
fieldNode.properties = localRef
|
|
364
|
+
? { $ref: localRef, nullable: !required }
|
|
365
|
+
: { type: 'string', nullable: !required };
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
diagnostics.push({ severity: 'warning', file: specPath, message: `Inline object for field ${fieldId} has no endpoint context; treating as string` });
|
|
369
|
+
fieldNode.properties = { type: 'string', nullable: !required };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
else if (fs.type === 'object' && fs.additionalProperties) {
|
|
373
|
+
// Map/dictionary: keyed collection
|
|
374
|
+
const addlRaw = fs.additionalProperties;
|
|
375
|
+
if (typeof addlRaw === 'boolean') {
|
|
376
|
+
fieldNode.properties = { type: 'string', nullable: !required, collection: 'map' };
|
|
217
377
|
}
|
|
218
378
|
else {
|
|
219
|
-
const
|
|
220
|
-
|
|
379
|
+
const addlSchema = resolveAllOfRef(addlRaw);
|
|
380
|
+
if (isRefSchema(addlSchema)) {
|
|
381
|
+
fieldNode.properties = resolveFieldRef(refName(addlSchema.$ref), 'map', required, rootId, readsSource, addlSchema, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
const addlObj = addlSchema;
|
|
385
|
+
if (addlObj.type === 'object') {
|
|
386
|
+
const innerAddl = addlObj.additionalProperties;
|
|
387
|
+
if (!innerAddl || typeof innerAddl === 'boolean') {
|
|
388
|
+
fieldNode.properties = { type: 'string', nullable: !required, collection: 'map-of-map' };
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
const innerSchema = resolveAllOfRef(innerAddl);
|
|
392
|
+
if (isRefSchema(innerSchema)) {
|
|
393
|
+
fieldNode.properties = resolveFieldRef(refName(innerSchema.$ref), 'map-of-map', required, rootId, readsSource, innerSchema, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
const inner = innerSchema;
|
|
397
|
+
const scalarType = deriveScalarType(inner.type ?? 'string', inner.format, packConfig.scalarTypes);
|
|
398
|
+
if (scalarType) {
|
|
399
|
+
fieldNode.properties = { type: scalarType, nullable: !required, collection: 'map-of-map' };
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
diagnostics.push({ severity: 'warning', file: specPath, message: `[WARN] Double-nested map for field ${fieldId}; inner value type not representable, using string` });
|
|
403
|
+
fieldNode.properties = { type: 'string', nullable: !required, collection: 'map-of-map' };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
else if (addlObj.type === 'array') {
|
|
409
|
+
const rawItems = addlObj.items;
|
|
410
|
+
const items = rawItems ? resolveAllOfRef(rawItems) : undefined;
|
|
411
|
+
if (items && isRefSchema(items)) {
|
|
412
|
+
fieldNode.properties = resolveFieldRef(refName(items.$ref), 'map-of-array', required, rootId, readsSource, items, packConfig, specPath, nodes, edges, diagnostics, sharedSchemas, sourceSchemas, localSchemas);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
const scalarType = items ? deriveScalarType(items.type ?? 'string', items.format, packConfig.scalarTypes) : undefined;
|
|
416
|
+
fieldNode.properties = { type: scalarType ?? 'string', nullable: !required, collection: 'map-of-array' };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
const scalarType = deriveScalarType(addlObj.type ?? 'string', addlObj.format, packConfig.scalarTypes);
|
|
421
|
+
fieldNode.properties = { type: scalarType ?? 'string', nullable: !required, collection: 'map' };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
221
424
|
}
|
|
222
425
|
}
|
|
223
426
|
else {
|
|
224
427
|
const scalarType = deriveScalarType(fs.type ?? 'string', fs.format, packConfig.scalarTypes);
|
|
225
428
|
if (scalarType) {
|
|
226
|
-
fieldNode.properties = { type: scalarType, nullable: !required
|
|
429
|
+
fieldNode.properties = { type: scalarType, nullable: !required };
|
|
227
430
|
}
|
|
228
431
|
else {
|
|
229
432
|
diagnostics.push({ severity: 'warning', file: specPath, message: `Unknown type for field ${fieldId}: ${fs.type}/${fs.format}` });
|
|
230
|
-
fieldNode.properties = { type: 'string', nullable: !required
|
|
433
|
+
fieldNode.properties = { type: 'string', nullable: !required };
|
|
231
434
|
}
|
|
232
435
|
}
|
|
233
436
|
}
|
|
@@ -239,7 +442,6 @@ function deriveComponentForSchema(name, document, entry, visited = new Set()) {
|
|
|
239
442
|
if (visited.has(name))
|
|
240
443
|
return undefined;
|
|
241
444
|
visited.add(name);
|
|
242
|
-
// Direct: collect all components whose operations reference this schema
|
|
243
445
|
const directComponents = new Set();
|
|
244
446
|
for (const [urlPath, pathItem] of Object.entries(document.paths ?? {})) {
|
|
245
447
|
if (!pathItem)
|
|
@@ -262,7 +464,6 @@ function deriveComponentForSchema(name, document, entry, visited = new Set()) {
|
|
|
262
464
|
return 'shared';
|
|
263
465
|
if (directComponents.size === 1)
|
|
264
466
|
return [...directComponents][0];
|
|
265
|
-
// Indirect: find another component schema that references this one and use its component
|
|
266
467
|
for (const [schemaName, schema] of Object.entries(document.components?.schemas ?? {})) {
|
|
267
468
|
if (schemaName === name || isRefSchema(schema))
|
|
268
469
|
continue;
|
package/dist/src/graph/index.js
CHANGED
|
@@ -43,15 +43,32 @@ export function getClusterView(graph, nodeId, includeEdgeTypes = []) {
|
|
|
43
43
|
}
|
|
44
44
|
const clusterIds = new Set([cluster.root.id, ...cluster.children.map(node => node.id)]);
|
|
45
45
|
const requestedTypes = new Set(includeEdgeTypes);
|
|
46
|
+
// reads edges are directional (consumer → type); only follow outbound so viewing a shared
|
|
47
|
+
// Schema doesn't pull in every endpoint that references it
|
|
48
|
+
const inboundTypes = new Set([...requestedTypes].filter(t => t !== 'reads'));
|
|
46
49
|
const includedNodeIds = new Set();
|
|
47
50
|
const edges = [...cluster.edges];
|
|
48
51
|
const seen = new Set(cluster.edges.map(edge => edge.id));
|
|
49
52
|
for (const id of clusterIds) {
|
|
50
53
|
for (const edge of graph.edgesByFrom.get(id) ?? []) {
|
|
51
|
-
collectIncludedEdge(edge, requestedTypes, clusterIds, includedNodeIds, edges, seen);
|
|
54
|
+
collectIncludedEdge(edge, requestedTypes, clusterIds, includedNodeIds, edges, seen, graph);
|
|
52
55
|
}
|
|
53
56
|
for (const edge of graph.edgesByTo.get(id) ?? []) {
|
|
54
|
-
collectIncludedEdge(edge,
|
|
57
|
+
collectIncludedEdge(edge, inboundTypes, clusterIds, includedNodeIds, edges, seen, graph);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// BFS over included nodes (outbound only) to transitively pull in referenced schemas
|
|
61
|
+
const processedOutbound = new Set(clusterIds);
|
|
62
|
+
let prevSize = 0;
|
|
63
|
+
while (includedNodeIds.size > prevSize) {
|
|
64
|
+
prevSize = includedNodeIds.size;
|
|
65
|
+
for (const id of includedNodeIds) {
|
|
66
|
+
if (processedOutbound.has(id))
|
|
67
|
+
continue;
|
|
68
|
+
processedOutbound.add(id);
|
|
69
|
+
for (const edge of graph.edgesByFrom.get(id) ?? []) {
|
|
70
|
+
collectIncludedEdge(edge, requestedTypes, clusterIds, includedNodeIds, edges, seen, graph);
|
|
71
|
+
}
|
|
55
72
|
}
|
|
56
73
|
}
|
|
57
74
|
return {
|
|
@@ -97,15 +114,21 @@ function collectMapsTo(edge, edges, nodeIds, seen) {
|
|
|
97
114
|
nodeIds.add(edge.to);
|
|
98
115
|
seen.add(edge.id);
|
|
99
116
|
}
|
|
100
|
-
function collectIncludedEdge(edge, requestedTypes, clusterIds, includedNodeIds, edges, seen) {
|
|
117
|
+
function collectIncludedEdge(edge, requestedTypes, clusterIds, includedNodeIds, edges, seen, graph) {
|
|
101
118
|
if (!requestedTypes.has(edge.type) || seen.has(edge.id))
|
|
102
119
|
return;
|
|
103
120
|
edges.push(edge);
|
|
104
121
|
seen.add(edge.id);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
includedNodeIds.add(
|
|
122
|
+
for (const endId of [edge.from, edge.to]) {
|
|
123
|
+
if (clusterIds.has(endId))
|
|
124
|
+
continue;
|
|
125
|
+
includedNodeIds.add(endId);
|
|
126
|
+
const prefix = `${endId}.`;
|
|
127
|
+
for (const id of graph.nodesById.keys()) {
|
|
128
|
+
if (id.startsWith(prefix))
|
|
129
|
+
includedNodeIds.add(id);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
109
132
|
}
|
|
110
133
|
const OVERLAY_EXCLUDED = new Set(['local', 'shared']);
|
|
111
134
|
export function computeClusterOverlay(multi, viewingRef, overlayRefs, clusterRootId) {
|
|
@@ -28,6 +28,18 @@ export async function runImport(config, runtimeConfig) {
|
|
|
28
28
|
for (const node of [...toAdd, ...toUpdate, ...toRemove]) {
|
|
29
29
|
graph.nodesById.set(node.id, node);
|
|
30
30
|
}
|
|
31
|
+
const STRUCTURAL_EDGE_TYPES = new Set(['has-field', 'has-value']);
|
|
32
|
+
for (const edge of result.edges) {
|
|
33
|
+
if (STRUCTURAL_EDGE_TYPES.has(edge.type))
|
|
34
|
+
continue;
|
|
35
|
+
const existing = graph.edgesByFrom.get(edge.from) ?? [];
|
|
36
|
+
if (!existing.some(e => e.id === edge.id)) {
|
|
37
|
+
const updated = [...existing, edge];
|
|
38
|
+
graph.edgesByFrom.set(edge.from, updated);
|
|
39
|
+
const byTo = graph.edgesByTo.get(edge.to) ?? [];
|
|
40
|
+
graph.edgesByTo.set(edge.to, [...byTo, edge]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
31
43
|
}
|
|
32
44
|
const graphPath = runtimeConfig.kind === 'filesystem' ? runtimeConfig.graphPath : undefined;
|
|
33
45
|
const contentMap = serializeGraph(graph, { sourceGraphPath: graphPath, outputGraphPath: graphPath });
|
package/package.json
CHANGED
package/web/app.jsx
CHANGED
|
@@ -376,7 +376,7 @@ function NodePage({ nodeId, templates, onNavigate, refreshToken, viewingRef, ove
|
|
|
376
376
|
const overlayParam = overlayRefs && overlayRefs.length > 0
|
|
377
377
|
? '&' + overlayRefs.map(ref => `overlayRefs=${encodeURIComponent(ref)}`).join('&')
|
|
378
378
|
: '';
|
|
379
|
-
fetch(`/api/cluster?nodeId=${encodeURIComponent(nodeId)}&includeEdges=maps-to${refParam}${overlayParam}`)
|
|
379
|
+
fetch(`/api/cluster?nodeId=${encodeURIComponent(nodeId)}&includeEdges=maps-to,reads${refParam}${overlayParam}`)
|
|
380
380
|
.then(response => response.ok ? response.json() : Promise.reject(response.status))
|
|
381
381
|
.then(setCluster)
|
|
382
382
|
.catch(err => setError(String(err)));
|
|
@@ -402,12 +402,15 @@ function NodePage({ nodeId, templates, onNavigate, refreshToken, viewingRef, ove
|
|
|
402
402
|
const displayedNodeIds = new Set([
|
|
403
403
|
root.id,
|
|
404
404
|
...Array.from(displayChildren.values()).reduce((all, group) => all.concat(group), []).map(child => child.id),
|
|
405
|
+
...includedNodes.map(n => n.id),
|
|
405
406
|
]);
|
|
406
407
|
const rootSpecializedTemplates = new Set(['Schema', 'EnumDefinition']);
|
|
407
408
|
const rootSpecializedNodes = rootSpecializedTemplates.has(root.template) ? [[root.template, [root]]] : [];
|
|
408
409
|
const childDisplayEntries = [...displayChildren.entries()]
|
|
409
410
|
.filter(([templateName]) => templateName !== 'Field' && templateName !== 'EnumValue');
|
|
410
411
|
const displayEntries = [...rootSpecializedNodes, ...childDisplayEntries];
|
|
412
|
+
const includedSchemaNodes = includedNodes.filter(n => n.template === 'Schema');
|
|
413
|
+
const includedEnumNodes = includedNodes.filter(n => n.template === 'EnumDefinition');
|
|
411
414
|
|
|
412
415
|
function handlePropertyNavigate(targetNodeId) {
|
|
413
416
|
if (displayedNodeIds.has(targetNodeId)) {
|
|
@@ -465,6 +468,28 @@ function NodePage({ nodeId, templates, onNavigate, refreshToken, viewingRef, ove
|
|
|
465
468
|
overlayRefs={cluster.overlay ? cluster.overlay.overlayRefs : null}
|
|
466
469
|
/>
|
|
467
470
|
))}
|
|
471
|
+
{includedSchemaNodes.length > 0 && (
|
|
472
|
+
<SchemaCard
|
|
473
|
+
key="__shared-schemas__"
|
|
474
|
+
title="Schema"
|
|
475
|
+
nodes={includedSchemaNodes}
|
|
476
|
+
allNodes={[root, ...descendants, ...includedNodes]}
|
|
477
|
+
edges={edges}
|
|
478
|
+
anchorIdForNode={anchorIdForNode}
|
|
479
|
+
isShared={true}
|
|
480
|
+
/>
|
|
481
|
+
)}
|
|
482
|
+
{includedEnumNodes.length > 0 && (
|
|
483
|
+
<SchemaCard
|
|
484
|
+
key="__shared-enums__"
|
|
485
|
+
title="EnumDefinition"
|
|
486
|
+
nodes={includedEnumNodes}
|
|
487
|
+
allNodes={[root, ...descendants, ...includedNodes]}
|
|
488
|
+
edges={edges}
|
|
489
|
+
anchorIdForNode={anchorIdForNode}
|
|
490
|
+
isShared={true}
|
|
491
|
+
/>
|
|
492
|
+
)}
|
|
468
493
|
</div>
|
|
469
494
|
);
|
|
470
495
|
}
|
package/web/primitives.jsx
CHANGED
|
@@ -197,17 +197,27 @@ function refName(ref) {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
function refLocalSchemaName(ref) {
|
|
200
|
-
if (typeof ref
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
if (typeof ref !== 'string') {
|
|
201
|
+
if (ref && typeof ref === 'object' && 'display' in ref) {
|
|
202
|
+
const d = String(ref.display);
|
|
203
|
+
const dot = d.lastIndexOf('.');
|
|
204
|
+
return dot >= 0 ? d.slice(dot + 1) : d;
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
if (ref.startsWith('#/schemas/')) return ref.slice(10);
|
|
209
|
+
// Global node ID (e.g. "component.Schema.TypeName") — local name is the final segment
|
|
210
|
+
const lastDot = ref.lastIndexOf('.');
|
|
211
|
+
return lastDot >= 0 ? ref.slice(lastDot + 1) : null;
|
|
203
212
|
}
|
|
204
213
|
|
|
205
214
|
function fieldType(properties) {
|
|
206
|
-
const
|
|
207
|
-
|
|
215
|
+
const c = properties?.collection;
|
|
216
|
+
const suffix = c === 'array' ? '[]' : c === 'map' ? '{}' : c === 'map-of-map' ? '{{}}' : c === 'map-of-array' ? '{[]}' : '';
|
|
217
|
+
if (properties?.type) return `${properties.type}${suffix}`;
|
|
208
218
|
const ref = properties?.['$ref'];
|
|
209
|
-
if (ref) return `${refName(ref)}${
|
|
210
|
-
return
|
|
219
|
+
if (ref) return `${refName(ref)}${suffix}`;
|
|
220
|
+
return suffix ? `unknown${suffix}` : 'unknown';
|
|
211
221
|
}
|
|
212
222
|
|
|
213
223
|
function fieldRequirement(properties) {
|
|
@@ -217,7 +227,7 @@ function fieldRequirement(properties) {
|
|
|
217
227
|
}
|
|
218
228
|
|
|
219
229
|
function fieldCardinality(properties) {
|
|
220
|
-
return properties?.
|
|
230
|
+
return properties?.collection ?? 'one';
|
|
221
231
|
}
|
|
222
232
|
|
|
223
233
|
function fieldDetails(properties) {
|
|
@@ -253,7 +263,11 @@ function enumValueEnumName(nodeId) {
|
|
|
253
263
|
const valueMarker = '.values.';
|
|
254
264
|
const enumIdx = nodeId.indexOf(enumMarker);
|
|
255
265
|
const valueIdx = nodeId.indexOf(valueMarker);
|
|
256
|
-
if (
|
|
266
|
+
if (valueIdx < 0) return null;
|
|
267
|
+
if (enumIdx < 0) {
|
|
268
|
+
// Standalone EnumDefinition node: e.g. component.EnumDefinition.Name.values.X → 'Name'
|
|
269
|
+
return nodeId.slice(0, valueIdx).split('.').pop() ?? null;
|
|
270
|
+
}
|
|
257
271
|
return nodeId.slice(enumIdx + enumMarker.length, valueIdx);
|
|
258
272
|
}
|
|
259
273
|
|
|
@@ -266,7 +280,11 @@ function enumValueDescription(node) {
|
|
|
266
280
|
}
|
|
267
281
|
|
|
268
282
|
function buildSchemaModel(schemaNodes, allNodes) {
|
|
269
|
-
|
|
283
|
+
// Include all Schema nodes from allNodes so canExpand works for included/referenced schemas
|
|
284
|
+
const allSchemaNodes = (allNodes ?? []).filter(n => n.template === 'Schema');
|
|
285
|
+
const schemasByName = new Map(allSchemaNodes.map(node => [localSchemaName(node.id), node]));
|
|
286
|
+
// Ensure the primary schemaNodes are always present (they may not be in allNodes)
|
|
287
|
+
for (const node of schemaNodes) schemasByName.set(localSchemaName(node.id), node);
|
|
270
288
|
const fieldsBySchema = new Map();
|
|
271
289
|
const referencedSchemas = new Set();
|
|
272
290
|
|
|
@@ -317,7 +335,8 @@ function SchemaFieldRows({ schemaName, model, prefix = '', depth = 0, visited =
|
|
|
317
335
|
const canExpand = localRef !== null && model.schemasByName.has(localRef) && !visited.has(localRef);
|
|
318
336
|
const childSchemaNode = canExpand ? model.schemasByName.get(localRef) : null;
|
|
319
337
|
const childGhostFields = childSchemaNode ? overlayFieldsForSchema(overlayFields, childSchemaNode.id) : [];
|
|
320
|
-
const
|
|
338
|
+
const c = field.properties?.collection;
|
|
339
|
+
const childPrefix = `${prefix}${name}${c === 'map-of-map' || c === 'map-of-array' ? '[][].' : c === 'array' || c === 'map' ? '[].' : '.'}`;
|
|
321
340
|
const nextVisited = new Set(visited);
|
|
322
341
|
nextVisited.add(schemaName);
|
|
323
342
|
const links = edges.filter(e =>
|
|
@@ -454,7 +473,7 @@ function OverlayLegend({ overlayRefs }) {
|
|
|
454
473
|
);
|
|
455
474
|
}
|
|
456
475
|
|
|
457
|
-
function SchemaCard({ title, nodes, allNodes, edges, anchorIdForNode, overlayFields, overlayRefs }) {
|
|
476
|
+
function SchemaCard({ title, nodes, allNodes, edges, anchorIdForNode, overlayFields, overlayRefs, isShared }) {
|
|
458
477
|
if (!nodes || nodes.length === 0) return null;
|
|
459
478
|
|
|
460
479
|
if (title === 'EnumDefinition') {
|
|
@@ -469,7 +488,7 @@ function SchemaCard({ title, nodes, allNodes, edges, anchorIdForNode, overlayFie
|
|
|
469
488
|
|
|
470
489
|
return (
|
|
471
490
|
<div className="card enum-card">
|
|
472
|
-
<div className="card-head">Enums</div>
|
|
491
|
+
<div className="card-head">{isShared ? 'Shared Enums' : 'Enums'}</div>
|
|
473
492
|
<div className="card-body">
|
|
474
493
|
{nodes.map(enumNode => {
|
|
475
494
|
const enumName = localEnumName(enumNode.id);
|
|
@@ -513,7 +532,7 @@ function SchemaCard({ title, nodes, allNodes, edges, anchorIdForNode, overlayFie
|
|
|
513
532
|
const model = buildSchemaModel(nodes, allNodes ?? nodes);
|
|
514
533
|
return (
|
|
515
534
|
<div className="card schema-card">
|
|
516
|
-
<div className="card-head">Schemas</div>
|
|
535
|
+
<div className="card-head">{isShared ? 'Shared Schemas' : 'Schemas'}</div>
|
|
517
536
|
<div className="card-body">
|
|
518
537
|
{model.topSchemas.map(schema => {
|
|
519
538
|
const schemaName = localSchemaName(schema.id);
|
|
@@ -521,8 +540,9 @@ function SchemaCard({ title, nodes, allNodes, edges, anchorIdForNode, overlayFie
|
|
|
521
540
|
return (
|
|
522
541
|
<div key={schema.id} className="schema-section" id={anchorIdForNode ? anchorIdForNode(schema.id) : undefined}>
|
|
523
542
|
<div className="schema-section-head">
|
|
524
|
-
<div>
|
|
543
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
525
544
|
<div className="schema-title">{schemaName}</div>
|
|
545
|
+
{isShared && <span className="tag" style={{ fontSize: 10, padding: '1px 6px', background: 'var(--ink-4)', color: 'var(--bg)' }}>shared</span>}
|
|
526
546
|
{schema.properties?.description && <div className="label-sm">{schema.properties.description}</div>}
|
|
527
547
|
</div>
|
|
528
548
|
<div className="label-sm mono">{schema.id}</div>
|
|
@@ -532,7 +552,7 @@ function SchemaCard({ title, nodes, allNodes, edges, anchorIdForNode, overlayFie
|
|
|
532
552
|
<div />
|
|
533
553
|
<div className="label-xs">Name</div>
|
|
534
554
|
<div className="label-xs">Type</div>
|
|
535
|
-
<div className="label-xs">
|
|
555
|
+
<div className="label-xs">Collection</div>
|
|
536
556
|
<div className="label-xs">Req</div>
|
|
537
557
|
<div className="label-xs">State</div>
|
|
538
558
|
<div className="label-xs">Links</div>
|