From f11a37dcd2efd440b31c1f4ee7d47c6616b7588d Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 7 Nov 2026 23:07:33 +0000 Subject: [PATCH 1/4] Spec 006 T104: node answers /query/abridged/{id}, its attributes, and /query/{id}/more Three endpoints Java answers but leaves out of its description, now in the data-query family: - /query/abridged/{id}: graph-core's findByIdNoRelations, the node alone by the id as getIdentifier reads it, written as /query/{id} writes an object. - /query/abridged/{id}/{attributeName}: ControllerUtils.getProperty on that object; a relationship getter loads its relationship, outgoing ones too, as LazyFetchAspect does. - /query/{id}/more: /query/enhanced/v2/{id} with its flags fixed, reading no parameter (enhancedAnswer, shared with v2). Spring's order is kept in the registry: /query/abridged/more and /query/enhanced/more are the object called more. Dots alone are Java's 500 on all three and on /enhanced/v2/{id}, which answered a 404. Routed in nginx and the dev proxy: one location now takes every /query/{id}/{x}, and another the abridged attributes. /query/{id}/more joins the contract as v2's answer; the description is regenerated. Co-Authored-By: Claude Opus 5.5 --- deploy/nginx/common/routes-core.conf | 22 +- deploy/nginx/routes.spec.ts | 6 +- projects/contract/openapi.json | 74 ++++++ projects/contract/routes.ts | 1 + .../src/scripts/proxy-order.spec.ts | 18 +- .../src/scripts/serve-prod.spec.ts | 11 +- proxy.conf.js | 12 +- specs/006-content-service-to-node/tasks.md | 2 +- tools/content-node/classify.spec.ts | 27 +- tools/content-node/classify.ts | 6 - tools/content-node/data-query-endpoints.ts | 199 ++++++++++---- tools/content-node/fixtures/route-order.json | 3 + tools/content-node/objects.ts | 11 +- tools/content-node/parameters.ts | 18 ++ tools/content-node/query-abridged.spec.ts | 246 ++++++++++++++++++ .../query-attribute-endpoint.spec.ts | 10 - tools/content-node/registry.spec.ts | 10 +- 17 files changed, 569 insertions(+), 107 deletions(-) create mode 100644 tools/content-node/query-abridged.spec.ts diff --git a/deploy/nginx/common/routes-core.conf b/deploy/nginx/common/routes-core.conf index d80c6b44b..ee03ed413 100644 --- a/deploy/nginx/common/routes-core.conf +++ b/deploy/nginx/common/routes-core.conf @@ -312,8 +312,8 @@ location ~ ^/ContentService/data/pathways/top/[^/]+/?$ { # OUTGOING) as Java's Jackson writes it, in each of the views Java's converter # offers (?view=nested|nested-aggregated, ?includeRef=true). The model is read # from the deployed graph-core by snapshot-domain.ts, not restated. Only the -# plain path: /query/{id}/{attribute} and /query/enhanced|abridged/{id} are -# other endpoints. Byte-identical to Java over a corpus of 20,000 objects of +# plain path: /query/{id}/{attribute}, /query/enhanced|abridged/{id} and +# /query/{id}/more are other endpoints, below. Byte-identical to Java over a corpus of 20,000 objects of # every class, bar the order of a few getter-only properties in the nested # views, which Java itself does not keep from one start to the next. # Including POST /query/ids, a batch of objects by id, and its map form @@ -342,10 +342,20 @@ location ~ ^/ContentService/data/diseases(/doid)?/?$ { # id forms -- versions, REACT_ ids, # dbIds -- bar a 404 body's `url`, which names the # service that answered (the same declared difference as /pathways/top/{id}). -# `abridged` and `enhanced` are not ids, nor `more` an attribute: Java's -# more specific /query/abridged/{id}, /query/enhanced/{id} and -# /query/{id}/more take those paths. -location ~ ^/ContentService/data/query/(?!(abridged|enhanced)/)[^/]+/(?!more/?$)[^/]+/?$ { +# Java's more specific /query/abridged/{id}, /query/enhanced/{id} and +# /query/{id}/more share this pattern, and node answers each in its own +# handler, so one location takes all three. +# +# /query/abridged/{id}, and its attributes: the object without its +# relationships (graph-core's findByIdNoRelations), written and read as +# above, a relationship's getter loading it as LazyFetchAspect does. +# /query/{id}/more: /query/enhanced/v2/{id} with its flags fixed. Java +# answers all three but leaves them out of its description. Their errors +# compared live with Java's (spec 006 T104), bar a 404 body's `url`. +location ~ ^/ContentService/data/query/[^/]+/[^/]+/?$ { + include /etc/nginx/common/content-node-proxy.conf; +} +location ~ ^/ContentService/data/query/abridged/[^/]+/[^/]+/?$ { include /etc/nginx/common/content-node-proxy.conf; } diff --git a/deploy/nginx/routes.spec.ts b/deploy/nginx/routes.spec.ts index 49dcfd434..00058833a 100644 --- a/deploy/nginx/routes.spec.ts +++ b/deploy/nginx/routes.spec.ts @@ -370,8 +370,8 @@ const JAVAS = [ '/ContentService/data/content/toc/X1', '/ContentService/data/diseases/X1', '/ContentService/data/schema/model/X1', - '/ContentService/data/query/abridged/R-HSA-109581', - '/ContentService/data/query/R-HSA-109581/more', + '/ContentService/data/query/abridged/R-HSA-109581/stId/x', + '/ContentService/data/query/R-HSA-109581/more/x', '/ContentService/interactors/psicquic/resourcesx', '/ContentService/search/facet_queryx', // An event's other exports, beside its SBML (spec 015 T066), SBGN and BioPAX (T071). @@ -445,7 +445,7 @@ describe('a matrix parameter', () => { it('is not an encoded one, nor one in the query string', async () => { for (const url of [ - '/ContentService/data/query/abridged/R-HSA-109581%3Bx', + '/ContentService/data/pathway/R-HSA-109606%3Bx', '/ContentService/data/species/9606?a=1;b=2', ]) expect([url, nginxTarget(url), await proxySendsToNode(url)]).toEqual([url, 'tomcat', false]); diff --git a/projects/contract/openapi.json b/projects/contract/openapi.json index b28912a16..74ff8164c 100644 --- a/projects/contract/openapi.json +++ b/projects/contract/openapi.json @@ -3076,6 +3076,54 @@ } } }, + "/ContentService/data/query/abridged/{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." + } + } + } + }, + "/ContentService/data/query/abridged/{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." + } + } + } + }, "/ContentService/data/query/enhanced/v2/{id}": { "get": { "description": "Based on the given identifier, i.e. stable id or database id, this method queries for an entry in Reactome knowledgebase providing more information. In particular, the retrieved database object has all its properties and direct relationships (relationships of depth 1) filled, while it also includes any second level relationships regarding regulations and catalysts.", @@ -3366,6 +3414,32 @@ } } }, + "/ContentService/data/query/{id}/more": { + "get": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The answer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GraphObject_JsogReference" + } + } + } + } + } + } + }, "/ContentService/data/query/{id}/normalPathway": { "get": { "x-contract": "not yet", diff --git a/projects/contract/routes.ts b/projects/contract/routes.ts index 132ab9b6b..f7814ed13 100644 --- a/projects/contract/routes.ts +++ b/projects/contract/routes.ts @@ -48,6 +48,7 @@ export interface Routes { 'GET /ContentService/data/schema/{id}/min': SimpleDatabaseObject[]; 'GET /ContentService/data/instance/{id}/referrers': InstanceReferrals[]; 'GET /ContentService/data/query/enhanced/v2/{id}': GraphObject; + 'GET /ContentService/data/query/{id}/more': GraphObject; 'GET /ContentService/data/detail/{id}/locationsInPWB': PathwayBrowserNode[]; 'GET /ContentService/data/participants/{id}': Participant[]; 'GET /ContentService/data/person/{id}/authoredPathways': SimpleEvent[]; diff --git a/projects/website-angular/src/scripts/proxy-order.spec.ts b/projects/website-angular/src/scripts/proxy-order.spec.ts index 31169f81c..02b9da11b 100644 --- a/projects/website-angular/src/scripts/proxy-order.spec.ts +++ b/projects/website-angular/src/scripts/proxy-order.spec.ts @@ -178,6 +178,15 @@ describe('the dev server proxy', () => { '/ContentService/data/query/70486/NORMALPATHWAY', '/ContentService/data/query/70486/normalPathways', '/ContentService/data/query/R-HSA-109581/hasEvent/', + // Java's more specific /query/abridged/{id} takes this, reading the + // attribute's name as the id, as /query/enhanced/{id} does. + '/ContentService/data/query/abridged/stId', + '/ContentService/data/query/abridged/70486', + '/ContentService/data/query/abridged/70486/hasEvent', + '/ContentService/data/query/abridged/70486/more/', + // Java's own /query/{id}/more, not an attribute called more. + '/ContentService/data/query/70486/more', + '/ContentService/data/query/70486/more/', '/ContentService/data/person/68285/firstname', '/ContentService/data/person/68285/authoredPathwaysAndMore', ]) { @@ -332,16 +341,9 @@ describe('the dev server proxy', () => { } for (const other of [ - // Java's more specific /query/abridged/{id} takes this, reading the - // attribute's name as the id. (/query/enhanced/{id} does the same, and - // is node's now: see above.) - '/ContentService/data/query/abridged/stId', - '/ContentService/data/query/abridged/70486', - // Java's own /query/{id}/more, not an attribute called more. - '/ContentService/data/query/70486/more', - '/ContentService/data/query/70486/more/', // Below an attribute: no mapping. '/ContentService/data/query/70486/displayName/x', + '/ContentService/data/query/abridged/70486/displayName/x', '/ContentService/data/person/68285/firstname/x', // The search endpoints node does not serve yet. '/ContentService/search/query/x?query=apoptosis', diff --git a/projects/website-angular/src/scripts/serve-prod.spec.ts b/projects/website-angular/src/scripts/serve-prod.spec.ts index f4b9420d3..cdd4d0772 100644 --- a/projects/website-angular/src/scripts/serve-prod.spec.ts +++ b/projects/website-angular/src/scripts/serve-prod.spec.ts @@ -318,7 +318,10 @@ describe('serve-prod and the API roots', () => { ['/ContentService/data/person/68285/authoredPathwaysAndMore', 'content-node'], ['/ContentService/data/person/68285/displayName/x', 'backend'], ['/ContentService/data/query/70486/displayName', 'content-node'], - ['/ContentService/data/query/70486/more', 'backend'], + ['/ContentService/data/query/70486/more', 'content-node'], + ['/ContentService/data/query/abridged/70486', 'content-node'], + ['/ContentService/data/query/abridged/70486/displayName', 'content-node'], + ['/ContentService/data/query/abridged/70486/displayName/x', 'backend'], ['/ContentService/data/query/70486/moreover', 'content-node'], ['/ContentService/data/query/R-HSA-70486', 'content-node'], ['/ContentService/data/query/R-HSA-70486?view=nested', 'content-node'], @@ -358,9 +361,9 @@ describe('serve-prod and the API roots', () => { for (const api of [ // A path still answered by Java. /database/version, then - // /query/{id}/displayName, were the example until each moved to the node - // content service. - '/ContentService/data/query/abridged/70486', + // /query/{id}/displayName, then /query/abridged/{id}, were the example + // until each moved to the node content service. + '/ContentService/data/pathways/top/9606/curated', '/ContentService/v3/api-docs', '/AnalysisService/v3/api-docs', '/ContentService/swagger-ui/index.html', diff --git a/proxy.conf.js b/proxy.conf.js index eb432c30d..594db98d0 100644 --- a/proxy.conf.js +++ b/proxy.conf.js @@ -154,18 +154,18 @@ module.exports = { '^/ContentService/data/people/name/[^/?]+(/exact)?/?(\\?|$)': contentNode, // One object with everything it points to, in any of Java's views // (?view=, ?includeRef=). Only the plain path: /query/{id}/... and Java's - // /query/enhanced|abridged/{id} are other endpoints. + // /query/enhanced|abridged/{id} are other endpoints, below. // Including POST /query/ids, a batch of objects by id, and its map form. '^/ContentService/data/query/[^/?]+/?(\\?|$)': contentNode, '^/ContentService/data/query/ids/map/?(\\?|$)': contentNode, // Every disease, whole and as a DOID list. '^/ContentService/data/diseases(/doid)?/?(\\?|$)': contentNode, // Any attribute of an object, as Java's /data/query/{id}/{attributeName} - // writes it. `abridged` and `enhanced` are not ids, nor `more` an attribute: - // Java's more specific /query/abridged/{id}, /query/enhanced/{id} and - // /query/{id}/more take those paths. - '^/ContentService/data/query/(?!(abridged|enhanced)/)[^/?]+/(?!more/?(\\?|$))[^/?]+/?(\\?|$)': - contentNode, + // writes it, and Java's more specific /query/abridged/{id}, + // /query/enhanced/{id} and /query/{id}/more, which node answers too. Then an + // attribute of an object without its relationships. Same patterns as nginx's. + '^/ContentService/data/query/[^/?]+/[^/?]+/?(\\?|$)': contentNode, + '^/ContentService/data/query/abridged/[^/?]+/[^/?]+/?(\\?|$)': contentNode, // Every route from an event up to a top-level pathway; the Pathway Browser // asks on every pathway it opens. '^/ContentService/data/event/[^/?]+/ancestors/?(\\?|$)': contentNode, diff --git a/specs/006-content-service-to-node/tasks.md b/specs/006-content-service-to-node/tasks.md index 24aea8a5f..70de3b7f3 100644 --- a/specs/006-content-service-to-node/tasks.md +++ b/specs/006-content-service-to-node/tasks.md @@ -112,7 +112,7 @@ These were held in Java because their substance is a Java library or Java numeri Found 19 Oct 2026 by comparing every mapping in the Java source with the published description (which the phases above follow). The site calls none of them; outside users of the API may. Each still answered by Java. -- [ ] T104 Graph reads: `GET /data/query/abridged/{id}`, `/data/query/abridged/{id}/{attributeName}`, `/data/query/{id}/more` +- [x] T104 Graph reads: `GET /data/query/abridged/{id}`, `/data/query/abridged/{id}/{attributeName}`, `/data/query/{id}/more`. Node answers all three (data-query family), routed in nginx and the dev proxy. The abridged object is graph-core's findByIdNoRelations: the node alone, by the id as getIdentifier reads it, written as `/query/{id}` writes an object; its attribute is ControllerUtils.getProperty on it, a relationship getter loading the relationship, outgoing ones too, as LazyFetchAspect does. `/more` is `/query/enhanced/v2/{id}` with its flags fixed (incoming relationships, no summary, disease data only if nothing is found without), reading no parameter. Spring's order kept: `/query/abridged/more` and `/query/enhanced/more` are the object called more. Dots alone are Java's 500 on all three, and on `/enhanced/v2/{id}`, which answered a 404 before. Compared with Java on 7 Nov 2026, spaced: its abridged answers for 26 objects of 14 classes (no Person or InstanceEdit) written by node's writer from their own properties, identical in all three views; its errors (404s, the attribute's 404, the 500s for dots and a dbId past a long) the same but for `url`. Node was not run against the graph here (no credentials in this environment): its queries were run against a stand-in in `query-abridged.spec.ts`. - [ ] T105 `GET /data/referenceMolecules`, `/data/referenceMolecules/identifiers`, `/data/referenceSequences`, `/data/referenceSequences/identifiers` - [ ] T106 `GET /data/pathways/top/{species}/curated`, `/data/pathways/low/diagram/identifier/{id}/allForms`, `/data/diagram/{id}/entities/{instance}`, `/data/database/info` - [ ] T107 `GET /data/detail/{id}/direction/…` (four forms), `/data/detail/{id}/getSubGraph`, `/data/detail/{id}/getReverseGraph` diff --git a/tools/content-node/classify.spec.ts b/tools/content-node/classify.spec.ts index f972ba072..3ab64b91e 100644 --- a/tools/content-node/classify.spec.ts +++ b/tools/content-node/classify.spec.ts @@ -29,26 +29,27 @@ describe('classify', () => { it('leaves out writes and what Java still answers', () => { expect(classify('POST', '/ContentService/data/query/ids')).toBe(undefined); - expect(classify('GET', '/ContentService/data/query/abridged/R-HSA-1')).toBe(undefined); - expect(classify('GET', '/ContentService/data/query/R-HSA-1/more')).toBe(undefined); + expect(classify('GET', '/ContentService/data/pathways/top/9606/curated')).toBe(undefined); expect(classify('GET', '/ContentService/data/query/enhanced/v2/R-HSA-1')?.endpoint).toBe( '/ContentService/data/query/enhanced/v2/{id}' ); expect(classify('GET', '/AnalysisService/token/x')).toBe(undefined); }); - it("leaves out every form nginx keeps on Java's /query, not node's own", () => { - for (const path of [ - '/ContentService/data/query/abridged/R-HSA-1', - '/ContentService/data/query/abridged/stId', - '/ContentService/data/query/abridged/R-HSA-1/stId', - '/ContentService/data/query/enhanced/R-HSA-1/stId', - '/ContentService/data/query/R-HSA-1/more/', + it("takes Java's more specific /query paths as their own endpoints, as Express routes them", () => { + for (const [path, endpoint] of [ + ['/ContentService/data/query/abridged/R-HSA-1', '/ContentService/data/query/abridged/{id}'], + ['/ContentService/data/query/abridged/stId', '/ContentService/data/query/abridged/{id}'], + [ + '/ContentService/data/query/abridged/R-HSA-1/stId', + '/ContentService/data/query/abridged/{id}/{attribute}', + ], + ['/ContentService/data/query/R-HSA-1/more/', '/ContentService/data/query/{id}/more'], + ['/ContentService/data/query/enhanced/R-HSA-1', '/ContentService/data/query/enhanced/{id}'], ]) - expect([path, classify('GET', path)]).toEqual([path, undefined]); - expect(classify('GET', '/ContentService/data/query/enhanced/R-HSA-1')?.endpoint).toBe( - '/ContentService/data/query/enhanced/{id}' - ); + expect([path, classify('GET', path)?.endpoint]).toEqual([path, endpoint]); + // No node route matches the rest of Java's /query. + expect(classify('GET', '/ContentService/data/query/enhanced/R-HSA-1/stId')).toBe(undefined); }); it('counts a HEAD as its GET', () => { diff --git a/tools/content-node/classify.ts b/tools/content-node/classify.ts index 54c531807..996a74c2d 100644 --- a/tools/content-node/classify.ts +++ b/tools/content-node/classify.ts @@ -40,12 +40,6 @@ export function classify(method: string, url: string) { const [path = ''] = sent.split('?'); const found = PATTERNS().find(([, pattern]) => pattern.test(path)); if (!found) return undefined; - // Java's own, which nginx keeps from node's /query/{id}/{attribute}, - // /query/{id}/stId and /query/{id}/normalPathway - // (deploy/nginx/common/routes-core.conf): /query/abridged/{id} and - // /query/{id}/more. No node route matches the rest of Java's /query. - if (/^\/ContentService\/data\/query\/(abridged\/[^/]+|[^/]+\/more)\/?$/.test(path)) - return undefined; const form = standardForm(found[0], sent); return { endpoint: found[0], ok: form.ok, rule: form.ok ? 'standard' : form.rule }; } diff --git a/tools/content-node/data-query-endpoints.ts b/tools/content-node/data-query-endpoints.ts index f6f99e5c5..21d6139c3 100644 --- a/tools/content-node/data-query-endpoints.ts +++ b/tools/content-node/data-query-endpoints.ts @@ -5,7 +5,7 @@ * view and an instance's referrers, with the helpers only they use. Moved * unchanged from service.ts. */ -import type { Endpoint } from './endpoint.ts'; +import type { Answer, Endpoint, Request } from './endpoint.ts'; import { asciiDigits, databaseObjectSet, javaNumeric, javaTrim } from './java-text.ts'; import { attributeAnswer, @@ -35,7 +35,7 @@ import { JavaHashMap } from './java-hashmap.ts'; import neo4j from 'neo4j-driver'; import type { Props } from './objects.ts'; import { databaseVersion } from './release.ts'; -import { cached, javaError, notFound, springNotFound } from './answers.ts'; +import { cached, javaError, notFound } from './answers.ts'; import { pathId, queryValues, rawSegment, springPrimitiveBoolean, writerFor } from './spring.ts'; import { orthologyIds, plainText, plainTextProblem } from './body.ts'; import { schemaClassOf } from './schema.ts'; @@ -275,6 +275,82 @@ export async function schemaDataSet( }; } +/** + * graph-core's DatabaseObjectService.findByIdNoRelations: the id as + * getIdentifier reads it (graphCoreId), then the node alone by stable id or + * dbId -- `MATCH (n:DatabaseObject{stId:$stId}) RETURN n` -- mapped with no + * relationship. {object}, or the answer to give instead: Java's 404 naming the + * id as sent, or its 500 for dots alone (findByIdFailure). A dbId past a long + * is the query's failure, answered as Java's (failureAnswer). + */ +async function abridgedObject( + request: Request, + raw: string +): Promise<{ object: DomainEntity } | Answer> { + const refused = /^\.+$/.test(javaTrim(raw)) ? findByIdFailure([raw]) : undefined; + if (refused) return javaError(request, 500, 'INTERNAL_SERVER_ERROR', refused); + const missing = () => notFound(request, `Id: ${raw} has not been found in the System`); + const id = await graphCoreId(raw); + if (!id) return missing(); + const [row] = await read<{ n: GraphNode }>( + `MATCH (n:DatabaseObject${id.object}) RETURN {id: id(n), labels: labels(n), props: properties(n)} AS n`, + id.params + ); + if (!row) return missing(); + const [object] = mapNodes([row.n]); + return object ? { object } : missing(); +} + +/** QueryObjectController.findEnhancedObjectById's three flags. */ +const ENHANCED_FLAGS = [ + 'fetchIncomingRelationships', + 'summariseReferenceEntity', + 'includeDisease', +] as const; +type EnhancedFlags = Record<(typeof ENHANCED_FLAGS)[number], boolean>; + +/** + * QueryObjectController.findEnhancedObjectById, its flags read: graph-core's + * findEnhancedObjectById (enhancedQuery), written by the domain serialiser in + * the request's view; a reference entity, when asked to summarise, as the + * content service's SummaryEntity (summaryEntity). Incoming relationships + * only when asked for and needsIncoming agrees; an object found only with its + * disease data, asked for without, looked up again with it. + * + * The id as getIdentifier reads it (inDepthId), after what it throws on + * (findByIdFailure): dots alone, Java's 500 indexing an empty split. A dbId + * past a long is the query's failure, answered as Java's (failureAnswer). + */ +async function enhancedAnswer(request: Request, raw: string, flags: EnhancedFlags) { + const refused = /^\.+$/.test(javaTrim(raw)) ? findByIdFailure([raw]) : undefined; + if (refused) return javaError(request, 500, 'INTERNAL_SERVER_ERROR', refused); + const id = inDepthId(raw); + const missing = () => notFound(request, `Id: ${raw} has not been found in the System`); + if (!id) return missing(); + const options = { + idType: id.idType, + summarise: flags.summariseReferenceEntity, + includeDisease: flags.includeDisease, + outgoingOnly: !(flags.fetchIncomingRelationships && (await needsIncoming(raw))), + }; + let [row] = await read(enhancedQuery(options), { id: id.id }); + if (!row && !options.includeDisease) + [row] = await read(enhancedQuery({ ...options, includeDisease: true }), { + id: id.id, + }); + if (!row) return missing(); + const found = mapResult(row); + const object = + flags.summariseReferenceEntity && isA(found.schemaClass, 'ReferenceEntity') + ? summaryEntity(found) + : found; + return { + status: 200, + serialised: writeDomain(object, ...writerFor(request)), + type: 'application/json', + }; +} + export const dataQueryEndpoints: Endpoint[] = [ { path: '/ContentService/data/database/version', @@ -332,51 +408,21 @@ export const dataQueryEndpoints: Endpoint[] = [ * * As QueryObjectController: three primitive booleans with defaults * (fetchIncomingRelationships true, summariseReferenceEntity false, - * includeDisease true); incoming relationships only when asked for and - * needsIncoming agrees; an object found only with its disease data, - * asked for without, looked up again with it. + * includeDisease true), then enhancedAnswer. */ handler: async (request) => { const raw = pathId(rawSegment(request, 6)); - const flags: { - fetchIncomingRelationships?: boolean; - summariseReferenceEntity?: boolean; - includeDisease?: boolean; - } = {}; - for (const [name, fallback] of [ - ['fetchIncomingRelationships', true], - ['summariseReferenceEntity', false], - ['includeDisease', true], - ] as const) { - const parsed = springPrimitiveBoolean(queryValues(request, name), fallback); + const flags: EnhancedFlags = { + fetchIncomingRelationships: true, + summariseReferenceEntity: false, + includeDisease: true, + }; + for (const name of ENHANCED_FLAGS) { + const parsed = springPrimitiveBoolean(queryValues(request, name), flags[name]); if (parsed.error !== undefined) return javaError(request, 400, 'BAD_REQUEST', parsed.error); flags[name] = parsed.value; } - const id = inDepthId(raw); - const missing = () => notFound(request, `Id: ${raw} has not been found in the System`); - if (!id) return missing(); - const options = { - idType: id.idType, - summarise: flags.summariseReferenceEntity, - includeDisease: flags.includeDisease, - outgoingOnly: !(flags.fetchIncomingRelationships && (await needsIncoming(raw))), - }; - let [row] = await read(enhancedQuery(options), { id: id.id }); - if (!row && !options.includeDisease) - [row] = await read(enhancedQuery({ ...options, includeDisease: true }), { - id: id.id, - }); - if (!row) return missing(); - const found = mapResult(row); - const object = - flags.summariseReferenceEntity && isA(found.schemaClass, 'ReferenceEntity') - ? summaryEntity(found) - : found; - return { - status: 200, - serialised: writeDomain(object, ...writerFor(request)), - type: 'application/json', - }; + return enhancedAnswer(request, raw, flags); }, }, { @@ -423,6 +469,70 @@ export const dataQueryEndpoints: Endpoint[] = [ }; }, }, + { + // Before /query/{id}/stId, /normalPathway and /more, as Spring ranks its + // literal `abridged` above their {id}: /query/abridged/more is this, with + // id more. + path: '/ContentService/data/query/abridged/{id}/{attribute}', + sample: 'R-HSA-109581', + samplePath: { attribute: 'hasEvent' }, + differs: [/^\/ContentService\/data\/query\/abridged\/[^/]+\/[^/]+: \.url: /], + /** + * One attribute of an object loaded without its relationships, as text: + * QueryObjectController.getAbridgedProperty -- graph-core's + * findByIdNoRelations (abridgedObject), then ControllerUtils.getProperty + * as /query/{id}/{attribute} calls it. Nothing is loaded with the object, + * so a relationship getter loads its relationship, outgoing ones too, as + * graph-core's LazyFetchAspect does (attributeValue, outgoingLoaded + * false): `hasEvent` is the pathway's events. + */ + handler: async (request) => { + const raw = pathId(rawSegment(request, 5)); + const found = await abridgedObject(request, raw); + if ('status' in found) return found; + return attributeAnswer(request, found.object, pathId(rawSegment(request, 6)), { + outgoingLoaded: false, + }); + }, + }, + { + path: '/ContentService/data/query/abridged/{id}', + sample: 'R-HSA-109581', + differs: [/^\/ContentService\/data\/query\/abridged\/[^/]+: \.url: /], + /** + * An object's own properties, none of its relationships: + * QueryObjectController.findByIdNoRelations over graph-core's + * findByIdNoRelations (abridgedObject), written by the domain serialiser + * as /query/{id} writes an object. Java leaves it out of its published + * description; the site does not call it. + */ + handler: async (request) => { + const found = await abridgedObject(request, pathId(rawSegment(request, 5))); + if ('status' in found) return found; + return { + status: 200, + serialised: writeDomain(found.object, ...writerFor(request)), + type: 'application/json', + }; + }, + }, + { + path: '/ContentService/data/query/{id}/more', + sample: 'R-HSA-70486', + differs: [/^\/ContentService\/data\/query\/[^/]+\/more: \.url: /], + /** + * QueryObjectController.findEnhancedObjectByIdMore, kept by Java for old + * callers and left out of its description: /query/enhanced/v2/{id} with + * its flags fixed (incoming relationships, no summary, no disease data + * unless only that finds the object) and no parameter read. + */ + handler: async (request) => + enhancedAnswer(request, pathId(rawSegment(request, 4)), { + fetchIncomingRelationships: true, + summariseReferenceEntity: false, + includeDisease: false, + }), + }, { path: '/ContentService/data/query/{id}/stId', // A reaction by dbId, so the comparison covers the lookup the site makes. @@ -668,16 +778,13 @@ export const dataQueryEndpoints: Endpoint[] = [ * not bring, as graph-core's LazyFetchAspect does (lazyRelationship). * * No object is Java's 404 naming the id; no getter, or one returning - * nothing, its 404 naming the attribute and the object's class. Not + * nothing, its 404 naming the attribute and the object's class. Never * /query/abridged/{id}, /query/enhanced/{id} or /query/{id}/more, Java's - * more specific mappings, which are no attribute: nginx and the dev proxy - * never send those here. + * more specific mappings, which come first. */ handler: async (request) => { const raw = pathId(rawSegment(request, 4)); const attribute = pathId(rawSegment(request, 5)); - if (raw === 'abridged' || raw === 'enhanced' || attribute === 'more') - return springNotFound(request); const object = await findObject(raw); if (!object) return notFound(request, `Id: ${raw} has not been found in the System`); return attributeAnswer(request, object, attribute); diff --git a/tools/content-node/fixtures/route-order.json b/tools/content-node/fixtures/route-order.json index edc6e5730..892da18c8 100644 --- a/tools/content-node/fixtures/route-order.json +++ b/tools/content-node/fixtures/route-order.json @@ -3,6 +3,9 @@ "GET /ContentService/data/database/name", "GET /ContentService/data/query/enhanced/v2/{id}", "GET /ContentService/data/query/enhanced/{id}", + "GET /ContentService/data/query/abridged/{id}/{attribute}", + "GET /ContentService/data/query/abridged/{id}", + "GET /ContentService/data/query/{id}/more", "GET /ContentService/data/query/{id}/stId", "GET /ContentService/data/query/{id}", "POST /ContentService/data/query/ids", diff --git a/tools/content-node/objects.ts b/tools/content-node/objects.ts index 7960db909..593e917be 100644 --- a/tools/content-node/objects.ts +++ b/tools/content-node/objects.ts @@ -172,10 +172,15 @@ export function lazyCollectionQuery(type: string, direction: string, schemaClass * ControllerUtils.getProperty on a loaded object, as an answer: the getter * found by name, its value, and toTSV's text of it (domain.ts); Java's 404 * naming the attribute and the object's class when there is no getter or it - * returns nothing. + * returns nothing. `outgoingLoaded` as attributeValue takes it. */ -export async function attributeAnswer(request: Request, object: DomainEntity, attribute: string) { - const answer = await attributeOf(request, object, attribute); +export async function attributeAnswer( + request: Request, + object: DomainEntity, + attribute: string, + { outgoingLoaded = true } = {} +) { + const answer = await attributeOf(request, object, attribute, { outgoingLoaded }); if ('status' in answer) return answer; return { status: 200, body: answer.text, type: 'text/plain;charset=UTF-8' }; } diff --git a/tools/content-node/parameters.ts b/tools/content-node/parameters.ts index ee7ee1aae..c2a34d907 100644 --- a/tools/content-node/parameters.ts +++ b/tools/content-node/parameters.ts @@ -189,6 +189,24 @@ export const RECORD: Record = { java: '/data/query/enhanced/{id}', path: { id: 'oid' }, }, + '/ContentService/data/query/abridged/{id}/{attribute}': { + java: null, + why: 'Java answers it but leaves it out of its description: /data/query/{id}/{attributeName} on the object without its relationships.', + path: { id: 'oid', attribute: 'attribute' }, + query: [], + }, + '/ContentService/data/query/abridged/{id}': { + java: null, + why: 'Java answers it but leaves it out of its description: the object without its relationships.', + path: { id: 'oid' }, + query: [], + }, + '/ContentService/data/query/{id}/more': { + java: null, + why: 'Java answers it but leaves it out of its description: /data/query/enhanced/v2/{id} with its flags fixed.', + path: { id: 'oid' }, + query: [], + }, '/ContentService/data/query/{id}/stId': { java: '/data/query/{id}/{attributeName}', attributeOf: true, diff --git a/tools/content-node/query-abridged.spec.ts b/tools/content-node/query-abridged.spec.ts new file mode 100644 index 000000000..943f7054d --- /dev/null +++ b/tools/content-node/query-abridged.spec.ts @@ -0,0 +1,246 @@ +// @vitest-environment node +// +// /data/query/abridged/{id}, /data/query/abridged/{id}/{attributeName} and +// /data/query/{id}/more (spec 006 T104) through the service, with a stand-in +// graph. The objects' properties are those Java wrote for them, with a +// property the node holds and Java does not write; the expected bodies are +// Java's answers, measured 7 Nov 2026. +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { GraphNode } from './graph.ts'; + +/** The nodes by stable id and dbId, as the abridged query finds them. */ +const nodes: GraphNode[] = [ + { + id: 1, + labels: ['DatabaseObject', 'Event', 'Pathway'], + props: { + dbId: 109581, + displayName: 'Apoptosis', + stId: 'R-HSA-109581', + stIdVersion: 'R-HSA-109581.6', + oldStId: 'REACT_578', + isInDisease: false, + isInferred: false, + maxDepth: 7, + name: ['Apoptosis'], + releaseDate: '2004-09-20', + speciesName: 'Homo sapiens', + hasDiagram: true, + hasEHLD: true, + lastUpdatedDate: '2022-06-09', + schemaClass: 'Pathway', + }, + }, + { + id: 2, + labels: ['DatabaseObject', 'Event', 'ReactionLikeEvent', 'Reaction'], + props: { + dbId: 70486, + displayName: 'PGK complexes (PGK1,2) phosphorylate 3PG to form 1,3BPG', + stId: 'R-HSA-70486', + stIdVersion: 'R-HSA-70486.7', + oldStId: 'REACT_1186', + isInDisease: false, + isInferred: false, + maxDepth: 1, + name: [ + 'PGK complexes (PGK1,2) phosphorylate 3PG to form 1,3BPG', + '(2R)-3-phosphoglycerate + ATP => (2R)-3-phospho-glyceroyl phosphate + ADP', + ], + releaseDate: '2004-07-06', + speciesName: 'Homo sapiens', + category: 'transition', + schemaClass: 'Reaction', + }, + }, + { + id: 3, + labels: ['DatabaseObject', 'Taxon', 'Species'], + props: { + dbId: 48887, + displayName: 'Homo sapiens', + name: ['Homo sapiens', 'H. sapiens', 'Hs', 'human', 'man'], + taxId: '9606', + abbreviation: 'HSA', + schemaClass: 'Species', + }, + }, +]; + +/** Java's answers for the three, as written. */ +const JAVA = { + 109581: + '{"dbId":109581,"displayName":"Apoptosis","stId":"R-HSA-109581","stIdVersion":"R-HSA-109581.6","isInDisease":false,"isInferred":false,"maxDepth":7,"name":["Apoptosis"],"releaseDate":"2004-09-20","speciesName":"Homo sapiens","hasDiagram":true,"hasEHLD":true,"lastUpdatedDate":"2022-06-09","schemaClass":"Pathway","className":"Pathway"}', + 70486: + '{"dbId":70486,"displayName":"PGK complexes (PGK1,2) phosphorylate 3PG to form 1,3BPG","stId":"R-HSA-70486","stIdVersion":"R-HSA-70486.7","isInDisease":false,"isInferred":false,"maxDepth":1,"name":["PGK complexes (PGK1,2) phosphorylate 3PG to form 1,3BPG","(2R)-3-phosphoglycerate + ATP => (2R)-3-phospho-glyceroyl phosphate + ADP"],"releaseDate":"2004-07-06","speciesName":"Homo sapiens","category":"transition","className":"Reaction","schemaClass":"Reaction"}', + 48887: + '{"dbId":48887,"displayName":"Homo sapiens","name":["Homo sapiens","H. sapiens","Hs","human","man"],"taxId":"9606","abbreviation":"HSA","className":"Species","schemaClass":"Species"}', +}; + +/** The pathway's events, as a lazy load of `hasEvent` finds them. */ +const events = [5357769, 109606, 75153, 169911].map((dbId, i) => ({ + m: { + id: 10 + i, + labels: ['DatabaseObject', 'Event', 'Pathway'], + props: { dbId, stId: `R-HSA-${dbId}`, displayName: `Event ${i}`, schemaClass: 'Pathway' }, + }, + n: null, +})); + +const asked: string[] = []; +vi.mock('./graph.ts', () => ({ + read: async (cypher: string, params: { id?: unknown; dbId?: unknown }) => { + asked.push(cypher); + // findNewStId: the old id's stable id. + if (cypher.includes('n.oldStId = $id')) + return nodes + .filter((node) => node.props['oldStId'] === params.id) + .map((node) => ({ stId: node.props['stId'] })); + // findByIdNoRelations: the node alone. + const alone = + /^MATCH \(n:DatabaseObject\{(stId|dbId): (\$id|toInteger\(\$id\))\}\) RETURN/.exec(cypher); + if (alone) + return nodes + .filter((node) => String(node.props[alone[1] ?? '']) === String(params.id)) + .map((node) => ({ n: node })); + if (cypher.includes('[r:hasEvent]->')) return Number(params.dbId) === 109581 ? events : []; + // The enhanced query: nothing, so each lookup is seen. + return []; + }, + configured: () => true, + close: async () => {}, + envSetting: () => '', +})); + +const { app } = await import('./server.ts'); + +let base: string; +let server: Server; +beforeAll(async () => { + await new Promise((resolve) => { + server = app().listen(0, '127.0.0.1', () => resolve()); + }); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); +afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + +const get = (path: string) => fetch(`${base}/ContentService/data/query/${path}`); +/** Status and the messages of Java's error envelope. */ +const messages = async (response: Response) => [ + response.status, + ((await response.json()) as { messages: string[] }).messages, +]; + +describe('/query/abridged/{id}', () => { + it("is the object's own properties, written as Java writes them", async () => { + for (const [path, dbId] of [ + ['abridged/R-HSA-109581', 109581], + ['abridged/R-HSA-70486', 70486], + ['abridged/48887', 48887], + // A version cut, an old id swapped for its stable id, a padded id. + ['abridged/R-HSA-109581.6', 109581], + ['abridged/REACT_578', 109581], + ['abridged/%20R-HSA-109581', 109581], + ['abridged/R-HSA-109581/', 109581], + ] as const) { + const response = await get(path); + expect([path, response.status, await response.text()]).toEqual([path, 200, JAVA[dbId]]); + } + }); + + it('loads the node alone, not its relationships', async () => { + asked.length = 0; + await get('abridged/R-HSA-109581'); + expect(asked).toEqual([ + 'MATCH (n:DatabaseObject{stId: $id}) RETURN {id: id(n), labels: labels(n), props: properties(n)} AS n', + ]); + }); + + it("is Java's 404 naming the id as sent, its 500 for dots alone", async () => { + expect(await messages(await get('abridged/R-HSA-0'))).toEqual([ + 404, + ['Id: R-HSA-0 has not been found in the System'], + ]); + expect(await messages(await get('abridged/xyz'))).toEqual([ + 404, + ['Id: xyz has not been found in the System'], + ]); + // Java's /query/abridged/{id} takes this path, not /query/{id}/more. + expect(await messages(await get('abridged/more'))).toEqual([ + 404, + ['Id: more has not been found in the System'], + ]); + expect(await messages(await get('abridged/...'))).toEqual([ + 500, + ['Index 0 out of bounds for length 0'], + ]); + }); +}); + +describe('/query/abridged/{id}/{attributeName}', () => { + it('loads a relationship the object came without, outgoing too', async () => { + for (const attribute of ['hasEvent', 'hasevent']) { + const response = await get(`abridged/R-HSA-109581/${attribute}`); + expect([response.status, await response.text()]).toEqual([ + 200, + 'R-HSA-5357769\tEvent 0\tPathway\nR-HSA-109606\tEvent 1\tPathway\nR-HSA-75153\tEvent 2\tPathway\nR-HSA-169911\tEvent 3\tPathway\n', + ]); + } + }); + + it('reads a property, and the stable id, as /query/{id}/{attribute} does', async () => { + const name = await get('abridged/R-HSA-109581/displayName'); + expect([name.status, await name.text()]).toEqual([200, 'Apoptosis']); + const stId = await get('abridged/109581/stid'); + expect([stId.status, await stId.text()]).toEqual([200, 'R-HSA-109581']); + }); + + it("is Java's 404 naming the attribute, or the id", async () => { + expect(await messages(await get('abridged/R-HSA-109581/foo'))).toEqual([ + 404, + ["Attribute: 'foo' has not been found for object: Pathway"], + ]); + expect(await messages(await get('abridged/R-HSA-109581/more'))).toEqual([ + 404, + ["Attribute: 'more' has not been found for object: Pathway"], + ]); + expect(await messages(await get('abridged/xyz/stId'))).toEqual([ + 404, + ['Id: xyz has not been found in the System'], + ]); + }); +}); + +describe('/query/{id}/more', () => { + it('is /query/enhanced/v2/{id} without disease data, then with it', async () => { + asked.length = 0; + expect(await messages(await get('R-HSA-70486/more'))).toEqual([ + 404, + ['Id: R-HSA-70486 has not been found in the System'], + ]); + const lookups = asked.filter((cypher) => cypher.includes('NOT m:UpdateTracker')); + expect(lookups.map((cypher) => cypher.includes('isInDisease = false'))).toEqual([true, false]); + }); + + it('reads no parameter: a value v2 would refuse is no 400', async () => { + expect((await get('R-HSA-70486/more?includeDisease=x')).status).toBe(404); + expect((await get('enhanced/v2/R-HSA-70486?includeDisease=x')).status).toBe(400); + }); + + it("is Java's 500 for dots alone, as v2 is", async () => { + for (const path of ['.../more', 'enhanced/v2/...']) + expect(await messages(await get(path))).toEqual([ + 500, + ['Index 0 out of bounds for length 0'], + ]); + }); + + it('is an attribute called MORE in another case', async () => { + // Spring's literal `more` is matched by case; the attribute route takes it. + asked.length = 0; + await get('R-HSA-70486/MORE'); + expect(asked.some((cypher) => cypher.includes('NOT m:UpdateTracker'))).toBe(false); + }); +}); diff --git a/tools/content-node/query-attribute-endpoint.spec.ts b/tools/content-node/query-attribute-endpoint.spec.ts index f0c9fe0d2..92397ccfc 100644 --- a/tools/content-node/query-attribute-endpoint.spec.ts +++ b/tools/content-node/query-attribute-endpoint.spec.ts @@ -167,16 +167,6 @@ describe('stId and normalPathway in any case', () => { }); describe("Java's other answers", () => { - it("leaves Java's own /query/abridged/{id}, /query/enhanced/{id} and /query/{id}/more alone", async () => { - root = pathway; - for (const path of ['query/abridged/R-HSA-9', 'query/R-HSA-9/more']) { - const response = await get(path); - expect([path, response.status, ((await response.json()) as { error: string }).error]).toEqual( - [path, 404, 'Not Found'] - ); - } - }); - it("is Java's 500 for a getter that fails, and its 404s for no object or no attribute", async () => { root = { id: 3, diff --git a/tools/content-node/registry.spec.ts b/tools/content-node/registry.spec.ts index 9c4f51f9d..81d79b805 100644 --- a/tools/content-node/registry.spec.ts +++ b/tools/content-node/registry.spec.ts @@ -50,7 +50,7 @@ function overlapping(list: string[]): string[] { return pairs; } -/** The 15 pairs whose order decides which answers, measured (research R3), each the first first. */ +/** The 21 pairs whose order decides which answers, measured (research R3), each the first first. */ const PAIRS = [ '/data/person/{id}/publications before /data/person/{id}/{attribute}', '/data/person/{id}/authoredPathways before /data/person/{id}/{attribute}', @@ -59,6 +59,14 @@ const PAIRS = [ '/data/person/{id}/reviewedReactions before /data/person/{id}/{attribute}', '/data/query/enhanced/{id} before /data/query/{id}/stId', '/data/query/enhanced/{id} before /data/query/{id}/normalPathway', + // Java answers /query/abridged/more and /query/enhanced/more as the + // object called more (measured), not as /query/{id}/more. + '/data/query/enhanced/{id} before /data/query/{id}/more', + '/data/query/abridged/{id} before /data/query/{id}/more', + '/data/query/abridged/{id} before /data/query/{id}/stId', + '/data/query/abridged/{id} before /data/query/{id}/normalPathway', + '/data/query/abridged/{id} before /data/query/{id}/{attribute}', + '/data/query/{id}/more before /data/query/{id}/{attribute}', '/data/query/enhanced/{id} before /data/query/{id}/{attribute}', '/data/query/{id}/stId before /data/query/{id}/{attribute}', '/data/query/ids/map before /data/query/{id}/{attribute}', From db98a9f34aa307fd02ace6c10766092fc32a3040 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 7 Nov 2026 23:21:04 +0000 Subject: [PATCH 2/4] Spec 006 T104: the classify cases typed; /query/{id}/more unrecorded, as nothing calls it Co-Authored-By: Claude Opus 5.5 --- tools/api-types/schema.spec.ts | 2 ++ tools/content-node/classify.spec.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/api-types/schema.spec.ts b/tools/api-types/schema.spec.ts index e7c3e9dd6..b399795dd 100644 --- a/tools/api-types/schema.spec.ts +++ b/tools/api-types/schema.spec.ts @@ -187,6 +187,8 @@ describe('recorded answers against the API description', () => { 'not in the recordings, which never cite a pathway (calls.ts names the gap)', 'GET /ContentService/data/instance/{id}/referrers': 'not in the recordings, which never open an instance’s referrers (calls.ts names the gap)', + 'GET /ContentService/data/query/{id}/more': + 'no call of the site reads it: Java keeps it for old callers (spec 006 T104)', 'GET /ContentService/data/schema/{id}/min': 'not in the recordings, which never open a class’s instances (calls.ts names the gap)', 'GET /ContentService/interactors/static/molecule/{id}/details': diff --git a/tools/content-node/classify.spec.ts b/tools/content-node/classify.spec.ts index 3ab64b91e..a75c68f7e 100644 --- a/tools/content-node/classify.spec.ts +++ b/tools/content-node/classify.spec.ts @@ -37,7 +37,7 @@ describe('classify', () => { }); it("takes Java's more specific /query paths as their own endpoints, as Express routes them", () => { - for (const [path, endpoint] of [ + const cases: [path: string, endpoint: string][] = [ ['/ContentService/data/query/abridged/R-HSA-1', '/ContentService/data/query/abridged/{id}'], ['/ContentService/data/query/abridged/stId', '/ContentService/data/query/abridged/{id}'], [ @@ -46,7 +46,8 @@ describe('classify', () => { ], ['/ContentService/data/query/R-HSA-1/more/', '/ContentService/data/query/{id}/more'], ['/ContentService/data/query/enhanced/R-HSA-1', '/ContentService/data/query/enhanced/{id}'], - ]) + ]; + for (const [path, endpoint] of cases) expect([path, classify('GET', path)?.endpoint]).toEqual([path, endpoint]); // No node route matches the rest of Java's /query. expect(classify('GET', '/ContentService/data/query/enhanced/R-HSA-1/stId')).toBe(undefined); From ff253f5b59b2376aefa01077868a7c363cc8d38f Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 7 Nov 2026 23:35:59 +0000 Subject: [PATCH 3/4] Spec 006 T104: the pull request is #897 Co-Authored-By: Claude Opus 5.5 --- specs/006-content-service-to-node/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/006-content-service-to-node/tasks.md b/specs/006-content-service-to-node/tasks.md index 70de3b7f3..485488430 100644 --- a/specs/006-content-service-to-node/tasks.md +++ b/specs/006-content-service-to-node/tasks.md @@ -112,7 +112,7 @@ These were held in Java because their substance is a Java library or Java numeri Found 19 Oct 2026 by comparing every mapping in the Java source with the published description (which the phases above follow). The site calls none of them; outside users of the API may. Each still answered by Java. -- [x] T104 Graph reads: `GET /data/query/abridged/{id}`, `/data/query/abridged/{id}/{attributeName}`, `/data/query/{id}/more`. Node answers all three (data-query family), routed in nginx and the dev proxy. The abridged object is graph-core's findByIdNoRelations: the node alone, by the id as getIdentifier reads it, written as `/query/{id}` writes an object; its attribute is ControllerUtils.getProperty on it, a relationship getter loading the relationship, outgoing ones too, as LazyFetchAspect does. `/more` is `/query/enhanced/v2/{id}` with its flags fixed (incoming relationships, no summary, disease data only if nothing is found without), reading no parameter. Spring's order kept: `/query/abridged/more` and `/query/enhanced/more` are the object called more. Dots alone are Java's 500 on all three, and on `/enhanced/v2/{id}`, which answered a 404 before. Compared with Java on 7 Nov 2026, spaced: its abridged answers for 26 objects of 14 classes (no Person or InstanceEdit) written by node's writer from their own properties, identical in all three views; its errors (404s, the attribute's 404, the 500s for dots and a dbId past a long) the same but for `url`. Node was not run against the graph here (no credentials in this environment): its queries were run against a stand-in in `query-abridged.spec.ts`. +- [x] T104 Graph reads: `GET /data/query/abridged/{id}`, `/data/query/abridged/{id}/{attributeName}`, `/data/query/{id}/more`. Node answers all three (data-query family), routed in nginx and the dev proxy. The abridged object is graph-core's findByIdNoRelations: the node alone, by the id as getIdentifier reads it, written as `/query/{id}` writes an object; its attribute is ControllerUtils.getProperty on it, a relationship getter loading the relationship, outgoing ones too, as LazyFetchAspect does. `/more` is `/query/enhanced/v2/{id}` with its flags fixed (incoming relationships, no summary, disease data only if nothing is found without), reading no parameter. Spring's order kept: `/query/abridged/more` and `/query/enhanced/more` are the object called more. Dots alone are Java's 500 on all three, and on `/enhanced/v2/{id}`, which answered a 404 before. Compared with Java on 7 Nov 2026, spaced: its abridged answers for 26 objects of 14 classes (no Person or InstanceEdit) written by node's writer from their own properties, identical in all three views; its errors (404s, the attribute's 404, the 500s for dots and a dbId past a long) the same but for `url`. Node was not run against the graph here (no credentials in this environment): its queries were run against a stand-in in `query-abridged.spec.ts`. — #897 - [ ] T105 `GET /data/referenceMolecules`, `/data/referenceMolecules/identifiers`, `/data/referenceSequences`, `/data/referenceSequences/identifiers` - [ ] T106 `GET /data/pathways/top/{species}/curated`, `/data/pathways/low/diagram/identifier/{id}/allForms`, `/data/diagram/{id}/entities/{instance}`, `/data/database/info` - [ ] T107 `GET /data/detail/{id}/direction/…` (four forms), `/data/detail/{id}/getSubGraph`, `/data/detail/{id}/getReverseGraph` From bca0693cb7dc7b1afe7ee3e58e17cf004f349058 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Wed, 11 Nov 2026 18:27:42 +0000 Subject: [PATCH 4/4] Spec 006 T104: review points; /query/enhanced/{id} is Java's 500 for dots /query/enhanced/{id} answered 404 for an id of dots alone, where Java answers 500 ("Index 0 out of bounds for length 0"), as v2 now does here; measured live today and checked against Java after the change. The /more test now compares what the graph is asked with v2's own under /more's flags (seen failing with incoming relationships off), and the MORE test checks the attribute route answers as for any other attribute name. The ranking comment moves to /query/abridged/{id}, which it describes. Co-Authored-By: Claude Opus 5.5 --- tools/content-node/data-query-endpoints.ts | 10 ++++++--- tools/content-node/query-abridged.spec.ts | 26 ++++++++++++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/tools/content-node/data-query-endpoints.ts b/tools/content-node/data-query-endpoints.ts index 21d6139c3..b144e2fdb 100644 --- a/tools/content-node/data-query-endpoints.ts +++ b/tools/content-node/data-query-endpoints.ts @@ -454,6 +454,9 @@ export const dataQueryEndpoints: Endpoint[] = [ */ handler: async (request) => { const raw = pathId(rawSegment(request, 5)); + // Dots alone, as enhancedAnswer: Java's 500, measured 11 Nov 2026. + const refused = /^\.+$/.test(javaTrim(raw)) ? findByIdFailure([raw]) : undefined; + if (refused) return javaError(request, 500, 'INTERNAL_SERVER_ERROR', refused); const id = inDepthId(raw); const missing = () => notFound(request, `Id: ${raw} has not been found in the System`); if (!id) return missing(); @@ -470,9 +473,7 @@ export const dataQueryEndpoints: Endpoint[] = [ }, }, { - // Before /query/{id}/stId, /normalPathway and /more, as Spring ranks its - // literal `abridged` above their {id}: /query/abridged/more is this, with - // id more. + // Three segments after /query, so it shares no path with another route. path: '/ContentService/data/query/abridged/{id}/{attribute}', sample: 'R-HSA-109581', samplePath: { attribute: 'hasEvent' }, @@ -496,6 +497,9 @@ export const dataQueryEndpoints: Endpoint[] = [ }, }, { + // Before /query/{id}/stId, /normalPathway and /more, as Spring ranks its + // literal `abridged` above their {id}: /query/abridged/more is this, with + // id more. path: '/ContentService/data/query/abridged/{id}', sample: 'R-HSA-109581', differs: [/^\/ContentService\/data\/query\/abridged\/[^/]+: \.url: /], diff --git a/tools/content-node/query-abridged.spec.ts b/tools/content-node/query-abridged.spec.ts index 943f7054d..67ea37164 100644 --- a/tools/content-node/query-abridged.spec.ts +++ b/tools/content-node/query-abridged.spec.ts @@ -224,13 +224,25 @@ describe('/query/{id}/more', () => { expect(lookups.map((cypher) => cypher.includes('isInDisease = false'))).toEqual([true, false]); }); + it("asks the graph what v2 asks with /more's flags", async () => { + asked.length = 0; + await get('R-HSA-70486/more'); + const more = [...asked]; + asked.length = 0; + await get( + 'enhanced/v2/R-HSA-70486?fetchIncomingRelationships=true&summariseReferenceEntity=false&includeDisease=false' + ); + expect(more).toEqual(asked); + expect(more.length).toBeGreaterThan(0); + }); + it('reads no parameter: a value v2 would refuse is no 400', async () => { expect((await get('R-HSA-70486/more?includeDisease=x')).status).toBe(404); expect((await get('enhanced/v2/R-HSA-70486?includeDisease=x')).status).toBe(400); }); it("is Java's 500 for dots alone, as v2 is", async () => { - for (const path of ['.../more', 'enhanced/v2/...']) + for (const path of ['.../more', 'enhanced/v2/...', 'enhanced/...']) expect(await messages(await get(path))).toEqual([ 500, ['Index 0 out of bounds for length 0'], @@ -238,9 +250,15 @@ describe('/query/{id}/more', () => { }); it('is an attribute called MORE in another case', async () => { - // Spring's literal `more` is matched by case; the attribute route takes it. + // Spring's literal `more` is matched by case; the attribute route takes it, + // asking and answering just as it does for any other attribute name. + asked.length = 0; + const more = await messages(await get('R-HSA-70486/MORE')); + const moreAsked = [...asked]; asked.length = 0; - await get('R-HSA-70486/MORE'); - expect(asked.some((cypher) => cypher.includes('NOT m:UpdateTracker'))).toBe(false); + const other = await messages(await get('R-HSA-70486/foo')); + expect([more, moreAsked]).toEqual([other, [...asked]]); + expect(moreAsked.length).toBeGreaterThan(0); + expect(moreAsked.some((cypher) => cypher.includes('NOT m:UpdateTracker'))).toBe(false); }); });