diff --git a/.prettierignore b/.prettierignore index b408caa8..28597980 100644 --- a/.prettierignore +++ b/.prettierignore @@ -16,6 +16,8 @@ tools/render/fixtures # Written by tools/content-node/snapshot-jsoup/JsoupTables.java from Jsoup's jar. tools/content-node/jsoup-tables.json tools/content-node/openapi-snapshot.json +# Written by tools/contract/generate.ts; generate.spec.ts holds it to that. +projects/contract/openapi.json projects/website-angular/content-dist projects/website-angular/tina/__generated__ projects/website-angular/tina/tina-lock.json diff --git a/package-lock.json b/package-lock.json index 2e03d2d6..a8a9a421 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,7 @@ "ng-packagr": "^21.2.7", "npm-run-all": "^4.1.1", "prettier": "^3.9.10", + "ts-json-schema-generator": "2.9.0", "typescript": "~5.9.3", "typescript-eslint": "^8.72.0", "vite-tsconfig-paths": "^6.1.1", @@ -6660,55 +6661,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@npmcli/package-json/node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@npmcli/package-json/node_modules/brace-expansion": { - "version": "5.0.9", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@npmcli/package-json/node_modules/glob": { - "version": "13.0.6", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@npmcli/package-json/node_modules/minimatch": { - "version": "10.2.6", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@npmcli/package-json/node_modules/spdx-expression-parse": { "version": "4.0.0", "dev": true, @@ -13966,41 +13918,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/cacache/node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "5.0.9", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/cacache/node_modules/glob": { - "version": "13.0.6", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/cacache/node_modules/lru-cache": { "version": "11.5.2", "dev": true, @@ -14009,20 +13926,6 @@ "node": "20 || >=22" } }, - "node_modules/cacache/node_modules/minimatch": { - "version": "10.2.6", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/cacheable": { "version": "2.5.0", "dev": true, @@ -17726,6 +17629,24 @@ "version": "0.0.0", "license": "MIT" }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "license": "ISC", @@ -17758,6 +17679,45 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globalthis": { "version": "1.0.4", "dev": true, @@ -25764,6 +25724,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "license": "MIT" @@ -27695,6 +27665,39 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/ts-json-schema-generator": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/ts-json-schema-generator/-/ts-json-schema-generator-2.9.0.tgz", + "integrity": "sha512-NR5ZE108uiPtBHBJNGnhwoUaUx5vWTDJzDFG9YlRoqxPU76n+5FClRh92dcGgysbe1smRmYalM9Saj97GW1J4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "commander": "^14.0.3", + "glob": "^13.0.6", + "json5": "^2.2.3", + "normalize-path": "^3.0.0", + "safe-stable-stringify": "^2.5.0", + "tslib": "^2.8.1", + "typescript": "^5.9.3" + }, + "bin": { + "ts-json-schema-generator": "bin/ts-json-schema-generator.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/ts-json-schema-generator/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/tsconfck": { "version": "3.1.6", "dev": true, diff --git a/package.json b/package.json index c3cff9ed..cb8464e4 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "ng-packagr": "^21.2.7", "npm-run-all": "^4.1.1", "prettier": "^3.9.10", + "ts-json-schema-generator": "2.9.0", "typescript": "~5.9.3", "typescript-eslint": "^8.72.0", "vite-tsconfig-paths": "^6.1.1", diff --git a/projects/contract/openapi.json b/projects/contract/openapi.json new file mode 100644 index 00000000..68202b0e --- /dev/null +++ b/projects/contract/openapi.json @@ -0,0 +1,1812 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "Reactome Content Service", + "version": "contract", + "description": "Generated from the shared contract (projects/contract). A route marked x-contract \"not yet\" is answered by the service but not yet described here." + }, + "paths": { + "/ContentService/citation/export": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/citation/pathway/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/citation/static/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/complex/{id}/subunits": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/complexes/{resource}/{identifier}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/content/contributors": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/content/doi": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/content/toc": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/database/name": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/database/version": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/detail/{id}/locationsInPWB": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/discover/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/diseases": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/diseases/doid": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/entity/{id}/componentOf": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/entity/{id}/in-depth": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/entity/{id}/otherForms": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/event/{id}/ancestors": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/event/{id}/in-depth": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/instance/{id}/referrers": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/mapping/{resource}/{identifier}/pathways": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/mapping/{resource}/{identifier}/reactions": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/orthology/{id}/species/{speciesId}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/participants/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/participants/{id}/participatingPhysicalEntities": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/participants/{id}/referenceEntities": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/pathway/{id}/containedEvents": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/pathway/{id}/containedEvents/{attribute}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/pathways/low/diagram/entity/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/pathways/low/diagram/entity/{id}/allForms": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/pathways/low/entity/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/pathways/low/entity/{id}/allForms": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/pathways/top/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/person/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/person/{id}/authoredPathways": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/person/{id}/authoredReactions": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/person/{id}/publications": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/person/{id}/reviewedPathways": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/person/{id}/reviewedReactions": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/person/{id}/{attribute}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/query/enhanced/v2/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/query/enhanced/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/query/ids/map": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + }, + "post": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/query/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/query/{id}/normalPathway": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/query/{id}/stId": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/query/{id}/{attribute}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/schema/model": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/schema/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/schema/{id}/attributes": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/schema/{id}/count": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/schema/{id}/min": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/schema/{id}/reference": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/schema/{id}/referrals": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/species/all": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/species/main": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/exporter/diagram/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/exporter/event/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/exporter/fireworks/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/exporter/sbml/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/exporter/voronoi/{id}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/interactors/psicquic/resources": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/interactors/static/molecule/enhanced/{id}/details": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/interactors/static/molecule/{id}/details": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/interactors/static/molecule/{id}/pathways": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/interactors/static/molecule/{id}/summary": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/interactors/static/molecule/{id}/withEntities": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/references/mapping/{identifier}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/references/mapping/{identifier}/xrefs": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/search/diagram/summary": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/search/diagram/{diagram}": { + "get": { + "responses": { + "200": { + "description": "The answer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiagramResult_SearchEntry" + } + } + } + } + } + } + }, + "/ContentService/search/diagram/{diagram}/occurrences/{instance}": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/search/diagram/{pathwayId}/flag": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/search/facet": { + "get": { + "responses": { + "200": { + "description": "The answer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FacetResponse" + } + } + } + } + } + } + }, + "/ContentService/search/facet_query": { + "get": { + "responses": { + "200": { + "description": "The answer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FacetResponse" + } + } + } + } + } + } + }, + "/ContentService/search/fireworks": { + "get": { + "responses": { + "200": { + "description": "The answer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiagramResult_SearchEntry" + } + } + } + } + } + } + }, + "/ContentService/search/fireworks/flag": { + "get": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/search/icon/facet": { + "get": { + "responses": { + "200": { + "description": "The answer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IconFacetResponse" + } + } + } + } + } + } + }, + "/ContentService/search/icon/query": { + "get": { + "responses": { + "200": { + "description": "The answer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IconResult" + } + } + } + } + } + } + }, + "/ContentService/search/icon/{identifier}": { + "get": { + "responses": { + "200": { + "description": "The answer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IconEntry" + } + } + } + } + } + } + }, + "/ContentService/search/pathways/of/{dbId}": { + "get": { + "responses": { + "200": { + "description": "The answer.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathwayOfEntry" + } + } + } + } + } + } + } + }, + "/ContentService/search/query": { + "get": { + "responses": { + "200": { + "description": "The answer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupedResult_SearchEntry" + } + } + } + } + } + } + }, + "/ContentService/search/query/paginated": { + "get": { + "responses": { + "200": { + "description": "The answer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupedResult_SearchEntry" + } + } + } + } + } + } + }, + "/ContentService/search/spellcheck": { + "get": { + "responses": { + "200": { + "description": "The answer.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "/ContentService/search/suggest": { + "get": { + "responses": { + "200": { + "description": "The answer.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "/ContentService/data/orthologies/ids/species/{speciesId}": { + "post": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/data/query/ids": { + "post": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/interactors/static/molecules/details": { + "post": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/interactors/static/molecules/summary": { + "post": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + }, + "/ContentService/references/mapping/xrefs": { + "post": { + "x-contract": "not yet", + "responses": { + "200": { + "description": "Not yet described by the contract." + } + } + } + } + }, + "components": { + "schemas": { + "DiagramResult_SearchEntry": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchEntry" + }, + "description": "Left out when there are none." + }, + "facets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FacetCount" + }, + "description": "Left out when there are none." + }, + "found": { + "type": "number" + } + }, + "required": [ + "found" + ], + "additionalProperties": false, + "description": "`/search/diagram/{id}` and `/search/fireworks`: the entries, their type facets, Solr's count." + }, + "EpochMillis": { + "type": "number", + "description": "A date written as milliseconds since the epoch." + }, + "Facet": { + "type": "object", + "properties": { + "selected": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FacetCount" + } + }, + "available": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FacetCount" + } + } + }, + "additionalProperties": false + }, + "FacetCount": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "count": { + "type": "number" + } + }, + "required": [ + "count" + ], + "additionalProperties": false + }, + "FacetResponse": { + "type": "object", + "properties": { + "totalNumFount": { + "type": "number" + }, + "speciesFacet": { + "$ref": "#/components/schemas/Facet" + }, + "typeFacet": { + "$ref": "#/components/schemas/Facet" + }, + "keywordFacet": { + "$ref": "#/components/schemas/Facet" + }, + "compartmentFacet": { + "$ref": "#/components/schemas/Facet" + } + }, + "required": [ + "totalNumFount" + ], + "additionalProperties": false + }, + "GroupedResult_SearchEntry": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResultGroup_SearchEntry" + } + }, + "rowCount": { + "type": "number" + }, + "numberOfGroups": { + "type": "number" + }, + "numberOfMatches": { + "type": "number" + }, + "targetResults": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TargetResult" + }, + "description": "For a search that found nothing: the terms, and whether each is a target." + } + }, + "required": [ + "results", + "rowCount", + "numberOfGroups", + "numberOfMatches" + ], + "additionalProperties": false, + "description": "`/search/query`: the groups, the rows written, the groups and matches Solr counted." + }, + "Highlighted": { + "type": "string", + "description": "Text Solr may have highlighted: a match is wrapped in `…`, so a reader showing it as plain text escapes it." + }, + "IconCategory": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "count": { + "type": "number" + } + }, + "required": [ + "count" + ], + "additionalProperties": false + }, + "IconEntry": { + "type": "object", + "properties": { + "dbId": { + "type": "string" + }, + "id": { + "type": "string", + "description": "The stId, or the dbId when there is none; never highlighted." + }, + "name": { + "$ref": "#/components/schemas/Highlighted" + }, + "exactType": { + "type": "string", + "const": "Icon" + }, + "type": { + "type": "string", + "const": "Icon" + }, + "icon": { + "type": "boolean", + "const": true, + "description": "Java's getter: an icon has a name." + }, + "stId": { + "$ref": "#/components/schemas/Highlighted" + }, + "species": { + "type": "array", + "items": { + "type": "string" + } + }, + "summation": { + "$ref": "#/components/schemas/Highlighted", + "description": "The icon's description. Icons have no inferred one to fall back to." + }, + "iconName": { + "type": "string" + }, + "iconCategories": { + "type": "array", + "items": { + "type": "string" + } + }, + "iconCuratorName": { + "type": "string" + }, + "iconCuratorOrcidId": { + "type": "string" + }, + "iconCuratorUrl": { + "type": "string" + }, + "iconDesignerName": { + "type": "string" + }, + "iconDesignerOrcidId": { + "type": "string" + }, + "iconDesignerUrl": { + "type": "string" + }, + "iconReferences": { + "type": "array", + "items": { + "type": "string" + } + }, + "iconEhlds": { + "type": "array", + "items": { + "type": "string" + } + }, + "iconPhysicalEntities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "stId": { + "type": "string" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "compartments": { + "type": "string" + }, + "displayName": { + "type": "string" + } + }, + "additionalProperties": false, + "description": "One physical entity an icon draws; an empty part is left out." + } + }, + "hasReferenceEntity": { + "type": "boolean", + "description": "Always false." + }, + "hasEHLD": { + "type": "boolean", + "description": "Always false." + } + }, + "required": [ + "dbId", + "exactType", + "hasEHLD", + "hasReferenceEntity", + "icon", + "id", + "stId", + "type" + ], + "additionalProperties": false, + "description": "An icon of the icon library." + }, + "IconFacet": { + "type": "object", + "properties": { + "selected": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IconCategory" + } + }, + "available": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IconCategory" + } + } + }, + "additionalProperties": false + }, + "IconFacetResponse": { + "type": "object", + "properties": { + "totalNumFount": { + "type": "number" + }, + "iconCategoriesFacet": { + "$ref": "#/components/schemas/IconFacet" + } + }, + "required": [ + "totalNumFount" + ], + "additionalProperties": false + }, + "IconResult": { + "$ref": "#/components/schemas/ResultGroup_IconEntry", + "description": "`/search/icon/query`: the icon library's page, one ungrouped group of icons." + }, + "Long": { + "type": "integer", + "description": "A Java long, written exactly (`JSON.rawJSON`): it may exceed 2^53, beyond which a JavaScript number reads it rounded.", + "format": "int64" + }, + "PathwayOfEntry": { + "type": "object", + "properties": { + "dbId": { + "type": "string" + }, + "stId": { + "$ref": "#/components/schemas/Highlighted" + }, + "id": { + "type": "string" + }, + "name": { + "$ref": "#/components/schemas/Highlighted" + }, + "hasEHLD": { + "type": "boolean" + } + }, + "required": [ + "dbId", + "stId", + "id" + ], + "additionalProperties": false, + "description": "What `/search/pathways/of/{dbId}` answers: pathways, written with only the fields the pathway browser asks for (`fields=DB_ID,ST_ID,NAME,HAS_EHLD`), so no type." + }, + "ResultGroup_IconEntry": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IconEntry" + }, + "description": "Left out when the group has none (an ungrouped search that found none)." + }, + "typeName": { + "type": "string", + "description": "Left out for a group Solr names with nothing." + }, + "entriesCount": { + "type": "number", + "description": "How many Solr found." + }, + "rowCount": { + "type": "number", + "description": "How many are written here." + } + }, + "required": [ + "entriesCount", + "rowCount" + ], + "additionalProperties": false, + "description": "One group of results: a type's, or \"Results\" for a search not grouped." + }, + "ResultGroup_SearchEntry": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchEntry" + }, + "description": "Left out when the group has none (an ungrouped search that found none)." + }, + "typeName": { + "type": "string", + "description": "Left out for a group Solr names with nothing." + }, + "entriesCount": { + "type": "number", + "description": "How many Solr found." + }, + "rowCount": { + "type": "number", + "description": "How many are written here." + } + }, + "required": [ + "entriesCount", + "rowCount" + ], + "additionalProperties": false, + "description": "One group of results: a type's, or \"Results\" for a search not grouped." + }, + "SearchEntry": { + "description": "One search result.", + "oneOf": [ + { + "type": "object", + "properties": { + "dbId": { + "type": "string" + }, + "id": { + "type": "string", + "description": "The stId, or the dbId when there is none; never highlighted." + }, + "name": { + "$ref": "#/components/schemas/Highlighted" + }, + "exactType": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "Pathway", + "Reaction" + ] + }, + "stId": { + "$ref": "#/components/schemas/Highlighted" + }, + "species": { + "type": "array", + "items": { + "type": "string" + } + }, + "summation": { + "$ref": "#/components/schemas/Highlighted" + }, + "isDisease": { + "type": "boolean" + }, + "disease": { + "type": "boolean", + "description": "`isDisease` again: Java's getter." + }, + "hasReferenceEntity": { + "type": "boolean", + "description": "Always false: an event has no reference entity." + }, + "hasEHLD": { + "type": "boolean", + "description": "A pathway's." + }, + "compartmentNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "compartmentAccession": { + "type": "array", + "items": { + "type": "string" + } + }, + "fireworksSpecies": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only on the species-overview searches." + } + }, + "required": [ + "dbId", + "disease", + "exactType", + "hasReferenceEntity", + "id", + "isDisease", + "species", + "stId", + "type" + ], + "additionalProperties": false, + "description": "A pathway or a reaction. A reaction's `exactType` is its category (binding, transition, ...)." + }, + { + "type": "object", + "properties": { + "dbId": { + "type": "string" + }, + "id": { + "type": "string", + "description": "The stId, or the dbId when there is none; never highlighted." + }, + "name": { + "$ref": "#/components/schemas/Highlighted" + }, + "exactType": { + "type": "string" + }, + "type": { + "anyOf": [ + { + "type": "string", + "const": "Protein" + }, + { + "type": "string", + "const": "DNA Sequence" + }, + { + "type": "string", + "const": "RNA Sequence" + }, + { + "type": "string", + "const": "Chemical Compound" + }, + { + "type": "string", + "const": "Drug" + }, + { + "type": "string", + "const": "Set" + }, + { + "type": "string", + "const": "Genes and Transcripts" + }, + { + "type": "string", + "enum": [ + "Cell", + "ChemicalDrug", + "Complex", + "OtherEntity", + "Polymer", + "ProteinDrug", + "RNADrug", + "SimpleEntity" + ], + "description": "The physical-entity classes the indexer names a type by: those neither a set nor genome-encoded. Every reference-entity class has a type of its own. entity-types.spec.ts derives the list from the domain snapshot." + } + ], + "description": "The indexer's types for a physical entity or a reference entity (`getType`, `getReferenceTypes`); any other class goes by its own name. The list of those names is checked against the domain snapshot." + }, + "stId": { + "$ref": "#/components/schemas/Highlighted" + }, + "species": { + "type": "array", + "items": { + "type": "string" + }, + "description": "\"Entries without species\" when it has none." + }, + "summation": { + "$ref": "#/components/schemas/Highlighted" + }, + "isDisease": { + "type": "boolean" + }, + "disease": { + "type": "boolean" + }, + "hasReferenceEntity": { + "type": "boolean" + }, + "compartmentNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "compartmentAccession": { + "type": "array", + "items": { + "type": "string" + } + }, + "referenceName": { + "$ref": "#/components/schemas/Highlighted" + }, + "referenceIdentifier": { + "$ref": "#/components/schemas/Highlighted" + }, + "databaseName": { + "type": "string" + }, + "referenceURL": { + "type": "string" + }, + "fireworksSpecies": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "dbId", + "exactType", + "hasReferenceEntity", + "id", + "species", + "stId", + "type" + ], + "additionalProperties": false, + "description": "A physical entity or a reference entity." + }, + { + "$ref": "#/components/schemas/IconEntry" + }, + { + "type": "object", + "properties": { + "dbId": { + "type": "string" + }, + "id": { + "type": "string", + "description": "The stId, or the dbId when there is none; never highlighted." + }, + "name": { + "$ref": "#/components/schemas/Highlighted" + }, + "exactType": { + "type": "string", + "const": "Person" + }, + "type": { + "type": "string", + "const": "Person" + }, + "species": { + "type": "array", + "items": { + "type": "string" + }, + "description": "\"Entries without species\"." + }, + "orcidId": { + "type": "string" + }, + "authoredPathways": { + "type": "string" + }, + "authoredReactions": { + "type": "string" + }, + "reviewedPathways": { + "type": "string" + }, + "reviewedReactions": { + "type": "string" + } + }, + "required": [ + "dbId", + "exactType", + "id", + "species", + "type" + ], + "additionalProperties": false, + "description": "A person, by name. No stId: `id` is the dbId. The counts are text, as Solr stores them." + }, + { + "type": "object", + "properties": { + "dbId": { + "type": "string" + }, + "id": { + "type": "string", + "description": "The stId, or the dbId when there is none; never highlighted." + }, + "name": { + "$ref": "#/components/schemas/Highlighted" + }, + "exactType": { + "type": "string", + "const": "Interactor" + }, + "type": { + "type": "string", + "const": "Interactor" + }, + "stId": { + "$ref": "#/components/schemas/Highlighted" + }, + "species": { + "type": "array", + "items": { + "type": "string" + } + }, + "referenceName": { + "$ref": "#/components/schemas/Highlighted" + }, + "referenceIdentifier": { + "$ref": "#/components/schemas/Highlighted" + }, + "databaseName": { + "type": "string" + }, + "referenceURL": { + "type": "string" + }, + "fireworksSpecies": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "dbId", + "exactType", + "id", + "species", + "stId", + "type" + ], + "additionalProperties": false, + "description": "A molecule known only from interaction data; `dbId` and `stId` are its accession." + }, + { + "type": "object", + "properties": { + "dbId": { + "type": "string" + }, + "id": { + "type": "string", + "description": "The stId, or the dbId when there is none; never highlighted." + }, + "name": { + "$ref": "#/components/schemas/Highlighted" + }, + "exactType": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "const": true + }, + "type": { + "type": "string" + }, + "stId": { + "$ref": "#/components/schemas/Highlighted" + }, + "explanation": { + "type": "string" + }, + "date": { + "$ref": "#/components/schemas/EpochMillis" + }, + "replacementDbIds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Long" + } + }, + "replacementStIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "dbId", + "deleted", + "exactType", + "id", + "stId", + "type" + ], + "additionalProperties": false, + "description": "An object of an earlier release, deleted since; its type and exactType are those it had." + } + ] + }, + "TargetResult": { + "type": "object", + "properties": { + "term": { + "type": "string" + }, + "resource": { + "type": [ + "string", + "number", + "boolean" + ] + }, + "target": { + "type": "boolean" + } + }, + "required": [ + "target" + ], + "additionalProperties": false, + "description": "A term the search found nothing for, and whether it names a resource; an empty term or resource is left out." + } + } + } +} diff --git a/projects/contract/routes.ts b/projects/contract/routes.ts new file mode 100644 index 00000000..c0d0583b --- /dev/null +++ b/projects/contract/routes.ts @@ -0,0 +1,30 @@ +// The content service's routes whose answers the contract describes: each +// "METHOD /path", as the registry has it, to the answer it writes with a 200 +// (spec 016, contracts/module.md). The description is generated from this +// (tools/contract/generate.ts); a route the registry has and this does not +// is listed there as not yet in the contract. +import type { + DiagramResult, + FacetResponse, + GroupedResult, + IconEntry, + IconFacetResponse, + IconResult, + PathwayOfEntry, + SearchEntry, +} from './search.ts'; + +export interface Routes { + 'GET /ContentService/search/query': GroupedResult; + 'GET /ContentService/search/query/paginated': GroupedResult; + 'GET /ContentService/search/facet': FacetResponse; + 'GET /ContentService/search/facet_query': FacetResponse; + 'GET /ContentService/search/suggest': string[]; + 'GET /ContentService/search/spellcheck': string[]; + 'GET /ContentService/search/pathways/of/{dbId}': PathwayOfEntry[]; + 'GET /ContentService/search/icon/facet': IconFacetResponse; + 'GET /ContentService/search/icon/query': IconResult; + 'GET /ContentService/search/icon/{identifier}': IconEntry; + 'GET /ContentService/search/diagram/{diagram}': DiagramResult; + 'GET /ContentService/search/fireworks': DiagramResult; +} diff --git a/projects/contract/search.ts b/projects/contract/search.ts index e2f1cff1..95eca0d1 100644 --- a/projects/contract/search.ts +++ b/projects/contract/search.ts @@ -212,7 +212,7 @@ interface DeletedEntry extends Omit { } /** One search result. */ -type SearchEntry = +export type SearchEntry = EventEntry | EntityEntry | IconEntry | PersonEntry | InteractorEntry | DeletedEntry; /** Every field any kind of entry has. */ diff --git a/specs/016-api-contract/tasks.md b/specs/016-api-contract/tasks.md index 7170053b..8a32b1f5 100644 --- a/specs/016-api-contract/tasks.md +++ b/specs/016-api-contract/tasks.md @@ -93,12 +93,15 @@ description: 'Tasks for spec 016: one API contract for the content service and t **Independent test**: [quickstart.md](quickstart.md) §5. -- [ ] T018 [US4] Add ts-json-schema-generator 2.9.0 as a dev dependency (check the lock's diff by name and version at every depth) and write `tools/contract/generate.ts`. +- [x] T018 [US4] Add ts-json-schema-generator 2.9.0 as a dev dependency (check the lock's diff by name and version at every depth) and write `tools/contract/generate.ts`. - It generates the route table's answer types (`discriminatorType: 'open-api'`, `skipTypeCheck: true`) and rewrites the refs to `#/components/schemas/`. - It writes `projects/contract/openapi.json`. - `tools/load.spec.ts` gains `'contract'` in `SERVICES`, and lists `generate` under `RUN_ON_LOAD` if it writes when loaded. -- [ ] T019 [US4] Write `projects/contract/routes.ts`, the route table, as types. `tools/contract/generate.spec.ts` checks it against `tools/content-node/registry.ts` both ways, and marks registry routes not yet in the contract `x-contract: "not yet"`. Show it failing on a row for a route the registry lacks. -- [ ] T020 [US4] Add the up-to-date check in `tools/contract/generate.spec.ts`: regenerate in memory and compare with the committed file. Show it failing on a contract change made without regenerating. + - Done in #707, with T019 and T020 (the generator reads the route table). The lock gains the generator, `commander`, `safe-stable-stringify` and a hoisted `glob`, whose copy of `brace-expansion` resolves to 5.0.12 where the two nested copies had 5.0.9 (in its range). It writes only when run as a script (`import.meta.main`), so loading it does nothing and it is not in `RUN_ON_LOAD`. A generic instance's name (`GroupedResult`) becomes an OpenAPI component name (`GroupedResult_SearchEntry`); the search entry's union is written as `oneOf` of its six kinds (research R4); `Long` is `integer`, `int64`. In 1.7 s, the same bytes on every run. Prettier leaves the file alone (`.prettierignore`); the up-to-date check holds it. +- [x] T019 [US4] Write `projects/contract/routes.ts`, the route table, as types. `tools/contract/generate.spec.ts` checks it against `tools/content-node/registry.ts` both ways, and marks registry routes not yet in the contract `x-contract: "not yet"`. Show it failing on a row for a route the registry lacks. + - Done in #707. `projects/contract/routes.ts` is the interface `Routes`, each `"METHOD /path"` as the registry has it to its 200 answer: twelve search routes. The description lists all 90 the service serves, 78 marked `x-contract: "not yet"`. A row for a route the registry lacks failed the check. +- [x] T020 [US4] Add the up-to-date check in `tools/contract/generate.spec.ts`: regenerate in memory and compare with the committed file. Show it failing on a contract change made without regenerating. + - Done in #707. A contract field renamed without regenerating failed it. - [ ] T021 [US4] Add ajv 8.20.0 as a dev dependency and `tools/api-types/schema.spec.ts`, which validates the recorded answers of contract routes against `openapi.json`. - Use ajv 2020-12, with `discriminator: true` where a type has one, and register the `int64` format. - Read exact longs with a JSON.parse reviver. diff --git a/tools/contract/generate.spec.ts b/tools/contract/generate.spec.ts new file mode 100644 index 00000000..438f36b5 --- /dev/null +++ b/tools/contract/generate.spec.ts @@ -0,0 +1,55 @@ +// @vitest-environment node +// +// The API description (spec 016, US4): generated from the contract, its route +// table held to the service's registry, and the committed file current. +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { DESCRIPTION, componentName, generate, servedRoutes, written } from './generate.ts'; + +/** The contract's route table's keys, as the routes module declares them. */ +function contractRoutes(source: string): string[] { + return [...source.matchAll(/^ {2}'((?:GET|POST) [^']+)':/gm)].map((match) => match[1] ?? ''); +} + +const ROUTES = readFileSync(new URL('../../projects/contract/routes.ts', import.meta.url), 'utf8'); + +describe('the API description', () => { + it('describes only routes the service serves', () => { + const served = new Set(servedRoutes()); + expect(contractRoutes(ROUTES).filter((route) => !served.has(route))).toEqual([]); + }); + + it('lists every served route, the ones the contract lacks marked not yet', () => { + const document = generate(); + const described = new Set(contractRoutes(ROUTES)); + for (const route of servedRoutes()) { + const [method = '', where = ''] = route.split(' '); + const operation = document.paths[where]?.[method.toLowerCase()]; + expect(operation, route).toBeDefined(); + expect(operation?.['x-contract'], route).toBe(described.has(route) ? undefined : 'not yet'); + } + }); + + it('is the committed file: regenerate it with `node tools/contract/generate.ts`', () => { + expect(readFileSync(DESCRIPTION, 'utf8')).toBe(written()); + }); + + it('writes the search entry as exactly one of its kinds', () => { + const entry = generate().components.schemas['SearchEntry'] as { + oneOf?: unknown[]; + anyOf?: unknown; + }; + expect(entry.anyOf).toBeUndefined(); + expect(entry.oneOf).toHaveLength(6); + }); + + it('names a generic instance as OpenAPI allows', () => { + expect(componentName('GroupedResult')).toBe('GroupedResult_SearchEntry'); + expect(componentName('Map>')).toBe( + 'Map_string_ResultGroup_IconEntry' + ); + expect(Object.keys(generate().components.schemas).every((name) => /^[\w.-]+$/.test(name))).toBe( + true + ); + }); +}); diff --git a/tools/contract/generate.ts b/tools/contract/generate.ts new file mode 100644 index 00000000..56f0beaa --- /dev/null +++ b/tools/contract/generate.ts @@ -0,0 +1,117 @@ +/** + * The API description, generated from the contract (spec 016, US4): an + * OpenAPI 3.1 document of every route the content service answers, with the + * answer the contract has for it, and the rest listed as not yet in the + * contract. Run as a script, it writes projects/contract/openapi.json; + * generate.spec.ts checks the committed file is what this makes. + * + * node tools/contract/generate.ts + */ +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createGenerator } from 'ts-json-schema-generator'; +import { endpoints } from '../content-node/registry.ts'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +export const DESCRIPTION = path.join(ROOT, 'projects/contract/openapi.json'); + +type Schema = { [key: string]: unknown }; + +/** + * A component name OpenAPI accepts (`^[a-zA-Z0-9._-]+$`): the generator names + * an instance of a generic type `GroupedResult`. + */ +export const componentName = (name: string) => name.replace(/[<>, ]+/g, '_').replace(/_+$/, ''); + +/** Every `$ref` in a schema, rewritten to a component, by name. */ +function rewriteRefs(value: unknown): unknown { + if (Array.isArray(value)) return value.map(rewriteRefs); + if (value === null || typeof value !== 'object') return value; + const out: Schema = {}; + for (const [key, inner] of Object.entries(value as Schema)) + out[key] = + key === '$ref' && typeof inner === 'string' + ? `#/components/schemas/${componentName(decodeURIComponent(inner.replace('#/definitions/', '')))}` + : rewriteRefs(inner); + return out; +} + +/** The registry's routes, as "METHOD /path". */ +export function servedRoutes(): string[] { + return endpoints.map((each) => `${(each.method ?? 'get').toUpperCase()} ${each.path}`); +} + +/** The contract's route table: "METHOD /path" to its answer's schema, and the shared definitions. */ +function contractSchemas() { + const schema = createGenerator({ + path: path.join(ROOT, 'projects/contract/routes.ts'), + tsconfig: path.join(ROOT, 'tools/tsconfig.json'), + type: 'Routes', + // The type checks have run already (check:types); the generator's own + // would repeat them, slower. + skipTypeCheck: true, + discriminatorType: 'open-api', + expose: 'export', + topRef: true, + jsDoc: 'extended', + additionalProperties: false, + }).createSchema('Routes'); + const definitions = { ...(schema.definitions ?? {}) } as Record; + const table = definitions['Routes'] as { properties: Record }; + delete definitions['Routes']; + // The search entry's kinds share no single tag (a deleted entry keeps the + // type it had), so it has no discriminator, and the generator writes its + // union as anyOf, which accepts an entry matching two kinds. Exactly one. + const entry = definitions['SearchEntry']; + if (entry?.['anyOf']) { + entry['oneOf'] = entry['anyOf']; + delete entry['anyOf']; + } + return { table: table.properties, definitions }; +} + +/** The OpenAPI document, as an object. */ +export function generate() { + const { table, definitions } = contractSchemas(); + const paths: Record> = {}; + const served = servedRoutes(); + for (const route of [...new Set([...served, ...Object.keys(table)])].sort()) { + const [method = '', where = ''] = route.split(' '); + const answer = table[route]; + (paths[where] ??= {})[method.toLowerCase()] = answer + ? { + responses: { + '200': { + description: 'The answer.', + content: { 'application/json': { schema: rewriteRefs(answer) } }, + }, + }, + } + : { + 'x-contract': 'not yet', + responses: { '200': { description: 'Not yet described by the contract.' } }, + }; + } + const schemas = Object.fromEntries( + Object.entries(definitions) + .map(([name, schema]) => [componentName(name), rewriteRefs(schema)] as const) + .sort(([a], [b]) => a.localeCompare(b)) + ); + return { + openapi: '3.1.1', + info: { + title: 'Reactome Content Service', + version: 'contract', + description: + 'Generated from the shared contract (projects/contract). A route marked x-contract "not yet" is answered by the service but not yet described here.', + }, + paths, + components: { schemas }, + }; +} + +/** The document as written: two-space JSON and a final newline. */ +export const written = () => `${JSON.stringify(generate(), null, 2)}\n`; + +if (import.meta.main) writeFileSync(DESCRIPTION, written()); diff --git a/tools/load.spec.ts b/tools/load.spec.ts index f2d4f1b1..733d5637 100644 --- a/tools/load.spec.ts +++ b/tools/load.spec.ts @@ -24,6 +24,7 @@ const SERVICES = [ 'analysis-types', 'java-parity', 'library-update', + 'contract', ]; /**