@globaltypesystem/gts-ts 0.1.0
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/.eslintrc.json +16 -0
- package/.github/workflows/ci.yml +198 -0
- package/.gitmodules +3 -0
- package/.prettierrc +7 -0
- package/LICENSE +201 -0
- package/Makefile +64 -0
- package/README.md +298 -0
- package/dist/cast.d.ts +9 -0
- package/dist/cast.d.ts.map +1 -0
- package/dist/cast.js +153 -0
- package/dist/cast.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +318 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/compatibility.d.ts +11 -0
- package/dist/compatibility.d.ts.map +1 -0
- package/dist/compatibility.js +176 -0
- package/dist/compatibility.js.map +1 -0
- package/dist/extract.d.ts +13 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +194 -0
- package/dist/extract.js.map +1 -0
- package/dist/gts.d.ts +18 -0
- package/dist/gts.d.ts.map +1 -0
- package/dist/gts.js +472 -0
- package/dist/gts.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +97 -0
- package/dist/index.js.map +1 -0
- package/dist/query.d.ts +10 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +171 -0
- package/dist/query.js.map +1 -0
- package/dist/relationships.d.ts +7 -0
- package/dist/relationships.d.ts.map +1 -0
- package/dist/relationships.js +80 -0
- package/dist/relationships.js.map +1 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +132 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/server.d.ts +33 -0
- package/dist/server/server.d.ts.map +1 -0
- package/dist/server/server.js +678 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/types.d.ts +61 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +3 -0
- package/dist/server/types.js.map +1 -0
- package/dist/store.d.ts +39 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +1026 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +111 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/types.js.map +1 -0
- package/dist/x-gts-ref.d.ts +35 -0
- package/dist/x-gts-ref.d.ts.map +1 -0
- package/dist/x-gts-ref.js +304 -0
- package/dist/x-gts-ref.js.map +1 -0
- package/jest.config.js +13 -0
- package/package.json +54 -0
- package/src/cast.ts +179 -0
- package/src/cli/index.ts +315 -0
- package/src/compatibility.ts +201 -0
- package/src/extract.ts +213 -0
- package/src/gts.ts +550 -0
- package/src/index.ts +97 -0
- package/src/query.ts +191 -0
- package/src/relationships.ts +91 -0
- package/src/server/index.ts +112 -0
- package/src/server/server.ts +771 -0
- package/src/server/types.ts +74 -0
- package/src/store.ts +1178 -0
- package/src/types.ts +138 -0
- package/src/x-gts-ref.ts +349 -0
- package/tests/gts.test.ts +525 -0
- package/tsconfig.json +32 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
import Ajv from 'ajv';
|
|
2
|
+
import { GtsConfig, JsonEntity, ValidationResult, GTS_URI_PREFIX } from './types';
|
|
3
|
+
import { Gts } from './gts';
|
|
4
|
+
import { GtsExtractor } from './extract';
|
|
5
|
+
import { XGtsRefValidator } from './x-gts-ref';
|
|
6
|
+
|
|
7
|
+
export class GtsStore {
|
|
8
|
+
private byId: Map<string, JsonEntity> = new Map();
|
|
9
|
+
private config: GtsConfig;
|
|
10
|
+
private ajv: Ajv;
|
|
11
|
+
|
|
12
|
+
constructor(config?: Partial<GtsConfig>) {
|
|
13
|
+
this.config = {
|
|
14
|
+
validateRefs: config?.validateRefs ?? false,
|
|
15
|
+
strictMode: config?.strictMode ?? false,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
this.ajv = new Ajv({
|
|
19
|
+
strict: false,
|
|
20
|
+
validateSchema: false,
|
|
21
|
+
addUsedSchema: false,
|
|
22
|
+
loadSchema: this.loadSchema.bind(this),
|
|
23
|
+
validateFormats: false, // Disable format validation to match Go implementation
|
|
24
|
+
});
|
|
25
|
+
// Don't add format validators since Go uses lenient validation
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private async loadSchema(uri: string): Promise<any> {
|
|
29
|
+
const normalizedUri = uri.startsWith(GTS_URI_PREFIX) ? uri.substring(GTS_URI_PREFIX.length) : uri;
|
|
30
|
+
|
|
31
|
+
if (Gts.isValidGtsID(normalizedUri)) {
|
|
32
|
+
const entity = this.get(normalizedUri);
|
|
33
|
+
if (entity && entity.isSchema) {
|
|
34
|
+
return entity.content;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`Unresolvable GTS reference: ${uri}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
register(entity: JsonEntity): void {
|
|
41
|
+
if (this.config.validateRefs) {
|
|
42
|
+
for (const ref of entity.references) {
|
|
43
|
+
if (!this.byId.has(ref)) {
|
|
44
|
+
throw new Error(`Unresolved reference: ${ref}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
this.byId.set(entity.id, entity);
|
|
49
|
+
|
|
50
|
+
// If this is a schema, add it to AJV for reference resolution
|
|
51
|
+
if (entity.isSchema && entity.content) {
|
|
52
|
+
try {
|
|
53
|
+
const normalizedSchema = this.normalizeSchema(entity.content);
|
|
54
|
+
// Set $id to the GTS ID if not already set
|
|
55
|
+
if (!normalizedSchema.$id) {
|
|
56
|
+
normalizedSchema.$id = entity.id;
|
|
57
|
+
}
|
|
58
|
+
this.ajv.addSchema(normalizedSchema, entity.id);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
// Ignore errors adding schema - it might already exist or be invalid
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get(id: string): JsonEntity | undefined {
|
|
66
|
+
return this.byId.get(id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getAll(): JsonEntity[] {
|
|
70
|
+
return Array.from(this.byId.values());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
query(pattern: string, limit?: number): string[] {
|
|
74
|
+
const results: string[] = [];
|
|
75
|
+
const maxResults = limit ?? Number.MAX_SAFE_INTEGER;
|
|
76
|
+
|
|
77
|
+
for (const [id] of this.byId) {
|
|
78
|
+
if (results.length >= maxResults) break;
|
|
79
|
+
|
|
80
|
+
const matchResult = Gts.matchIDPattern(id, pattern);
|
|
81
|
+
if (matchResult.match) {
|
|
82
|
+
results.push(id);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return results;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
validateInstance(gtsId: string): ValidationResult {
|
|
90
|
+
try {
|
|
91
|
+
const gid = Gts.parseGtsID(gtsId);
|
|
92
|
+
|
|
93
|
+
const obj = this.get(gid.id);
|
|
94
|
+
if (!obj) {
|
|
95
|
+
return {
|
|
96
|
+
id: gtsId,
|
|
97
|
+
ok: false,
|
|
98
|
+
valid: false,
|
|
99
|
+
error: `Entity not found: ${gtsId}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!obj.schemaId) {
|
|
104
|
+
return {
|
|
105
|
+
id: gtsId,
|
|
106
|
+
ok: false,
|
|
107
|
+
valid: false,
|
|
108
|
+
error: `No schema found for instance: ${gtsId}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const schemaEntity = this.get(obj.schemaId);
|
|
113
|
+
if (!schemaEntity) {
|
|
114
|
+
return {
|
|
115
|
+
id: gtsId,
|
|
116
|
+
ok: false,
|
|
117
|
+
valid: false,
|
|
118
|
+
error: `Schema not found: ${obj.schemaId}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!schemaEntity.isSchema) {
|
|
123
|
+
return {
|
|
124
|
+
id: gtsId,
|
|
125
|
+
ok: false,
|
|
126
|
+
valid: false,
|
|
127
|
+
error: `Entity '${obj.schemaId}' is not a schema`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const validate = this.ajv.compile(this.normalizeSchema(schemaEntity.content));
|
|
132
|
+
const isValid = validate(obj.content);
|
|
133
|
+
|
|
134
|
+
if (!isValid) {
|
|
135
|
+
const errors =
|
|
136
|
+
validate.errors
|
|
137
|
+
?.map((e) => {
|
|
138
|
+
if (e.keyword === 'required') {
|
|
139
|
+
return `${e.instancePath || '/'} must have required property '${(e.params as any)?.missingProperty}'`;
|
|
140
|
+
}
|
|
141
|
+
return `${e.instancePath} ${e.message}`;
|
|
142
|
+
})
|
|
143
|
+
.join('; ') || 'Validation failed';
|
|
144
|
+
return {
|
|
145
|
+
id: gtsId,
|
|
146
|
+
ok: false,
|
|
147
|
+
valid: false,
|
|
148
|
+
error: errors,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Validate x-gts-ref constraints
|
|
153
|
+
const xGtsRefValidator = new XGtsRefValidator(this);
|
|
154
|
+
const xGtsRefErrors = xGtsRefValidator.validateInstance(obj.content, schemaEntity.content);
|
|
155
|
+
if (xGtsRefErrors.length > 0) {
|
|
156
|
+
const errorMsgs = xGtsRefErrors.map((err) => err.reason).join('; ');
|
|
157
|
+
return {
|
|
158
|
+
id: gtsId,
|
|
159
|
+
ok: false,
|
|
160
|
+
valid: false,
|
|
161
|
+
error: `x-gts-ref validation failed: ${errorMsgs}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
id: gtsId,
|
|
167
|
+
ok: true,
|
|
168
|
+
valid: true,
|
|
169
|
+
error: '',
|
|
170
|
+
};
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return {
|
|
173
|
+
id: gtsId,
|
|
174
|
+
ok: false,
|
|
175
|
+
valid: false,
|
|
176
|
+
error: error instanceof Error ? error.message : String(error),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private normalizeSchema(schema: any): any {
|
|
182
|
+
return this.normalizeSchemaRecursive(schema);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private normalizeSchemaRecursive(obj: any): any {
|
|
186
|
+
if (obj === null || typeof obj !== 'object') {
|
|
187
|
+
return obj;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (Array.isArray(obj)) {
|
|
191
|
+
return obj.map((item) => this.normalizeSchemaRecursive(item));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const normalized: any = {};
|
|
195
|
+
|
|
196
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
197
|
+
let newKey = key;
|
|
198
|
+
let newValue = value;
|
|
199
|
+
|
|
200
|
+
// Convert $$ prefixed keys to $ prefixed keys
|
|
201
|
+
switch (key) {
|
|
202
|
+
case '$$id':
|
|
203
|
+
newKey = '$id';
|
|
204
|
+
break;
|
|
205
|
+
case '$$schema':
|
|
206
|
+
newKey = '$schema';
|
|
207
|
+
break;
|
|
208
|
+
case '$$ref':
|
|
209
|
+
newKey = '$ref';
|
|
210
|
+
break;
|
|
211
|
+
case '$$defs':
|
|
212
|
+
newKey = '$defs';
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Recursively normalize nested objects
|
|
217
|
+
if (value && typeof value === 'object') {
|
|
218
|
+
newValue = this.normalizeSchemaRecursive(value);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
normalized[newKey] = newValue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Normalize $id values
|
|
225
|
+
if (normalized['$id'] && typeof normalized['$id'] === 'string') {
|
|
226
|
+
if (normalized['$id'].startsWith(GTS_URI_PREFIX)) {
|
|
227
|
+
normalized['$id'] = normalized['$id'].substring(GTS_URI_PREFIX.length);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Normalize $ref values
|
|
232
|
+
if (normalized['$ref'] && typeof normalized['$ref'] === 'string') {
|
|
233
|
+
if (normalized['$ref'].startsWith(GTS_URI_PREFIX)) {
|
|
234
|
+
normalized['$ref'] = normalized['$ref'].substring(GTS_URI_PREFIX.length);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return normalized;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
resolveRelationships(gtsId: string): any {
|
|
242
|
+
const seen = new Set<string>();
|
|
243
|
+
return this.buildSchemaGraphNode(gtsId, seen);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private buildSchemaGraphNode(gtsId: string, seen: Set<string>): any {
|
|
247
|
+
const node: any = {
|
|
248
|
+
id: gtsId,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Check for cycles
|
|
252
|
+
if (seen.has(gtsId)) {
|
|
253
|
+
return node;
|
|
254
|
+
}
|
|
255
|
+
seen.add(gtsId);
|
|
256
|
+
|
|
257
|
+
// Get the entity from store
|
|
258
|
+
const entity = this.get(gtsId);
|
|
259
|
+
if (!entity) {
|
|
260
|
+
node.errors = ['Entity not found'];
|
|
261
|
+
return node;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Process GTS references found in the entity
|
|
265
|
+
const refs = this.extractGtsReferences(entity.content);
|
|
266
|
+
const nodeRefs: any = {};
|
|
267
|
+
|
|
268
|
+
for (const ref of refs) {
|
|
269
|
+
// Skip self-references
|
|
270
|
+
if (ref.id === gtsId) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// Skip JSON Schema meta-schema references
|
|
274
|
+
if (this.isJsonSchemaUrl(ref.id)) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
// Recursively build node for this reference
|
|
278
|
+
nodeRefs[ref.sourcePath] = this.buildSchemaGraphNode(ref.id, seen);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (Object.keys(nodeRefs).length > 0) {
|
|
282
|
+
node.refs = nodeRefs;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Process schema ID if present
|
|
286
|
+
if (entity.schemaId) {
|
|
287
|
+
if (!this.isJsonSchemaUrl(entity.schemaId)) {
|
|
288
|
+
node.schema_id = this.buildSchemaGraphNode(entity.schemaId, seen);
|
|
289
|
+
}
|
|
290
|
+
} else if (!entity.isSchema) {
|
|
291
|
+
// Instance without schema ID is an error
|
|
292
|
+
node.errors = node.errors || [];
|
|
293
|
+
node.errors.push('Schema not recognized');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return node;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private extractGtsReferences(content: any): Array<{ id: string; sourcePath: string }> {
|
|
300
|
+
const refs: Array<{ id: string; sourcePath: string }> = [];
|
|
301
|
+
const seen = new Set<string>();
|
|
302
|
+
|
|
303
|
+
const walkAndCollectRefs = (node: any, path: string) => {
|
|
304
|
+
if (node === null || node === undefined) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Check if current node is a GTS ID string
|
|
309
|
+
if (typeof node === 'string') {
|
|
310
|
+
if (Gts.isValidGtsID(node)) {
|
|
311
|
+
const sourcePath = path || 'root';
|
|
312
|
+
const key = `${node}|${sourcePath}`;
|
|
313
|
+
if (!seen.has(key)) {
|
|
314
|
+
refs.push({ id: node, sourcePath });
|
|
315
|
+
seen.add(key);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Recurse into object
|
|
322
|
+
if (typeof node === 'object' && !Array.isArray(node)) {
|
|
323
|
+
for (const [k, v] of Object.entries(node)) {
|
|
324
|
+
const nextPath = path ? `${path}.${k}` : k;
|
|
325
|
+
walkAndCollectRefs(v, nextPath);
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Recurse into array
|
|
331
|
+
if (Array.isArray(node)) {
|
|
332
|
+
for (let i = 0; i < node.length; i++) {
|
|
333
|
+
const nextPath = path ? `${path}[${i}]` : `[${i}]`;
|
|
334
|
+
walkAndCollectRefs(node[i], nextPath);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
walkAndCollectRefs(content, '');
|
|
340
|
+
return refs;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private isJsonSchemaUrl(s: string): boolean {
|
|
344
|
+
return (s.startsWith('http://') || s.startsWith('https://')) && s.includes('json-schema.org');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
checkCompatibility(oldSchemaId: string, newSchemaId: string, _mode?: string): any {
|
|
348
|
+
const oldEntity = this.get(oldSchemaId);
|
|
349
|
+
const newEntity = this.get(newSchemaId);
|
|
350
|
+
|
|
351
|
+
if (!oldEntity || !newEntity) {
|
|
352
|
+
return {
|
|
353
|
+
from: oldSchemaId,
|
|
354
|
+
to: newSchemaId,
|
|
355
|
+
old: oldSchemaId,
|
|
356
|
+
new: newSchemaId,
|
|
357
|
+
direction: 'unknown',
|
|
358
|
+
added_properties: [],
|
|
359
|
+
removed_properties: [],
|
|
360
|
+
changed_properties: [],
|
|
361
|
+
is_fully_compatible: false,
|
|
362
|
+
is_backward_compatible: false,
|
|
363
|
+
is_forward_compatible: false,
|
|
364
|
+
incompatibility_reasons: [],
|
|
365
|
+
backward_errors: ['Schema not found'],
|
|
366
|
+
forward_errors: ['Schema not found'],
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const oldSchema = oldEntity.content;
|
|
371
|
+
const newSchema = newEntity.content;
|
|
372
|
+
|
|
373
|
+
if (!oldSchema || !newSchema) {
|
|
374
|
+
return {
|
|
375
|
+
from: oldSchemaId,
|
|
376
|
+
to: newSchemaId,
|
|
377
|
+
old: oldSchemaId,
|
|
378
|
+
new: newSchemaId,
|
|
379
|
+
direction: 'unknown',
|
|
380
|
+
added_properties: [],
|
|
381
|
+
removed_properties: [],
|
|
382
|
+
changed_properties: [],
|
|
383
|
+
is_fully_compatible: false,
|
|
384
|
+
is_backward_compatible: false,
|
|
385
|
+
is_forward_compatible: false,
|
|
386
|
+
incompatibility_reasons: [],
|
|
387
|
+
backward_errors: ['Invalid schema content'],
|
|
388
|
+
forward_errors: ['Invalid schema content'],
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Check compatibility
|
|
393
|
+
const { isBackward, backwardErrors } = this.checkBackwardCompatibility(oldSchema, newSchema);
|
|
394
|
+
const { isForward, forwardErrors } = this.checkForwardCompatibility(oldSchema, newSchema);
|
|
395
|
+
|
|
396
|
+
// Determine direction
|
|
397
|
+
const direction = this.inferDirection(oldSchemaId, newSchemaId);
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
from: oldSchemaId,
|
|
401
|
+
to: newSchemaId,
|
|
402
|
+
old: oldSchemaId,
|
|
403
|
+
new: newSchemaId,
|
|
404
|
+
direction,
|
|
405
|
+
added_properties: [],
|
|
406
|
+
removed_properties: [],
|
|
407
|
+
changed_properties: [],
|
|
408
|
+
is_fully_compatible: isBackward && isForward,
|
|
409
|
+
is_backward_compatible: isBackward,
|
|
410
|
+
is_forward_compatible: isForward,
|
|
411
|
+
incompatibility_reasons: [],
|
|
412
|
+
backward_errors: backwardErrors,
|
|
413
|
+
forward_errors: forwardErrors,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private inferDirection(fromId: string, toId: string): string {
|
|
418
|
+
try {
|
|
419
|
+
const fromGtsId = Gts.parseGtsID(fromId);
|
|
420
|
+
const toGtsId = Gts.parseGtsID(toId);
|
|
421
|
+
|
|
422
|
+
if (!fromGtsId.segments.length || !toGtsId.segments.length) {
|
|
423
|
+
return 'unknown';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const fromSeg = fromGtsId.segments[fromGtsId.segments.length - 1];
|
|
427
|
+
const toSeg = toGtsId.segments[toGtsId.segments.length - 1];
|
|
428
|
+
|
|
429
|
+
if (fromSeg.verMinor !== undefined && toSeg.verMinor !== undefined) {
|
|
430
|
+
if (toSeg.verMinor > fromSeg.verMinor) {
|
|
431
|
+
return 'up';
|
|
432
|
+
}
|
|
433
|
+
if (toSeg.verMinor < fromSeg.verMinor) {
|
|
434
|
+
return 'down';
|
|
435
|
+
}
|
|
436
|
+
return 'none';
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return 'unknown';
|
|
440
|
+
} catch {
|
|
441
|
+
return 'unknown';
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private checkBackwardCompatibility(
|
|
446
|
+
oldSchema: any,
|
|
447
|
+
newSchema: any
|
|
448
|
+
): { isBackward: boolean; backwardErrors: string[] } {
|
|
449
|
+
return this.checkSchemaCompatibility(oldSchema, newSchema, true);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private checkForwardCompatibility(oldSchema: any, newSchema: any): { isForward: boolean; forwardErrors: string[] } {
|
|
453
|
+
return this.checkSchemaCompatibility(oldSchema, newSchema, false);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private checkSchemaCompatibility(oldSchema: any, newSchema: any, checkBackward: boolean): any {
|
|
457
|
+
const errors: string[] = [];
|
|
458
|
+
|
|
459
|
+
// Flatten schemas to handle allOf
|
|
460
|
+
const oldFlat = this.flattenSchema(oldSchema);
|
|
461
|
+
const newFlat = this.flattenSchema(newSchema);
|
|
462
|
+
|
|
463
|
+
const oldProps = oldFlat.properties || {};
|
|
464
|
+
const newProps = newFlat.properties || {};
|
|
465
|
+
const oldRequired = new Set(oldFlat.required || []);
|
|
466
|
+
const newRequired = new Set(newFlat.required || []);
|
|
467
|
+
|
|
468
|
+
// Check required properties changes
|
|
469
|
+
if (checkBackward) {
|
|
470
|
+
// Backward: cannot add required properties
|
|
471
|
+
const newlyRequired = Array.from(newRequired).filter((p) => !oldRequired.has(p));
|
|
472
|
+
if (newlyRequired.length > 0) {
|
|
473
|
+
errors.push(`Added required properties: ${newlyRequired.join(', ')}`);
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
// Forward: cannot remove required properties
|
|
477
|
+
const removedRequired = Array.from(oldRequired).filter((p) => !newRequired.has(p));
|
|
478
|
+
if (removedRequired.length > 0) {
|
|
479
|
+
errors.push(`Removed required properties: ${removedRequired.join(', ')}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Check properties that exist in both schemas
|
|
484
|
+
const commonProps = Object.keys(oldProps).filter((k) => k in newProps);
|
|
485
|
+
for (const prop of commonProps) {
|
|
486
|
+
const oldPropSchema = oldProps[prop] || {};
|
|
487
|
+
const newPropSchema = newProps[prop] || {};
|
|
488
|
+
|
|
489
|
+
// Check if type changed
|
|
490
|
+
const oldType = oldPropSchema.type;
|
|
491
|
+
const newType = newPropSchema.type;
|
|
492
|
+
if (oldType && newType && oldType !== newType) {
|
|
493
|
+
errors.push(`Property '${prop}' type changed from ${oldType} to ${newType}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Check enum constraints
|
|
497
|
+
const oldEnum = oldPropSchema.enum || [];
|
|
498
|
+
const newEnum = newPropSchema.enum || [];
|
|
499
|
+
if (oldEnum.length > 0 && newEnum.length > 0) {
|
|
500
|
+
const oldEnumSet = new Set(oldEnum);
|
|
501
|
+
const newEnumSet = new Set(newEnum);
|
|
502
|
+
if (checkBackward) {
|
|
503
|
+
// Backward: cannot add enum values
|
|
504
|
+
const addedEnumValues = newEnum.filter((v: any) => !oldEnumSet.has(v));
|
|
505
|
+
if (addedEnumValues.length > 0) {
|
|
506
|
+
errors.push(`Property '${prop}' added enum values: ${addedEnumValues.join(', ')}`);
|
|
507
|
+
}
|
|
508
|
+
} else {
|
|
509
|
+
// Forward: cannot remove enum values
|
|
510
|
+
const removedEnumValues = oldEnum.filter((v: any) => !newEnumSet.has(v));
|
|
511
|
+
if (removedEnumValues.length > 0) {
|
|
512
|
+
errors.push(`Property '${prop}' removed enum values: ${removedEnumValues.join(', ')}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Check constraint compatibility
|
|
518
|
+
errors.push(...this.checkConstraintCompatibility(prop, oldPropSchema, newPropSchema, checkBackward));
|
|
519
|
+
|
|
520
|
+
// Recursively check nested object properties
|
|
521
|
+
if (oldType === 'object' && newType === 'object') {
|
|
522
|
+
const nestedResult = this.checkSchemaCompatibility(oldPropSchema, newPropSchema, checkBackward);
|
|
523
|
+
const nestedErrors = checkBackward ? nestedResult.backwardErrors : nestedResult.forwardErrors;
|
|
524
|
+
if (nestedErrors) {
|
|
525
|
+
errors.push(...nestedErrors.map((e: string) => `Property '${prop}': ${e}`));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Recursively check array item schemas
|
|
530
|
+
if (oldType === 'array' && newType === 'array' && oldPropSchema.items && newPropSchema.items) {
|
|
531
|
+
const itemsResult = this.checkSchemaCompatibility(oldPropSchema.items, newPropSchema.items, checkBackward);
|
|
532
|
+
const itemsErrors = checkBackward ? itemsResult.backwardErrors : itemsResult.forwardErrors;
|
|
533
|
+
if (itemsErrors) {
|
|
534
|
+
errors.push(...itemsErrors.map((e: string) => `Property '${prop}' array items: ${e}`));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (checkBackward) {
|
|
540
|
+
return { isBackward: errors.length === 0, backwardErrors: errors };
|
|
541
|
+
} else {
|
|
542
|
+
return { isForward: errors.length === 0, forwardErrors: errors };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private checkConstraintCompatibility(
|
|
547
|
+
prop: string,
|
|
548
|
+
oldPropSchema: any,
|
|
549
|
+
newPropSchema: any,
|
|
550
|
+
checkTightening: boolean
|
|
551
|
+
): string[] {
|
|
552
|
+
const errors: string[] = [];
|
|
553
|
+
const propType = oldPropSchema.type;
|
|
554
|
+
|
|
555
|
+
// Numeric constraints
|
|
556
|
+
if (propType === 'number' || propType === 'integer') {
|
|
557
|
+
errors.push(
|
|
558
|
+
...this.checkMinMaxConstraint(prop, oldPropSchema, newPropSchema, 'minimum', 'maximum', checkTightening)
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// String constraints
|
|
563
|
+
if (propType === 'string') {
|
|
564
|
+
errors.push(
|
|
565
|
+
...this.checkMinMaxConstraint(prop, oldPropSchema, newPropSchema, 'minLength', 'maxLength', checkTightening)
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Array constraints
|
|
570
|
+
if (propType === 'array') {
|
|
571
|
+
errors.push(
|
|
572
|
+
...this.checkMinMaxConstraint(prop, oldPropSchema, newPropSchema, 'minItems', 'maxItems', checkTightening)
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return errors;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private checkMinMaxConstraint(
|
|
580
|
+
prop: string,
|
|
581
|
+
oldSchema: any,
|
|
582
|
+
newSchema: any,
|
|
583
|
+
minKey: string,
|
|
584
|
+
maxKey: string,
|
|
585
|
+
checkTightening: boolean
|
|
586
|
+
): string[] {
|
|
587
|
+
const errors: string[] = [];
|
|
588
|
+
|
|
589
|
+
const oldMin = oldSchema[minKey];
|
|
590
|
+
const newMin = newSchema[minKey];
|
|
591
|
+
const oldMax = oldSchema[maxKey];
|
|
592
|
+
const newMax = newSchema[maxKey];
|
|
593
|
+
|
|
594
|
+
// Check minimum constraint
|
|
595
|
+
if (checkTightening) {
|
|
596
|
+
// Backward: cannot increase minimum (tighten)
|
|
597
|
+
if (oldMin !== undefined && newMin !== undefined && newMin > oldMin) {
|
|
598
|
+
errors.push(`Property '${prop}' ${minKey} increased from ${oldMin} to ${newMin}`);
|
|
599
|
+
} else if (oldMin === undefined && newMin !== undefined) {
|
|
600
|
+
errors.push(`Property '${prop}' added ${minKey} constraint: ${newMin}`);
|
|
601
|
+
}
|
|
602
|
+
} else {
|
|
603
|
+
// Forward: cannot decrease minimum (relax)
|
|
604
|
+
if (oldMin !== undefined && newMin !== undefined && newMin < oldMin) {
|
|
605
|
+
errors.push(`Property '${prop}' ${minKey} decreased from ${oldMin} to ${newMin}`);
|
|
606
|
+
} else if (oldMin !== undefined && newMin === undefined) {
|
|
607
|
+
errors.push(`Property '${prop}' removed ${minKey} constraint`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Check maximum constraint
|
|
612
|
+
if (checkTightening) {
|
|
613
|
+
// Backward: cannot decrease maximum (tighten)
|
|
614
|
+
if (oldMax !== undefined && newMax !== undefined && newMax < oldMax) {
|
|
615
|
+
errors.push(`Property '${prop}' ${maxKey} decreased from ${oldMax} to ${newMax}`);
|
|
616
|
+
} else if (oldMax === undefined && newMax !== undefined) {
|
|
617
|
+
errors.push(`Property '${prop}' added ${maxKey} constraint: ${newMax}`);
|
|
618
|
+
}
|
|
619
|
+
} else {
|
|
620
|
+
// Forward: cannot increase maximum (relax)
|
|
621
|
+
if (oldMax !== undefined && newMax !== undefined && newMax > oldMax) {
|
|
622
|
+
errors.push(`Property '${prop}' ${maxKey} increased from ${oldMax} to ${newMax}`);
|
|
623
|
+
} else if (oldMax !== undefined && newMax === undefined) {
|
|
624
|
+
errors.push(`Property '${prop}' removed ${maxKey} constraint`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return errors;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private flattenSchema(schema: any): any {
|
|
632
|
+
const result: any = {
|
|
633
|
+
properties: {},
|
|
634
|
+
required: [],
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
// Merge allOf schemas
|
|
638
|
+
if (schema.allOf && Array.isArray(schema.allOf)) {
|
|
639
|
+
for (const subSchema of schema.allOf) {
|
|
640
|
+
const flattened = this.flattenSchema(subSchema);
|
|
641
|
+
|
|
642
|
+
// Merge properties
|
|
643
|
+
Object.assign(result.properties, flattened.properties || {});
|
|
644
|
+
|
|
645
|
+
// Merge required
|
|
646
|
+
if (flattened.required && Array.isArray(flattened.required)) {
|
|
647
|
+
result.required.push(...flattened.required);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Preserve additionalProperties
|
|
651
|
+
if (flattened.additionalProperties !== undefined) {
|
|
652
|
+
result.additionalProperties = flattened.additionalProperties;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Add direct properties
|
|
658
|
+
if (schema.properties) {
|
|
659
|
+
Object.assign(result.properties, schema.properties);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Add direct required
|
|
663
|
+
if (schema.required && Array.isArray(schema.required)) {
|
|
664
|
+
result.required.push(...schema.required);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Top level additionalProperties overrides
|
|
668
|
+
if (schema.additionalProperties !== undefined) {
|
|
669
|
+
result.additionalProperties = schema.additionalProperties;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return result;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
castInstance(instanceId: string, toSchemaId: string): any {
|
|
676
|
+
try {
|
|
677
|
+
// Get instance entity
|
|
678
|
+
const instanceEntity = this.get(instanceId);
|
|
679
|
+
if (!instanceEntity) {
|
|
680
|
+
return {
|
|
681
|
+
instance_id: instanceId,
|
|
682
|
+
to_schema_id: toSchemaId,
|
|
683
|
+
ok: false,
|
|
684
|
+
error: `Entity not found: ${instanceId}`,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Get target schema
|
|
689
|
+
const toSchema = this.get(toSchemaId);
|
|
690
|
+
if (!toSchema) {
|
|
691
|
+
return {
|
|
692
|
+
instance_id: instanceId,
|
|
693
|
+
to_schema_id: toSchemaId,
|
|
694
|
+
ok: false,
|
|
695
|
+
error: `Schema not found: ${toSchemaId}`,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Determine source schema
|
|
700
|
+
let fromSchemaId: string;
|
|
701
|
+
let fromSchema: any;
|
|
702
|
+
if (instanceEntity.isSchema) {
|
|
703
|
+
// Not allowed to cast directly from a schema
|
|
704
|
+
return {
|
|
705
|
+
instance_id: instanceId,
|
|
706
|
+
to_schema_id: toSchemaId,
|
|
707
|
+
ok: false,
|
|
708
|
+
error: 'Source must be an instance, not a schema',
|
|
709
|
+
};
|
|
710
|
+
} else {
|
|
711
|
+
// Casting an instance - need to find its schema
|
|
712
|
+
fromSchemaId = instanceEntity.schemaId!;
|
|
713
|
+
if (!fromSchemaId) {
|
|
714
|
+
return {
|
|
715
|
+
instance_id: instanceId,
|
|
716
|
+
to_schema_id: toSchemaId,
|
|
717
|
+
ok: false,
|
|
718
|
+
error: `Schema not found for instance: ${instanceId}`,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
// Don't try to get a JSON Schema URL as a GTS entity
|
|
722
|
+
if (fromSchemaId.startsWith('http://') || fromSchemaId.startsWith('https://')) {
|
|
723
|
+
return {
|
|
724
|
+
instance_id: instanceId,
|
|
725
|
+
to_schema_id: toSchemaId,
|
|
726
|
+
ok: false,
|
|
727
|
+
error: `Cannot cast instance with schema ${fromSchemaId}`,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
fromSchema = this.get(fromSchemaId);
|
|
731
|
+
if (!fromSchema) {
|
|
732
|
+
return {
|
|
733
|
+
instance_id: instanceId,
|
|
734
|
+
to_schema_id: toSchemaId,
|
|
735
|
+
ok: false,
|
|
736
|
+
error: `Schema not found: ${fromSchemaId}`,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Get content
|
|
742
|
+
const instanceContent = instanceEntity.content;
|
|
743
|
+
const fromSchemaContent = fromSchema.content;
|
|
744
|
+
const toSchemaContent = toSchema.content;
|
|
745
|
+
|
|
746
|
+
// Perform the cast
|
|
747
|
+
return this.performCast(instanceId, toSchemaId, instanceContent, fromSchemaContent, toSchemaContent);
|
|
748
|
+
} catch (error) {
|
|
749
|
+
return {
|
|
750
|
+
instance_id: instanceId,
|
|
751
|
+
to_schema_id: toSchemaId,
|
|
752
|
+
ok: false,
|
|
753
|
+
error: error instanceof Error ? error.message : String(error),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
private performCast(
|
|
759
|
+
fromInstanceId: string,
|
|
760
|
+
toSchemaId: string,
|
|
761
|
+
fromInstanceContent: any,
|
|
762
|
+
fromSchemaContent: any,
|
|
763
|
+
toSchemaContent: any
|
|
764
|
+
): any {
|
|
765
|
+
// Flatten target schema to merge allOf
|
|
766
|
+
const targetSchema = this.flattenSchema(toSchemaContent);
|
|
767
|
+
|
|
768
|
+
// Determine direction
|
|
769
|
+
const direction = this.inferDirection(fromInstanceId, toSchemaId);
|
|
770
|
+
|
|
771
|
+
// Determine which is old/new based on direction
|
|
772
|
+
let oldSchema: any;
|
|
773
|
+
let newSchema: any;
|
|
774
|
+
switch (direction) {
|
|
775
|
+
case 'up':
|
|
776
|
+
oldSchema = fromSchemaContent;
|
|
777
|
+
newSchema = toSchemaContent;
|
|
778
|
+
break;
|
|
779
|
+
case 'down':
|
|
780
|
+
oldSchema = toSchemaContent;
|
|
781
|
+
newSchema = fromSchemaContent;
|
|
782
|
+
break;
|
|
783
|
+
default:
|
|
784
|
+
oldSchema = fromSchemaContent;
|
|
785
|
+
newSchema = toSchemaContent;
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Check compatibility
|
|
790
|
+
const { isBackward, backwardErrors } = this.checkBackwardCompatibility(oldSchema, newSchema);
|
|
791
|
+
const { isForward, forwardErrors } = this.checkForwardCompatibility(oldSchema, newSchema);
|
|
792
|
+
|
|
793
|
+
// Apply casting rules to transform the instance
|
|
794
|
+
const { casted, added, removed, incompatibilityReasons } = this.castInstanceToSchema(
|
|
795
|
+
this.deepCopy(fromInstanceContent),
|
|
796
|
+
targetSchema,
|
|
797
|
+
''
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
// Validate the casted instance against the target schema
|
|
801
|
+
let isFullyCompatible = false;
|
|
802
|
+
if (casted) {
|
|
803
|
+
try {
|
|
804
|
+
const modifiedSchema = this.removeGtsConstConstraints(toSchemaContent);
|
|
805
|
+
const validate = this.ajv.compile(this.normalizeSchema(modifiedSchema));
|
|
806
|
+
const isValid = validate(casted);
|
|
807
|
+
if (!isValid) {
|
|
808
|
+
const errors =
|
|
809
|
+
validate.errors?.map((e) => `${e.instancePath} ${e.message}`).join('; ') || 'Validation failed';
|
|
810
|
+
incompatibilityReasons.push(errors);
|
|
811
|
+
} else {
|
|
812
|
+
isFullyCompatible = true;
|
|
813
|
+
}
|
|
814
|
+
} catch (err) {
|
|
815
|
+
incompatibilityReasons.push(err instanceof Error ? err.message : String(err));
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return {
|
|
820
|
+
from: fromInstanceId,
|
|
821
|
+
to: toSchemaId,
|
|
822
|
+
old: fromInstanceId,
|
|
823
|
+
new: toSchemaId,
|
|
824
|
+
direction,
|
|
825
|
+
added_properties: this.deduplicate(added),
|
|
826
|
+
removed_properties: this.deduplicate(removed),
|
|
827
|
+
changed_properties: [],
|
|
828
|
+
is_fully_compatible: isFullyCompatible,
|
|
829
|
+
is_backward_compatible: isBackward,
|
|
830
|
+
is_forward_compatible: isForward,
|
|
831
|
+
incompatibility_reasons: incompatibilityReasons,
|
|
832
|
+
backward_errors: backwardErrors,
|
|
833
|
+
forward_errors: forwardErrors,
|
|
834
|
+
casted_entity: casted,
|
|
835
|
+
instance_id: fromInstanceId,
|
|
836
|
+
to_schema_id: toSchemaId,
|
|
837
|
+
ok: isFullyCompatible,
|
|
838
|
+
error: isFullyCompatible ? '' : incompatibilityReasons.join('; '),
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
private castInstanceToSchema(
|
|
843
|
+
instance: any,
|
|
844
|
+
schema: any,
|
|
845
|
+
basePath: string
|
|
846
|
+
): { casted: any; added: string[]; removed: string[]; incompatibilityReasons: string[] } {
|
|
847
|
+
const added: string[] = [];
|
|
848
|
+
const removed: string[] = [];
|
|
849
|
+
const incompatibilityReasons: string[] = [];
|
|
850
|
+
|
|
851
|
+
if (!instance || typeof instance !== 'object' || Array.isArray(instance)) {
|
|
852
|
+
incompatibilityReasons.push('Instance must be an object for casting');
|
|
853
|
+
return { casted: null, added, removed, incompatibilityReasons };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const targetProps = schema.properties || {};
|
|
857
|
+
const required = new Set<string>(schema.required || []);
|
|
858
|
+
const additional = schema.additionalProperties !== false;
|
|
859
|
+
|
|
860
|
+
// Start from current values
|
|
861
|
+
const result = this.deepCopy(instance);
|
|
862
|
+
|
|
863
|
+
// 1) Ensure required properties exist (fill defaults if provided)
|
|
864
|
+
for (const reqProp of Array.from(required)) {
|
|
865
|
+
if (!(reqProp in result)) {
|
|
866
|
+
const propSchema = targetProps[reqProp as string];
|
|
867
|
+
if (propSchema && propSchema.default !== undefined) {
|
|
868
|
+
result[reqProp as string] = this.deepCopy(propSchema.default);
|
|
869
|
+
const path = this.buildPath(basePath, reqProp as string);
|
|
870
|
+
added.push(path);
|
|
871
|
+
} else {
|
|
872
|
+
const path = this.buildPath(basePath, reqProp as string);
|
|
873
|
+
incompatibilityReasons.push(`Missing required property '${path}' and no default is defined`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// 2) For optional properties with defaults, set if missing
|
|
879
|
+
for (const [prop, propSchema] of Object.entries(targetProps)) {
|
|
880
|
+
if (required.has(prop)) {
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (!(prop in result)) {
|
|
884
|
+
const ps = propSchema as any;
|
|
885
|
+
if (ps.default !== undefined) {
|
|
886
|
+
result[prop] = this.deepCopy(ps.default);
|
|
887
|
+
const path = this.buildPath(basePath, prop);
|
|
888
|
+
added.push(path);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// 2.5) Update const values to match target schema (for GTS ID fields)
|
|
894
|
+
for (const [prop, propSchema] of Object.entries(targetProps)) {
|
|
895
|
+
const ps = propSchema as any;
|
|
896
|
+
if (ps.const !== undefined) {
|
|
897
|
+
const constVal = ps.const;
|
|
898
|
+
const existingVal = result[prop];
|
|
899
|
+
if (typeof constVal === 'string' && typeof existingVal === 'string') {
|
|
900
|
+
// Only update if both are GTS IDs and they differ
|
|
901
|
+
if (Gts.isValidGtsID(constVal) && Gts.isValidGtsID(existingVal)) {
|
|
902
|
+
if (existingVal !== constVal) {
|
|
903
|
+
result[prop] = constVal;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// 3) Remove properties not in target schema when additionalProperties is false
|
|
911
|
+
if (!additional) {
|
|
912
|
+
for (const prop of Object.keys(result)) {
|
|
913
|
+
if (!(prop in targetProps)) {
|
|
914
|
+
delete result[prop];
|
|
915
|
+
const path = this.buildPath(basePath, prop);
|
|
916
|
+
removed.push(path);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// 4) Recurse into nested object properties
|
|
922
|
+
for (const [prop, propSchema] of Object.entries(targetProps)) {
|
|
923
|
+
const val = result[prop];
|
|
924
|
+
if (val === undefined) {
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
const ps = propSchema as any;
|
|
928
|
+
const propType = ps.type;
|
|
929
|
+
|
|
930
|
+
// Handle nested objects
|
|
931
|
+
if (propType === 'object') {
|
|
932
|
+
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
933
|
+
const nestedSchema = this.effectiveObjectSchema(ps);
|
|
934
|
+
const nestedResult = this.castInstanceToSchema(val, nestedSchema, this.buildPath(basePath, prop));
|
|
935
|
+
result[prop] = nestedResult.casted;
|
|
936
|
+
added.push(...nestedResult.added);
|
|
937
|
+
removed.push(...nestedResult.removed);
|
|
938
|
+
incompatibilityReasons.push(...nestedResult.incompatibilityReasons);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Handle arrays of objects
|
|
943
|
+
if (propType === 'array') {
|
|
944
|
+
if (Array.isArray(val)) {
|
|
945
|
+
const itemsSchema = ps.items;
|
|
946
|
+
if (itemsSchema && itemsSchema.type === 'object') {
|
|
947
|
+
const nestedSchema = this.effectiveObjectSchema(itemsSchema);
|
|
948
|
+
const newList: any[] = [];
|
|
949
|
+
for (let idx = 0; idx < val.length; idx++) {
|
|
950
|
+
const item = val[idx];
|
|
951
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
952
|
+
const nestedResult = this.castInstanceToSchema(
|
|
953
|
+
item,
|
|
954
|
+
nestedSchema,
|
|
955
|
+
this.buildPath(basePath, `${prop}[${idx}]`)
|
|
956
|
+
);
|
|
957
|
+
newList.push(nestedResult.casted);
|
|
958
|
+
added.push(...nestedResult.added);
|
|
959
|
+
removed.push(...nestedResult.removed);
|
|
960
|
+
incompatibilityReasons.push(...nestedResult.incompatibilityReasons);
|
|
961
|
+
} else {
|
|
962
|
+
newList.push(item);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
result[prop] = newList;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return { casted: result, added, removed, incompatibilityReasons };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
private effectiveObjectSchema(schema: any): any {
|
|
975
|
+
if (!schema) {
|
|
976
|
+
return {};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// If it has properties or required directly, use it
|
|
980
|
+
if (schema.properties || schema.required) {
|
|
981
|
+
return schema;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Check allOf for object schemas
|
|
985
|
+
if (schema.allOf && Array.isArray(schema.allOf)) {
|
|
986
|
+
for (const part of schema.allOf) {
|
|
987
|
+
if (part.properties || part.required) {
|
|
988
|
+
return part;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
return schema;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
private removeGtsConstConstraints(schema: any): any {
|
|
997
|
+
if (schema === null || schema === undefined) {
|
|
998
|
+
return schema;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (typeof schema === 'object' && !Array.isArray(schema)) {
|
|
1002
|
+
const result: any = {};
|
|
1003
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
1004
|
+
if (key === 'const') {
|
|
1005
|
+
if (typeof value === 'string' && Gts.isValidGtsID(value)) {
|
|
1006
|
+
// Replace const with type constraint instead
|
|
1007
|
+
result.type = 'string';
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
result[key] = this.removeGtsConstConstraints(value);
|
|
1012
|
+
}
|
|
1013
|
+
return result;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (Array.isArray(schema)) {
|
|
1017
|
+
return schema.map((item) => this.removeGtsConstConstraints(item));
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return schema;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
private buildPath(base: string, prop: string): string {
|
|
1024
|
+
if (!base) {
|
|
1025
|
+
return prop;
|
|
1026
|
+
}
|
|
1027
|
+
// Handle array indices that already have brackets
|
|
1028
|
+
if (prop.startsWith('[')) {
|
|
1029
|
+
return base + prop;
|
|
1030
|
+
}
|
|
1031
|
+
return base + '.' + prop;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
private deepCopy(obj: any): any {
|
|
1035
|
+
if (obj === null || obj === undefined) {
|
|
1036
|
+
return obj;
|
|
1037
|
+
}
|
|
1038
|
+
if (typeof obj !== 'object') {
|
|
1039
|
+
return obj;
|
|
1040
|
+
}
|
|
1041
|
+
if (Array.isArray(obj)) {
|
|
1042
|
+
return obj.map((item) => this.deepCopy(item));
|
|
1043
|
+
}
|
|
1044
|
+
const result: any = {};
|
|
1045
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1046
|
+
result[key] = this.deepCopy(value);
|
|
1047
|
+
}
|
|
1048
|
+
return result;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private deduplicate(arr: string[]): string[] {
|
|
1052
|
+
const unique = Array.from(new Set(arr));
|
|
1053
|
+
return unique.sort();
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
getAttribute(gtsId: string, path: string): any {
|
|
1057
|
+
const entity = this.get(gtsId);
|
|
1058
|
+
if (!entity) {
|
|
1059
|
+
return {
|
|
1060
|
+
gts_id: gtsId,
|
|
1061
|
+
path,
|
|
1062
|
+
resolved: false,
|
|
1063
|
+
error: `Entity not found: ${gtsId}`,
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const value = this.getNestedValue(entity.content, path);
|
|
1068
|
+
|
|
1069
|
+
return {
|
|
1070
|
+
gts_id: gtsId,
|
|
1071
|
+
path,
|
|
1072
|
+
resolved: value !== undefined,
|
|
1073
|
+
value,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
private getNestedValue(obj: any, path: string): any {
|
|
1078
|
+
// Split path by dots but handle array notation
|
|
1079
|
+
const parts: string[] = [];
|
|
1080
|
+
let current = '';
|
|
1081
|
+
let inBracket = false;
|
|
1082
|
+
|
|
1083
|
+
for (let i = 0; i < path.length; i++) {
|
|
1084
|
+
const char = path[i];
|
|
1085
|
+
if (char === '[') {
|
|
1086
|
+
if (current) {
|
|
1087
|
+
parts.push(current);
|
|
1088
|
+
current = '';
|
|
1089
|
+
}
|
|
1090
|
+
inBracket = true;
|
|
1091
|
+
} else if (char === ']') {
|
|
1092
|
+
if (current) {
|
|
1093
|
+
parts.push(`[${current}]`);
|
|
1094
|
+
current = '';
|
|
1095
|
+
}
|
|
1096
|
+
inBracket = false;
|
|
1097
|
+
} else if (char === '.' && !inBracket) {
|
|
1098
|
+
if (current) {
|
|
1099
|
+
parts.push(current);
|
|
1100
|
+
current = '';
|
|
1101
|
+
}
|
|
1102
|
+
} else {
|
|
1103
|
+
current += char;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
if (current) {
|
|
1107
|
+
parts.push(current);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
let result = obj;
|
|
1111
|
+
for (const part of parts) {
|
|
1112
|
+
if (result === null || result === undefined) {
|
|
1113
|
+
return undefined;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Handle array index notation
|
|
1117
|
+
if (part.startsWith('[') && part.endsWith(']')) {
|
|
1118
|
+
const index = parseInt(part.slice(1, -1), 10);
|
|
1119
|
+
if (Array.isArray(result) && !isNaN(index)) {
|
|
1120
|
+
result = result[index];
|
|
1121
|
+
} else {
|
|
1122
|
+
return undefined;
|
|
1123
|
+
}
|
|
1124
|
+
} else {
|
|
1125
|
+
// Regular property access
|
|
1126
|
+
if (typeof result === 'object' && part in result) {
|
|
1127
|
+
result = result[part];
|
|
1128
|
+
} else {
|
|
1129
|
+
return undefined;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
return result;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
export function createJsonEntity(content: any, _config?: Partial<GtsConfig>): JsonEntity {
|
|
1139
|
+
const extractResult = GtsExtractor.extractID(content);
|
|
1140
|
+
|
|
1141
|
+
const references = new Set<string>();
|
|
1142
|
+
findReferences(content, references);
|
|
1143
|
+
|
|
1144
|
+
return {
|
|
1145
|
+
id: extractResult.id,
|
|
1146
|
+
schemaId: extractResult.schema_id,
|
|
1147
|
+
content,
|
|
1148
|
+
isSchema: extractResult.is_schema,
|
|
1149
|
+
references,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function findReferences(obj: any, refs: Set<string>, visited = new Set()): void {
|
|
1154
|
+
if (!obj || typeof obj !== 'object' || visited.has(obj)) {
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
visited.add(obj);
|
|
1159
|
+
|
|
1160
|
+
if ('$ref' in obj && typeof obj['$ref'] === 'string') {
|
|
1161
|
+
const ref = obj['$ref'];
|
|
1162
|
+
const normalized = ref.startsWith(GTS_URI_PREFIX) ? ref.substring(GTS_URI_PREFIX.length) : ref;
|
|
1163
|
+
if (Gts.isValidGtsID(normalized)) {
|
|
1164
|
+
refs.add(normalized);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
if ('x-gts-ref' in obj && typeof obj['x-gts-ref'] === 'string') {
|
|
1169
|
+
const ref = obj['x-gts-ref'];
|
|
1170
|
+
if (Gts.isValidGtsID(ref)) {
|
|
1171
|
+
refs.add(ref);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
for (const value of Object.values(obj)) {
|
|
1176
|
+
findReferences(value, refs, visited);
|
|
1177
|
+
}
|
|
1178
|
+
}
|