From 67c7d56fe27ae2a41ccbd579153e07bb3bc300c2 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 27 Oct 2026 02:45:25 +0000 Subject: [PATCH 1/3] Spec 016 T018-T020: the API description, generated from the contract tools/contract/generate.ts (ts-json-schema-generator 2.9.0) writes projects/contract/openapi.json, an OpenAPI 3.1 document of every route the content service serves: the twelve search routes the contract's route table (projects/contract/routes.ts) describes, with their answers, and the 78 others marked not yet. The search entry is oneOf its six kinds; a Java long is int64. generate.spec.ts holds the table to the registry and the committed file to what the generator writes; each shown failing. Co-Authored-By: Claude Opus 5.5 --- .prettierignore | 2 + package-lock.json | 199 ++-- package.json | 1 + projects/contract/openapi.json | 1812 +++++++++++++++++++++++++++++++ projects/contract/routes.ts | 30 + projects/contract/search.ts | 2 +- specs/016-api-contract/tasks.md | 9 +- tools/contract/generate.spec.ts | 55 + tools/contract/generate.ts | 117 ++ tools/load.spec.ts | 1 + 10 files changed, 2126 insertions(+), 102 deletions(-) create mode 100644 projects/contract/openapi.json create mode 100644 projects/contract/routes.ts create mode 100644 tools/contract/generate.spec.ts create mode 100644 tools/contract/generate.ts 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', ]; /** From 8753810eb1b23218ed8ab9f3e8447457f1d8df35 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 27 Oct 2026 03:12:29 +0000 Subject: [PATCH 2/3] Spec 016, from #707's review: path variables, clashing names, the true counts Each templated path declares its variables, as OpenAPI requires (Redocly's spec rules now pass). Component names that would clash once made valid fail the generator rather than leaving one schema for two; schemas sort by code unit, the same in every locale; the route check reads the generator's own table, not a regex over the source. The description has 91 routes on 90 paths, 79 not yet in the contract. Co-Authored-By: Claude Opus 5.5 --- projects/contract/openapi.json | 684 +++++++++++++++++++++ specs/016-api-contract/contracts/module.md | 2 +- specs/016-api-contract/tasks.md | 6 +- tools/contract/generate.spec.ts | 35 +- tools/contract/generate.ts | 44 +- 5 files changed, 751 insertions(+), 20 deletions(-) diff --git a/projects/contract/openapi.json b/projects/contract/openapi.json index 68202b0e..57388cfe 100644 --- a/projects/contract/openapi.json +++ b/projects/contract/openapi.json @@ -19,6 +19,16 @@ "/ContentService/citation/pathway/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -29,6 +39,16 @@ "/ContentService/citation/static/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -39,6 +59,16 @@ "/ContentService/data/complex/{id}/subunits": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -49,6 +79,24 @@ "/ContentService/data/complexes/{resource}/{identifier}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "resource", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "identifier", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -109,6 +157,16 @@ "/ContentService/data/detail/{id}/locationsInPWB": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -119,6 +177,16 @@ "/ContentService/data/discover/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -149,6 +217,16 @@ "/ContentService/data/entity/{id}/componentOf": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -159,6 +237,16 @@ "/ContentService/data/entity/{id}/in-depth": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -169,6 +257,16 @@ "/ContentService/data/entity/{id}/otherForms": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -179,6 +277,16 @@ "/ContentService/data/event/{id}/ancestors": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -189,6 +297,16 @@ "/ContentService/data/event/{id}/in-depth": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -199,6 +317,16 @@ "/ContentService/data/instance/{id}/referrers": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -209,6 +337,24 @@ "/ContentService/data/mapping/{resource}/{identifier}/pathways": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "resource", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "identifier", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -219,6 +365,24 @@ "/ContentService/data/mapping/{resource}/{identifier}/reactions": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "resource", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "identifier", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -229,6 +393,24 @@ "/ContentService/data/orthology/{id}/species/{speciesId}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "speciesId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -239,6 +421,16 @@ "/ContentService/data/participants/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -249,6 +441,16 @@ "/ContentService/data/participants/{id}/participatingPhysicalEntities": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -259,6 +461,16 @@ "/ContentService/data/participants/{id}/referenceEntities": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -269,6 +481,16 @@ "/ContentService/data/pathway/{id}/containedEvents": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -279,6 +501,24 @@ "/ContentService/data/pathway/{id}/containedEvents/{attribute}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "attribute", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -289,6 +529,16 @@ "/ContentService/data/pathways/low/diagram/entity/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -299,6 +549,16 @@ "/ContentService/data/pathways/low/diagram/entity/{id}/allForms": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -309,6 +569,16 @@ "/ContentService/data/pathways/low/entity/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -319,6 +589,16 @@ "/ContentService/data/pathways/low/entity/{id}/allForms": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -329,6 +609,16 @@ "/ContentService/data/pathways/top/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -339,6 +629,16 @@ "/ContentService/data/person/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -349,6 +649,16 @@ "/ContentService/data/person/{id}/authoredPathways": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -359,6 +669,16 @@ "/ContentService/data/person/{id}/authoredReactions": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -369,6 +689,16 @@ "/ContentService/data/person/{id}/publications": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -379,6 +709,16 @@ "/ContentService/data/person/{id}/reviewedPathways": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -389,6 +729,16 @@ "/ContentService/data/person/{id}/reviewedReactions": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -399,6 +749,24 @@ "/ContentService/data/person/{id}/{attribute}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "attribute", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -409,6 +777,16 @@ "/ContentService/data/query/enhanced/v2/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -419,6 +797,16 @@ "/ContentService/data/query/enhanced/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -447,6 +835,16 @@ "/ContentService/data/query/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -457,6 +855,16 @@ "/ContentService/data/query/{id}/normalPathway": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -467,6 +875,16 @@ "/ContentService/data/query/{id}/stId": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -477,6 +895,24 @@ "/ContentService/data/query/{id}/{attribute}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "attribute", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -497,6 +933,16 @@ "/ContentService/data/schema/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -507,6 +953,16 @@ "/ContentService/data/schema/{id}/attributes": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -517,6 +973,16 @@ "/ContentService/data/schema/{id}/count": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -527,6 +993,16 @@ "/ContentService/data/schema/{id}/min": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -537,6 +1013,16 @@ "/ContentService/data/schema/{id}/reference": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -547,6 +1033,16 @@ "/ContentService/data/schema/{id}/referrals": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -577,6 +1073,16 @@ "/ContentService/exporter/diagram/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -587,6 +1093,16 @@ "/ContentService/exporter/event/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -597,6 +1113,16 @@ "/ContentService/exporter/fireworks/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -607,6 +1133,16 @@ "/ContentService/exporter/sbml/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -617,6 +1153,16 @@ "/ContentService/exporter/voronoi/{id}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -637,6 +1183,16 @@ "/ContentService/interactors/static/molecule/enhanced/{id}/details": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -647,6 +1203,16 @@ "/ContentService/interactors/static/molecule/{id}/details": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -657,6 +1223,16 @@ "/ContentService/interactors/static/molecule/{id}/pathways": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -667,6 +1243,16 @@ "/ContentService/interactors/static/molecule/{id}/summary": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -677,6 +1263,16 @@ "/ContentService/interactors/static/molecule/{id}/withEntities": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -687,6 +1283,16 @@ "/ContentService/references/mapping/{identifier}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "identifier", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -697,6 +1303,16 @@ "/ContentService/references/mapping/{identifier}/xrefs": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "identifier", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -716,6 +1332,16 @@ }, "/ContentService/search/diagram/{diagram}": { "get": { + "parameters": [ + { + "name": "diagram", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "The answer.", @@ -733,6 +1359,24 @@ "/ContentService/search/diagram/{diagram}/occurrences/{instance}": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "diagram", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "instance", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -743,6 +1387,16 @@ "/ContentService/search/diagram/{pathwayId}/flag": { "get": { "x-contract": "not yet", + "parameters": [ + { + "name": "pathwayId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." @@ -842,6 +1496,16 @@ }, "/ContentService/search/icon/{identifier}": { "get": { + "parameters": [ + { + "name": "identifier", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "The answer.", @@ -858,6 +1522,16 @@ }, "/ContentService/search/pathways/of/{dbId}": { "get": { + "parameters": [ + { + "name": "dbId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "The answer.", @@ -948,6 +1622,16 @@ "/ContentService/data/orthologies/ids/species/{speciesId}": { "post": { "x-contract": "not yet", + "parameters": [ + { + "name": "speciesId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Not yet described by the contract." diff --git a/specs/016-api-contract/contracts/module.md b/specs/016-api-contract/contracts/module.md index be1fcb8c..d41b6f11 100644 --- a/specs/016-api-contract/contracts/module.md +++ b/specs/016-api-contract/contracts/module.md @@ -31,6 +31,6 @@ projects/contract/ - **The format.** It is OpenAPI 3.1.1, generated by a script under `tools/` (TypeScript, spec 013) with ts-json-schema-generator 2.9.0 (`discriminatorType: 'open-api'`, `skipTypeCheck: true`: the contract is type-checked by `tools/tsconfig.json`, whose `include` gains `../projects/contract/**/*.ts`, so a module no reader imports yet is still checked). The search entry's union is rewritten from `anyOf` to `oneOf` (research R4). - **`components.schemas`.** These are the generator's definitions for the answer types the route table names, with refs rewritten from `#/definitions/` to `#/components/schemas/`. -- **`paths`.** There is one per route in the route table: its method, and a `200` response with the answer type's `$ref`. A route the service serves that is not yet in the contract is listed with the extension `x-contract: "not yet"` and no response schema, so nothing presents an unchecked generic object as checked. +- **`paths`.** There is one per route the service serves: its method, its path variables, and for a route in the route table a `200` response with the answer's schema (a `$ref` where the answer is a named type). A route the service serves that is not yet in the contract is listed with the extension `x-contract: "not yet"` and no response schema, so nothing presents an unchecked generic object as checked. - **What it does not hold.** Parameters stay in Java's `tools/content-node/openapi-snapshot.json` (spec 011's cache labelling) until a later task merges them in. The site's API page keeps reading Java's live description until the service publishes this one. - **When it is regenerated.** It is regenerated in the same change as any contract change; the up-to-date check fails otherwise. diff --git a/specs/016-api-contract/tasks.md b/specs/016-api-contract/tasks.md index 8a32b1f5..ab3e4f87 100644 --- a/specs/016-api-contract/tasks.md +++ b/specs/016-api-contract/tasks.md @@ -97,11 +97,11 @@ description: 'Tasks for spec 016: one API contract for the content service and t - 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. - - 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. + - Done in #708, 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. + - Done in #708. `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 91 routes the service serves (90 paths; one is both GET and POST), 79 marked `x-contract: "not yet"`, each templated path's variables declared as OpenAPI requires (from #707's review; Redocly's spec rules then pass). Component names that would clash once made valid fail the generator; the order is by code unit, the same in every locale. 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. + - Done in #708. 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 index 438f36b5..4e9fd2f5 100644 --- a/tools/contract/generate.spec.ts +++ b/tools/contract/generate.spec.ts @@ -4,24 +4,25 @@ // 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'); +import { + DESCRIPTION, + componentName, + components, + contractRoutes, + generate, + servedRoutes, + written, +} from './generate.ts'; 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([]); + expect(contractRoutes().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)); + const described = new Set(contractRoutes()); for (const route of servedRoutes()) { const [method = '', where = ''] = route.split(' '); const operation = document.paths[where]?.[method.toLowerCase()]; @@ -43,6 +44,20 @@ describe('the API description', () => { expect(entry.oneOf).toHaveLength(6); }); + it('declares every templated path variable, as OpenAPI requires', () => { + for (const [where, operations] of Object.entries(generate().paths)) + for (const operation of Object.values(operations)) { + const declared = ((operation['parameters'] ?? []) as { name: string }[]).map((p) => p.name); + const templated = [...where.matchAll(/\{([^}]+)\}/g)].map(([, name]) => name); + expect(declared, where).toEqual(templated); + } + }); + + it('refuses two types whose component names would clash', () => { + expect(() => components({ 'A': {}, 'A': {} })).toThrow(/clash: A_B_C/); + expect(Object.keys(components({ 'B': {}, A: {} }))).toEqual(['A', 'B_X']); + }); + it('names a generic instance as OpenAPI allows', () => { expect(componentName('GroupedResult')).toBe('GroupedResult_SearchEntry'); expect(componentName('Map>')).toBe( diff --git a/tools/contract/generate.ts b/tools/contract/generate.ts index 56f0beaa..ea5ce603 100644 --- a/tools/contract/generate.ts +++ b/tools/contract/generate.ts @@ -71,16 +71,51 @@ function contractSchemas() { return { table: table.properties, definitions }; } +/** The route table's routes, as "METHOD /path": what the contract describes. */ +export const contractRoutes = () => Object.keys(contractSchemas().table); + +/** Code-unit order, the same in every locale (localeCompare is not). */ +const byCodeUnit = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); + +/** + * A templated path's variables, as OpenAPI requires them declared: text, as + * the service reads them (an id may be a stable id or a dbId). + */ +const pathParameters = (where: string) => + [...where.matchAll(/\{([^}]+)\}/g)].map(([, name]) => ({ + name, + in: 'path', + required: true, + schema: { type: 'string' }, + })); + +/** + * The definitions as components, by their valid names, in code-unit order. + * Two types named alike once made valid (A and A) would leave one + * schema in place of two, and nothing would say so: that fails here. + */ +export function components(definitions: Record): Record { + const named = Object.entries(definitions).map( + ([name, schema]) => [componentName(name), rewriteRefs(schema)] as const + ); + const clashes = named.filter(([name], at) => named.findIndex(([other]) => other === name) !== at); + if (clashes.length) + throw new Error(`component names clash: ${clashes.map(([name]) => name).join(', ')}`); + return Object.fromEntries(named.sort(([a], [b]) => byCodeUnit(a, b))); +} + /** 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()) { + for (const route of [...new Set([...served, ...Object.keys(table)])].sort(byCodeUnit)) { const [method = '', where = ''] = route.split(' '); const answer = table[route]; + const parameters = pathParameters(where); (paths[where] ??= {})[method.toLowerCase()] = answer ? { + ...(parameters.length ? { parameters } : {}), responses: { '200': { description: 'The answer.', @@ -90,14 +125,11 @@ export function generate() { } : { 'x-contract': 'not yet', + ...(parameters.length ? { parameters } : {}), 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)) - ); + const schemas = components(definitions); return { openapi: '3.1.1', info: { From 8f0acb417b77e58f3c1eec243665d88d382803e6 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 27 Oct 2026 03:21:43 +0000 Subject: [PATCH 3/3] Spec 016: knip takes the route table as an entry The generator reads projects/contract/routes.ts by path, which knip cannot follow; without the entry it counts the route table, and the types only it uses, as dead. Co-Authored-By: Claude Opus 5.5 --- knip.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/knip.json b/knip.json index f59e0beb..d550a6d2 100644 --- a/knip.json +++ b/knip.json @@ -19,7 +19,8 @@ "tools/analysis-types/record.ts", "tools/svg-export-harness/{harness,harness.check,playwright.config}.ts", "tools/java-parity/parity.ts", - "tools/library-update/check-library-update.ts" + "tools/library-update/check-library-update.ts", + "projects/contract/routes.ts" ], "project": [ "src/**/*.ts",