diff --git a/.gts-spec b/.gts-spec index 56ca20d..4c929a2 160000 --- a/.gts-spec +++ b/.gts-spec @@ -1 +1 @@ -Subproject commit 56ca20d89df2c2d8e70773da33482e4c748c446d +Subproject commit 4c929a2f3a2fecdb2d226096ac52a50459bdea7c diff --git a/README.md b/README.md index acf3490..27c46c9 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Featureset: - [x] **OP#9 - Version Casting**: Transform instances between compatible MINOR versions - [x] **OP#10 - Query Execution**: Filter identifier collections using the GTS query language - [x] **OP#11 - Attribute Access**: Retrieve property values and metadata using the attribute selector (`@`) -- [ ] **OP#12 - Schema Validation**: Validate schema against its precedent schema +- [x] **OP#12 - Schema Validation**: Validate schema against its precedent schema Other GTS spec [Reference Implementation](https://github.com/globaltypesystem/gts-spec/blob/main/README.md#9-reference-implementation-recommendations) recommended features support: @@ -32,8 +32,8 @@ Other GTS spec [Reference Implementation](https://github.com/globaltypesystem/gt - [x] **CLI** - command-line interface for all GTS operations - [x] **Web server** - a non-production web-server with REST API for the operations processing and testing - [x] **x-gts-ref** - to support special GTS entity reference annotation in schemas -- [ ] **YAML support** - to support YAML files (*.yml, *.yaml) as input files -- [ ] **TypeSpec support** - add [typespec.io](https://typespec.io/) files (*.tsp) support +- [ ] **YAML support** - to support YAML files (`*.yml`, `*.yaml`) as input files +- [ ] **TypeSpec support** - add [typespec.io](https://typespec.io/) files (`*.tsp`) support - [ ] **UUID for instances** - to support UUID as ID in JSON instances ## Usage @@ -60,7 +60,7 @@ import { extractID } from '@globaltypesystem/gts-ts'; const content = { gtsId: 'gts.vendor.pkg.ns.type.v1.0', - name: 'My Entity' + name: 'My Entity', }; const extracted = extractID(content); @@ -76,10 +76,7 @@ if (parsed.ok) { } // OP#4 - Pattern Matching -const matchResult = matchIDPattern( - 'gts.vendor.pkg.ns.type.v1.0', - 'gts.vendor.pkg.*' -); +const matchResult = matchIDPattern('gts.vendor.pkg.ns.type.v1.0', 'gts.vendor.pkg.*'); if (matchResult.match) { console.log('Pattern matched!'); } @@ -100,7 +97,7 @@ const gts = new GTS(); // Register an entity const entity = { gtsId: 'gts.vendor.pkg.ns.type.v1.0', - name: 'My Entity' + name: 'My Entity', }; gts.register(entity); @@ -117,20 +114,13 @@ console.log(`Relationships: ${relationships.relationships}`); console.log(`Broken references: ${relationships.brokenReferences}`); // OP#8 - Check compatibility -const compatResult = gts.checkCompatibility( - 'gts.vendor.pkg.ns.type.v1~', - 'gts.vendor.pkg.ns.type.v2~', - 'backward' -); +const compatResult = gts.checkCompatibility('gts.vendor.pkg.ns.type.v1~', 'gts.vendor.pkg.ns.type.v2~', 'backward'); if (compatResult.compatible) { console.log('Schemas are compatible'); } // OP#9 - Cast instance to different version -const castResult = gts.castInstance( - 'gts.vendor.pkg.ns.type.v1.0', - 'gts.vendor.pkg.ns.type.v2~' -); +const castResult = gts.castInstance('gts.vendor.pkg.ns.type.v1.0', 'gts.vendor.pkg.ns.type.v2~'); if (castResult.ok) { console.log('Instance casted successfully'); } @@ -220,6 +210,7 @@ npx gts-server --host 127.0.0.1 --port 8000 --verbose 2 ### API Endpoints #### Entity Management + - `GET /entities` - List all entities - `GET /entities/:id` - Get specific entity - `POST /entities` - Add new entity @@ -227,6 +218,7 @@ npx gts-server --host 127.0.0.1 --port 8000 --verbose 2 - `POST /schemas` - Add new schema #### GTS Operations + - `GET /validate-id?id=` - Validate GTS ID (OP#1) - `POST /extract-id` - Extract GTS ID from JSON (OP#2) - `GET /parse-id?id=` - Parse GTS ID (OP#3) @@ -238,8 +230,11 @@ npx gts-server --host 127.0.0.1 --port 8000 --verbose 2 - `POST /cast` - Cast instance (OP#9) - `GET /query?expr=&limit=` - Query entities (OP#10) - `GET /attr?path=` - Get attribute value (OP#11) +- `POST /validate-schema` - Validate schema against parent schema (OP#12) +- `POST /validate-entity` - Validate entity (schema or instance) (OP#12) #### Other + - `GET /health` - Health check - `GET /openapi` - OpenAPI specification diff --git a/package-lock.json b/package-lock.json index 4fcfad5..dad1511 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,18 @@ { "name": "@globaltypesystem/gts-ts", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@globaltypesystem/gts-ts", - "version": "0.1.1", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^2.1.1", "commander": "^12.0.0", - "fastify": "^4.26.0", - "fastify-cors": "^6.1.0", + "fastify": "^5.7.3", "uuid": "^9.0.1" }, "bin": { @@ -686,44 +685,131 @@ } }, "node_modules/@fastify/ajv-compiler": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", - "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "ajv": "^8.11.0", - "ajv-formats": "^2.1.1", - "fast-uri": "^2.0.0" + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" } }, - "node_modules/@fastify/ajv-compiler/node_modules/fast-uri": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", - "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", - "license": "MIT" + "node_modules/@fastify/ajv-compiler/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } }, "node_modules/@fastify/error": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", - "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT" }, "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", - "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fast-json-stringify": "^5.7.0" + "fast-json-stringify": "^6.0.0" } }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/merge-json-schemas": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", - "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" } }, "node_modules/@humanwhocodes/config-array": { @@ -1445,7 +1531,6 @@ "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1521,7 +1606,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -1694,7 +1778,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1861,12 +1944,22 @@ } }, "node_modules/avvio": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", - "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "@fastify/error": "^3.3.0", + "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, @@ -2056,7 +2149,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2269,12 +2361,16 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/create-jest": { @@ -2371,6 +2467,15 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2494,7 +2599,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2751,12 +2855,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/fast-content-type-parse": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", - "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", - "license": "MIT" - }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -2807,17 +2905,26 @@ "license": "MIT" }, "node_modules/fast-json-stringify": { - "version": "5.16.1", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", - "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "@fastify/merge-json-schemas": "^0.1.0", - "ajv": "^8.10.0", + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", "ajv-formats": "^3.0.1", - "fast-deep-equal": "^3.1.3", - "fast-uri": "^2.1.0", - "json-schema-ref-resolver": "^1.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, @@ -2838,12 +2945,6 @@ } } }, - "node_modules/fast-json-stringify/node_modules/fast-uri": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", - "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", - "license": "MIT" - }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", @@ -2877,9 +2978,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", - "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", "funding": [ { "type": "github", @@ -2892,58 +2993,23 @@ ], "license": "MIT", "dependencies": { - "@fastify/ajv-compiler": "^3.5.0", - "@fastify/error": "^3.4.0", - "@fastify/fast-json-stringify-compiler": "^4.3.0", + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", - "avvio": "^8.3.0", - "fast-content-type-parse": "^1.1.0", - "fast-json-stringify": "^5.8.0", - "find-my-way": "^8.0.0", - "light-my-request": "^5.11.0", - "pino": "^9.0.0", - "process-warning": "^3.0.0", - "proxy-addr": "^2.0.7", - "rfdc": "^1.3.0", - "secure-json-parse": "^2.7.0", - "semver": "^7.5.4", - "toad-cache": "^3.3.0" - } - }, - "node_modules/fastify-cors": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/fastify-cors/-/fastify-cors-6.1.0.tgz", - "integrity": "sha512-QBKz32IoY/iuT74CunRY1XOSpjSTIOh9E3FxulXIBhd0D2vdgG0kDvy0eG6HA/88sRfWHeba43LkGEXPz0Rh8g==", - "deprecated": "Please use @fastify/cors@7.0.0 instead", - "license": "MIT", - "dependencies": { - "fastify-cors-deprecated": "npm:fastify-cors@6.0.3", - "process-warning": "^1.0.0" - } - }, - "node_modules/fastify-cors-deprecated": { - "name": "fastify-cors", - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/fastify-cors/-/fastify-cors-6.0.3.tgz", - "integrity": "sha512-fMbXubKKyBHHCfSBtsCi3+7VyVRdhJQmGes5gM+eGKkRErCdm0NaYO0ozd31BQBL1ycoTIjbqOZhJo4RTF/Vlg==", - "license": "MIT", - "dependencies": { - "fastify-plugin": "^3.0.0", - "vary": "^1.1.2" + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" } }, - "node_modules/fastify-cors/node_modules/process-warning": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz", - "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==", - "license": "MIT" - }, - "node_modules/fastify-plugin": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-3.0.1.tgz", - "integrity": "sha512-qKcDXmuZadJqdTm6vlCqioEbyewF60b/0LOFCcYN1B6BIZGlYJumWWOYs70SFYLDAH4YqdE1cxH/RKMG7rFxgA==", - "license": "MIT" - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2990,17 +3056,17 @@ } }, "node_modules/find-my-way": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", - "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", + "integrity": "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", - "safe-regex2": "^3.1.0" + "safe-regex2": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/find-up": { @@ -3042,15 +3108,6 @@ "dev": true, "license": "ISC" }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3375,12 +3432,12 @@ "license": "ISC" }, "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 10" } }, "node_modules/is-arrayish": { @@ -3566,7 +3623,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -4192,12 +4248,22 @@ "license": "MIT" }, "node_modules/json-schema-ref-resolver": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", - "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" + "dequal": "^2.0.3" } }, "node_modules/json-schema-traverse": { @@ -4271,16 +4337,42 @@ } }, "node_modules/light-my-request": { - "version": "5.14.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", - "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "BSD-3-Clause", "dependencies": { - "cookie": "^0.7.0", - "process-warning": "^3.0.0", - "set-cookie-parser": "^2.4.1" + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" } }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4681,31 +4773,31 @@ } }, "node_modules/pino": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", + "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" + "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", "license": "MIT", "dependencies": { "split2": "^4.0.0" @@ -4717,22 +4809,6 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, - "node_modules/pino/node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -4867,9 +4943,19 @@ } }, "node_modules/process-warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", - "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT" }, "node_modules/prompts": { @@ -4886,19 +4972,6 @@ "node": ">= 6" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5053,9 +5126,9 @@ } }, "node_modules/ret": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", - "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", "license": "MIT", "engines": { "node": ">=10" @@ -5119,12 +5192,22 @@ } }, "node_modules/safe-regex2": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", - "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "ret": "~0.4.0" + "ret": "~0.5.0" } }, "node_modules/safe-stable-stringify": { @@ -5137,9 +5220,19 @@ } }, "node_modules/secure-json-parse": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", - "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "BSD-3-Clause" }, "node_modules/semver": { @@ -5208,9 +5301,9 @@ } }, "node_modules/sonic-boom": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", - "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0" @@ -5424,12 +5517,15 @@ "license": "MIT" }, "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", "license": "MIT", "dependencies": { "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/tmpl": { @@ -5546,7 +5642,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -5627,7 +5722,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5733,15 +5827,6 @@ "node": ">=10.12.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/package.json b/package.json index 03751cb..ca675bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@globaltypesystem/gts-ts", - "version": "0.1.1", + "version": "0.2.0", "description": "TypeScript library for working with GTS (Global Type System) identifiers and JSON/JSON Schema artifacts", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -47,8 +47,7 @@ "ajv": "^8.12.0", "ajv-formats": "^2.1.1", "commander": "^12.0.0", - "fastify": "^4.26.0", - "fastify-cors": "^6.1.0", + "fastify": "^5.7.3", "uuid": "^9.0.1" } } diff --git a/src/server/server.ts b/src/server/server.ts index 491946b..822ba8b 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -13,6 +13,8 @@ import { CompatibilityParams, CastBody, QueryParams, + ValidateSchemaBody, + ValidateEntityBody, } from './types'; import * as gts from '../index'; @@ -99,6 +101,12 @@ export class GtsServer { // OP#11 - Attribute Access this.fastify.get('/attr', this.handleAttribute.bind(this)); + // OP#12 - Validate Schema + this.fastify.post('/validate-schema', this.handleValidateSchema.bind(this)); + + // OP#12 - Validate Entity (unified) + this.fastify.post('/validate-entity', this.handleValidateEntity.bind(this)); + // OpenAPI spec this.fastify.get('/openapi', this.handleOpenAPI.bind(this)); } @@ -584,6 +592,42 @@ export class GtsServer { return this.store['store'].getAttribute(gtsId, path); } + // OP#12 - Validate Schema + private async handleValidateSchema( + request: FastifyRequest<{ Body: ValidateSchemaBody }>, + _reply: FastifyReply + ): Promise { + const { schema_id } = request.body; + if (!schema_id) { + return { ok: false, error: 'Missing required field: schema_id' }; + } + return this.store['store'].validateSchemaAgainstParent(schema_id); + } + + // OP#12 - Validate Entity (unified) + private async handleValidateEntity( + request: FastifyRequest<{ Body: ValidateEntityBody }>, + _reply: FastifyReply + ): Promise { + const id = request.body.entity_id || request.body.gts_id; + if (!id) { + return { ok: false, error: 'Missing required field: entity_id or gts_id' }; + } + + const entity = this.store['store'].get(id); + if (!entity) { + return { ok: false, error: `Entity not found: ${id}` }; + } + + if (entity.isSchema) { + const result = this.store['store'].validateSchemaAgainstParent(id); + return { ...result, entity_type: 'schema' }; + } else { + const result = this.store.validateInstance(id); + return { ...result, entity_type: 'instance' }; + } + } + // OpenAPI Specification private async handleOpenAPI(): Promise { return { diff --git a/src/server/types.ts b/src/server/types.ts index 12d54d8..671a2f3 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -72,3 +72,12 @@ export interface AttributeParams { gts_id: string; path: string; } + +export interface ValidateSchemaBody { + schema_id: string; +} + +export interface ValidateEntityBody { + entity_id?: string; + gts_id?: string; +} diff --git a/src/store.ts b/src/store.ts index 3d205fa..1b6bb0e 100644 --- a/src/store.ts +++ b/src/store.ts @@ -4,6 +4,13 @@ import { Gts } from './gts'; import { GtsExtractor } from './extract'; import { XGtsRefValidator } from './x-gts-ref'; +interface ResolvedSchema { + properties: Record; + required: string[]; + additionalProperties?: boolean; + type?: string; +} + export class GtsStore { private byId: Map = new Map(); private config: GtsConfig; @@ -194,6 +201,9 @@ export class GtsStore { const normalized: any = {}; for (const [key, value] of Object.entries(obj)) { + // Strip x-gts-ref so Ajv never sees the unknown keyword + if (key === 'x-gts-ref') continue; + let newKey = key; let newValue = value; @@ -221,6 +231,25 @@ export class GtsStore { normalized[newKey] = newValue; } + // Clean up combinator arrays: remove subschemas that were x-gts-ref-only (now empty after stripping) + for (const combinator of ['oneOf', 'anyOf', 'allOf']) { + if (Array.isArray(normalized[combinator])) { + normalized[combinator] = normalized[combinator].filter((_sub: any, idx: number) => { + const original = (obj as any)[combinator]?.[idx]; + const isXGtsRefOnly = + original && + typeof original === 'object' && + !Array.isArray(original) && + Object.keys(original).length === 1 && + original['x-gts-ref'] !== undefined; + return !isXGtsRefOnly; + }); + if (normalized[combinator].length === 0) { + delete normalized[combinator]; + } + } + } + // Normalize $id values if (normalized['$id'] && typeof normalized['$id'] === 'string') { if (normalized['$id'].startsWith(GTS_URI_PREFIX)) { @@ -1053,6 +1082,425 @@ export class GtsStore { return unique.sort(); } + validateSchemaAgainstParent(schemaId: string): ValidationResult { + const entity = this.get(schemaId); + if (!entity) { + return { id: schemaId, ok: false, error: `Entity not found: ${schemaId}` }; + } + if (!entity.isSchema) { + return { id: schemaId, ok: false, error: `Entity is not a schema: ${schemaId}` }; + } + + const content = entity.content; + + // Find parent reference in allOf + const parentRef = this.findParentRef(content); + if (!parentRef) { + // Base schema with no parent → always valid + return { id: schemaId, ok: true, error: '' }; + } + + // Resolve parent entity + const parentId = parentRef.startsWith(GTS_URI_PREFIX) ? parentRef.substring(GTS_URI_PREFIX.length) : parentRef; + const parentEntity = this.get(parentId); + if (!parentEntity) { + return { id: schemaId, ok: false, error: `Parent schema not found: ${parentId}` }; + } + if (!parentEntity.isSchema || !parentEntity.content) { + return { id: schemaId, ok: false, error: `Parent entity is not a schema: ${parentId}` }; + } + + // Resolve parent's effective (fully flattened) schema + const resolvedParent = this.resolveSchemaFully(parentEntity.content); + + // Extract overlay from derived schema (non-$ref subschemas in allOf + top-level) + const overlay = this.extractOverlay(content); + + // Compare overlay against resolved parent + const errors = this.compareOverlayToBase(overlay, resolvedParent, ''); + if (errors.length > 0) { + return { id: schemaId, ok: false, error: errors.join('; ') }; + } + + return { id: schemaId, ok: true, error: '' }; + } + + private findParentRef(schema: any): string | null { + if (!schema || !schema.allOf || !Array.isArray(schema.allOf)) { + return null; + } + for (const sub of schema.allOf) { + if (sub && typeof sub === 'object') { + const ref = sub['$$ref'] || sub['$ref']; + if (typeof ref === 'string') { + return ref; + } + } + } + return null; + } + + private resolveSchemaFully(schema: any, visited: Set = new Set()): ResolvedSchema { + const result: ResolvedSchema = { + properties: {}, + required: [], + additionalProperties: undefined, + type: schema.type, + }; + + // If this schema has allOf, resolve each part + if (schema.allOf && Array.isArray(schema.allOf)) { + for (const sub of schema.allOf) { + const ref = sub['$$ref'] || sub['$ref']; + if (typeof ref === 'string') { + // Resolve referenced schema + const refId = ref.startsWith(GTS_URI_PREFIX) ? ref.substring(GTS_URI_PREFIX.length) : ref; + if (visited.has(refId)) { + continue; + } + visited.add(refId); + const refEntity = this.get(refId); + if (refEntity && refEntity.content) { + const resolved = this.resolveSchemaFully(refEntity.content, visited); + Object.assign(result.properties, resolved.properties); + if (resolved.required) { + result.required.push(...resolved.required); + } + if (resolved.additionalProperties !== undefined) { + result.additionalProperties = resolved.additionalProperties; + } + if (resolved.type && !result.type) { + result.type = resolved.type; + } + } + } else { + // Non-ref subschema - merge it + const resolved = this.resolveSchemaFully(sub, visited); + // For overlay properties, merge them (they override) + for (const [propName, propSchema] of Object.entries(resolved.properties || {})) { + if (result.properties[propName]) { + // Merge property constraints - overlay tightens base + result.properties[propName] = this.mergePropertySchemas(result.properties[propName], propSchema); + } else { + result.properties[propName] = propSchema; + } + } + if (resolved.required) { + result.required.push(...resolved.required); + } + if (resolved.additionalProperties !== undefined) { + result.additionalProperties = resolved.additionalProperties; + } + } + } + } + + // Add direct properties + if (schema.properties) { + for (const [propName, propSchema] of Object.entries(schema.properties)) { + if (result.properties[propName]) { + result.properties[propName] = this.mergePropertySchemas(result.properties[propName], propSchema); + } else { + result.properties[propName] = propSchema; + } + } + } + + // Add direct required + if (schema.required && Array.isArray(schema.required)) { + result.required.push(...schema.required); + } + + // Direct additionalProperties + if (schema.additionalProperties !== undefined) { + result.additionalProperties = schema.additionalProperties; + } + + // Deduplicate required + result.required = Array.from(new Set(result.required)); + + return result; + } + + private mergePropertySchemas(base: any, overlay: any): any { + if (base === false || overlay === false) { + return false; + } + if (typeof base !== 'object' || typeof overlay !== 'object') { + return overlay; + } + const merged: any = { ...base }; + for (const [key, val] of Object.entries(overlay)) { + if (key === 'properties' && merged.properties) { + merged.properties = { ...merged.properties, ...(val as any) }; + } else if (key === 'required' && merged.required) { + const mergedReq = new Set([...(merged.required as string[]), ...(val as string[])]); + merged.required = Array.from(mergedReq); + } else { + merged[key] = val; + } + } + return merged; + } + + private extractOverlay(schema: any): ResolvedSchema { + const overlay: ResolvedSchema = { + properties: {}, + required: [], + additionalProperties: undefined, + }; + + if (schema.allOf && Array.isArray(schema.allOf)) { + for (const sub of schema.allOf) { + const ref = sub['$$ref'] || sub['$ref']; + if (typeof ref === 'string') { + continue; // Skip ref subschemas + } + // This is a non-ref overlay subschema + if (sub.properties) { + for (const [propName, propSchema] of Object.entries(sub.properties)) { + overlay.properties[propName] = overlay.properties[propName] + ? this.mergePropertySchemas(overlay.properties[propName], propSchema) + : propSchema; + } + } + if (sub.required && Array.isArray(sub.required)) { + overlay.required.push(...sub.required); + } + if (sub.additionalProperties !== undefined) { + overlay.additionalProperties = sub.additionalProperties; + } + } + } + + // Add top-level properties (outside allOf) + if (schema.properties) { + for (const [propName, propSchema] of Object.entries(schema.properties)) { + overlay.properties[propName] = overlay.properties[propName] + ? this.mergePropertySchemas(overlay.properties[propName], propSchema) + : propSchema; + } + } + if (schema.required && Array.isArray(schema.required)) { + overlay.required.push(...schema.required); + } + if (schema.additionalProperties !== undefined && overlay.additionalProperties === undefined) { + overlay.additionalProperties = schema.additionalProperties; + } + + return overlay; + } + + private compareOverlayToBase(overlay: ResolvedSchema, baseResolved: ResolvedSchema, path: string): string[] { + const errors: string[] = []; + const overlayProps = overlay.properties || {}; + const baseProps = baseResolved.properties || {}; + + for (const [propName, propSchema] of Object.entries(overlayProps)) { + const propPath = path ? `${path}.${propName}` : propName; + + // Property schema set to false + if (propSchema === false) { + if (baseProps[propName] !== undefined) { + errors.push(`Property '${propPath}' is set to false but exists in base`); + } + continue; + } + + const baseProp = baseProps[propName]; + + if (baseProp === undefined || baseProp === null) { + // New property not in base + if (baseResolved.additionalProperties === false) { + errors.push(`Property '${propPath}' not in base and base has additionalProperties: false`); + } + continue; + } + + if (baseProp === false) { + // Base already set property to false, overlay can't use it + errors.push(`Property '${propPath}' is forbidden in base`); + continue; + } + + // Both base and overlay have this property — compare constraints + if (typeof propSchema === 'object' && propSchema !== null) { + errors.push(...this.comparePropertyConstraints(propSchema, baseProp, propPath)); + } + } + + // Check additionalProperties + if (baseResolved.additionalProperties === false) { + if (overlay.additionalProperties === true) { + errors.push('Cannot loosen additionalProperties from false to true'); + } else if (overlay.additionalProperties === undefined) { + errors.push('Base has additionalProperties: false but derived does not restate it'); + } + } + + return errors; + } + + private comparePropertyConstraints(derived: any, base: any, propPath: string): string[] { + const errors: string[] = []; + + if (typeof base !== 'object' || base === null) { + return errors; + } + + // Type check + const baseType = base.type; + const derivedType = derived.type; + if (baseType !== undefined && derivedType !== undefined) { + if (Array.isArray(derivedType)) { + // Derived has array type — widening (fail) + if (!Array.isArray(baseType)) { + errors.push(`Property '${propPath}' widens type from '${baseType}' to array`); + return errors; + } + } + if (Array.isArray(baseType)) { + if (!Array.isArray(derivedType)) { + // Could be narrowing from array type + if (!baseType.includes(derivedType)) { + errors.push(`Property '${propPath}' type '${derivedType}' not in base types [${baseType}]`); + return errors; + } + } + } else if (!Array.isArray(derivedType)) { + // Both scalar types + if (baseType !== derivedType) { + errors.push(`Property '${propPath}' type changed from '${baseType}' to '${derivedType}'`); + return errors; + } + } + } + + // Determine if the overlay adds any NEW constraint keywords not in the base. + // Under allOf semantics, base constraints are preserved. Drops are only flagged + // when the overlay doesn't introduce any new tightening constraints. + const CONSTRAINT_KEYWORDS = [ + 'maxLength', + 'minLength', + 'maximum', + 'minimum', + 'maxItems', + 'minItems', + 'enum', + 'const', + 'pattern', + 'items', + ]; + const baseConstraintKeys = new Set(CONSTRAINT_KEYWORDS.filter((kw) => base[kw] !== undefined)); + const derivedConstraintKeys = new Set(CONSTRAINT_KEYWORDS.filter((kw) => derived[kw] !== undefined)); + const hasNewConstraints = [...derivedConstraintKeys].some((kw) => !baseConstraintKeys.has(kw)); + + // Max constraints (tightening = lower value OK; loosening = higher value FAIL) + for (const kw of ['maxLength', 'maximum', 'maxItems']) { + if (base[kw] !== undefined) { + if (derived[kw] === undefined) { + if (!hasNewConstraints) { + errors.push(`Property '${propPath}' drops constraint '${kw}'`); + } + } else if (derived[kw] > base[kw]) { + errors.push(`Property '${propPath}' loosens '${kw}' from ${base[kw]} to ${derived[kw]}`); + } + } + } + + // Min constraints (tightening = higher value OK; loosening = lower value FAIL) + for (const kw of ['minLength', 'minimum', 'minItems']) { + if (base[kw] !== undefined) { + if (derived[kw] === undefined) { + if (!hasNewConstraints) { + errors.push(`Property '${propPath}' drops constraint '${kw}'`); + } + } else if (derived[kw] < base[kw]) { + errors.push(`Property '${propPath}' loosens '${kw}' from ${base[kw]} to ${derived[kw]}`); + } + } + } + + // Enum check + if (base.enum !== undefined) { + if (derived.enum === undefined) { + if (!hasNewConstraints) { + errors.push(`Property '${propPath}' drops constraint 'enum'`); + } + } else { + const baseSet = new Set(base.enum.map((v: any) => JSON.stringify(v))); + for (const val of derived.enum) { + if (!baseSet.has(JSON.stringify(val))) { + errors.push(`Property '${propPath}' enum value '${val}' not in base enum`); + } + } + } + } + + // Const check + if (base.const !== undefined) { + if (derived.const === undefined) { + if (!hasNewConstraints) { + errors.push(`Property '${propPath}' drops constraint 'const'`); + } + } else if (JSON.stringify(base.const) !== JSON.stringify(derived.const)) { + errors.push( + `Property '${propPath}' const conflict: ${JSON.stringify(derived.const)} vs base ${JSON.stringify(base.const)}` + ); + } + } + // Check const in derived against base numeric constraints + if (derived.const !== undefined && typeof derived.const === 'number') { + if (base.minimum !== undefined && derived.const < base.minimum) { + errors.push(`Property '${propPath}' const ${derived.const} violates base minimum ${base.minimum}`); + } + if (base.maximum !== undefined && derived.const > base.maximum) { + errors.push(`Property '${propPath}' const ${derived.const} violates base maximum ${base.maximum}`); + } + } + + // Pattern check + if (base.pattern !== undefined) { + if (derived.pattern === undefined) { + if (!hasNewConstraints) { + errors.push(`Property '${propPath}' drops constraint 'pattern'`); + } + } else if (base.pattern !== derived.pattern) { + errors.push(`Property '${propPath}' pattern changed from '${base.pattern}' to '${derived.pattern}'`); + } + } + + // Items check (array items) + if (base.items !== undefined) { + if (derived.items === undefined) { + if (!hasNewConstraints) { + errors.push(`Property '${propPath}' drops constraint 'items'`); + } + } else if (typeof base.items === 'object' && typeof derived.items === 'object') { + errors.push(...this.comparePropertyConstraints(derived.items, base.items, `${propPath}.items`)); + } + } + + // Nested object: recursively compare + if (base.type === 'object' && derived.type === 'object') { + if (base.properties || derived.properties) { + const nestedOverlay = { + properties: derived.properties || {}, + required: derived.required || [], + additionalProperties: derived.additionalProperties, + }; + const nestedBase = { + properties: base.properties || {}, + required: base.required || [], + additionalProperties: base.additionalProperties, + }; + errors.push(...this.compareOverlayToBase(nestedOverlay, nestedBase, propPath)); + } + } + + return errors; + } + getAttribute(gtsId: string, path: string): any { const entity = this.get(gtsId); if (!entity) { diff --git a/src/x-gts-ref.ts b/src/x-gts-ref.ts index ba4875f..697b249 100644 --- a/src/x-gts-ref.ts +++ b/src/x-gts-ref.ts @@ -81,6 +81,56 @@ export class XGtsRefValidator { }); } } + + // Recurse into combinator subschemas + if (Array.isArray(schema.allOf)) { + for (const subSchema of schema.allOf) { + this.visitInstance(instance, subSchema, path, rootSchema, errors); + } + } + + if (Array.isArray(schema.anyOf)) { + // Only enforce when all branches have x-gts-ref; mixed branches may be valid via non-x-gts-ref path (Ajv handles that) + const refBranches = schema.anyOf.filter((s: any) => this.containsXGtsRef(s)); + if (refBranches.length > 0 && refBranches.length === schema.anyOf.length) { + const branchResults = refBranches.map((subSchema: any) => { + const branchErrors: XGtsRefValidationError[] = []; + this.visitInstance(instance, subSchema, path, rootSchema, branchErrors); + return branchErrors; + }); + const anyPassed = branchResults.some((errs: XGtsRefValidationError[]) => errs.length === 0); + if (!anyPassed) { + for (const branchErrors of branchResults) { + errors.push(...branchErrors); + } + } + } + } + + if (Array.isArray(schema.oneOf)) { + // Only enforce when all branches have x-gts-ref; mixed branches can't be coordinated with Ajv's branch selection + const refBranches = schema.oneOf.filter((s: any) => this.containsXGtsRef(s)); + if (refBranches.length > 0 && refBranches.length === schema.oneOf.length) { + const branchResults = refBranches.map((subSchema: any) => { + const branchErrors: XGtsRefValidationError[] = []; + this.visitInstance(instance, subSchema, path, rootSchema, branchErrors); + return branchErrors; + }); + const passingCount = branchResults.filter((errs: XGtsRefValidationError[]) => errs.length === 0).length; + if (passingCount === 0) { + for (const branchErrors of branchResults) { + errors.push(...branchErrors); + } + } else if (passingCount > 1) { + errors.push({ + fieldPath: path || '/', + value: instance, + refPattern: '', + reason: `Value matches ${passingCount} oneOf branches but must match exactly one`, + }); + } + } + } } private visitSchema(schema: any, path: string, rootSchema: any, errors: XGtsRefValidationError[]): void { @@ -300,6 +350,19 @@ export class XGtsRefValidator { return null; } + private containsXGtsRef(schema: any): boolean { + if (!schema || typeof schema !== 'object') return false; + if (schema['x-gts-ref'] !== undefined) return true; + for (const value of Object.values(schema)) { + if (Array.isArray(value)) { + if (value.some((item) => this.containsXGtsRef(item))) return true; + } else if (value && typeof value === 'object') { + if (this.containsXGtsRef(value)) return true; + } + } + return false; + } + /** * Strip the "gts://" prefix from a value if present */ diff --git a/tests/gts.test.ts b/tests/gts.test.ts index 7c60b3c..82d1110 100644 --- a/tests/gts.test.ts +++ b/tests/gts.test.ts @@ -414,6 +414,8 @@ describe('GTS Store Operations', () => { }); }); + // x-gts-ref combinator tests (oneOf/anyOf/allOf) are in the canonical gts-spec test suite + describe('OP#12 - Wildcard Validation (v0.7)', () => { test('validates wildcard patterns', () => { const result = validateGtsID('gts.vendor.pkg.*');