diff --git a/deploy/nginx/common/routes-core.conf b/deploy/nginx/common/routes-core.conf index b73e1a1a..6f94b6aa 100644 --- a/deploy/nginx/common/routes-core.conf +++ b/deploy/nginx/common/routes-core.conf @@ -318,8 +318,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 @@ -348,10 +348,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 1aeaa5a4..f5925329 100644 --- a/deploy/nginx/routes.spec.ts +++ b/deploy/nginx/routes.spec.ts @@ -377,8 +377,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). @@ -452,7 +452,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 adf9c061..30420d39 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 8cb92254..de6ca074 100644 --- a/projects/contract/routes.ts +++ b/projects/contract/routes.ts @@ -49,6 +49,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 31169f81..02b9da11 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 f4b9420d..cdd4d077 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 f425d78a..7aa815d7 100644 --- a/proxy.conf.js +++ b/proxy.conf.js @@ -156,18 +156,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 24aea8a5..48548843 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`. — #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` diff --git a/tools/api-types/schema.spec.ts b/tools/api-types/schema.spec.ts index e7c3e9dd..b399795d 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 f972ba07..a75c68f7 100644 --- a/tools/content-node/classify.spec.ts +++ b/tools/content-node/classify.spec.ts @@ -29,26 +29,28 @@ 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/', - ]) - 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}' - ); + it("takes Java's more specific /query paths as their own endpoints, as Express routes them", () => { + 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}'], + [ + '/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}'], + ]; + 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); }); it('counts a HEAD as its GET', () => { diff --git a/tools/content-node/classify.ts b/tools/content-node/classify.ts index 54c53180..996a74c2 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 f6f99e5c..b144e2fd 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); }, }, { @@ -408,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(); @@ -423,6 +472,71 @@ export const dataQueryEndpoints: Endpoint[] = [ }; }, }, + { + // 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' }, + 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, + }); + }, + }, + { + // 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: /], + /** + * 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 +782,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 54f531b5..a0642e38 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 7960db90..593e917b 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 32382018..84555f55 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 00000000..67ea3716 --- /dev/null +++ b/tools/content-node/query-abridged.spec.ts @@ -0,0 +1,264 @@ +// @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("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/...', 'enhanced/...']) + 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, + // 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; + 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); + }); +}); diff --git a/tools/content-node/query-attribute-endpoint.spec.ts b/tools/content-node/query-attribute-endpoint.spec.ts index f0c9fe0d..92397ccf 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 9c4f51f9..81d79b80 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}',