From bcb885b005975e9d9e361c5eed5108994640e01e Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 17 Oct 2026 19:17:14 +0000 Subject: [PATCH 1/3] Port /data/entity/{id}/componentOf to content-node graph-core's ComponentOfLinkageRepository query as written, groups and members in Neo4j's order (Java never sorts them), the id read by graphCoreId. Byte-identical to Java over 21 requests. Routed in nginx and the dev proxy beside /otherForms, whose comment no longer says in-depth stays with Java. Spec 006 T076. Co-Authored-By: Claude Opus 5.5 --- deploy/nginx/common/routes-core.conf | 12 ++- .../src/scripts/proxy-order.spec.ts | 16 +++- proxy.conf.js | 3 +- .../contracts/operation-homes.md | 2 +- tools/content-node/component-of.spec.mjs | 94 +++++++++++++++++++ tools/content-node/service.mjs | 48 ++++++++++ 6 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 tools/content-node/component-of.spec.mjs diff --git a/deploy/nginx/common/routes-core.conf b/deploy/nginx/common/routes-core.conf index c4fbb2ff..09b1e877 100644 --- a/deploy/nginx/common/routes-core.conf +++ b/deploy/nginx/common/routes-core.conf @@ -381,11 +381,13 @@ location ~ ^/ContentService/data/participants/[^/]+/?$ { } # The other forms of a physical entity -- the same reference entity, modified, -# elsewhere or in another species. Java's query kept as written; nothing found -# is Java's 404. Byte-identical to Java over 25 ids covering every class that -# appears (EWAS by reference type, simple entities, drugs), bar a 404 body's -# url, as above. /entity/{id}/in-depth and the rest stay with Java. -location ~ ^/ContentService/data/entity/[^/]+/otherForms/?$ { +# elsewhere or in another species -- and what an entity or event is part of +# (/componentOf). Java's queries kept as written; nothing found is Java's 404. +# otherForms byte-identical to Java over 25 ids covering every class that +# appears (EWAS by reference type, simple entities, drugs); componentOf over +# 21, ATP's megabyte, every id form, events and every class of entity among +# them; both bar a 404 body's url, as above. /entity/{id}/in-depth is above. +location ~ ^/ContentService/data/entity/[^/]+/(otherForms|componentOf)/?$ { include /etc/nginx/common/content-node-proxy.conf; } diff --git a/projects/website-angular/src/scripts/proxy-order.spec.ts b/projects/website-angular/src/scripts/proxy-order.spec.ts index 046fa672..db14455b 100644 --- a/projects/website-angular/src/scripts/proxy-order.spec.ts +++ b/projects/website-angular/src/scripts/proxy-order.spec.ts @@ -142,9 +142,15 @@ describe('the dev server proxy', () => { }); } - it('sends the other forms of an entity to the content service', async () => { - await expectContentNode('/ContentService/data/entity/R-HSA-199420/otherForms'); - }); + for (const url of [ + '/ContentService/data/entity/R-HSA-199420/otherForms', + '/ContentService/data/entity/R-HSA-199420/componentOf', + '/ContentService/data/entity/199420/componentOf/', + ]) { + it(`sends ${url} to the content service`, async () => { + await expectContentNode(url); + }); + } for (const url of [ '/ContentService/data/database/version', @@ -321,8 +327,8 @@ describe('the dev server proxy', () => { '/ContentService/data/complexes/UniProt/P00533/x', // Only the participants and their reference entities are ported. '/ContentService/data/participants/R-HSA-5672965/participatingPhysicalEntities', - // Only the other forms and in-depth of an entity are ported. - '/ContentService/data/entity/R-HSA-199420/componentOf', + // Only these of an entity are ported: not a path below them. + '/ContentService/data/entity/R-HSA-199420/componentOf/x', // Only the locations are ported, not their neighbours under /detail. '/ContentService/data/detail/R-HSA-199420/getReverseGraph', '/ContentService/data/detail/R-HSA-109581/getSubGraph', diff --git a/proxy.conf.js b/proxy.conf.js index 514e1801..236b2328 100644 --- a/proxy.conf.js +++ b/proxy.conf.js @@ -186,7 +186,8 @@ module.exports = { '^/ContentService/data/database/(version|name)/?(\\?|$)': contentNode, '^/ContentService/data/pathways/top/[^/?]+/?(\\?|$)': contentNode, // The other forms of a physical entity: the details panel and the popup. - '^/ContentService/data/entity/[^/?]+/otherForms/?(\\?|$)': contentNode, + // What an entity or event is part of: the details page's table. + '^/ContentService/data/entity/[^/?]+/(otherForms|componentOf)/?(\\?|$)': contentNode, // Every place an object appears in the Pathway Browser: the details page's // locations tree. Only this one of Java's /data/detail/{id}/... paths. '^/ContentService/data/detail/[^/?]+/locationsInPWB/?(\\?|$)': contentNode, diff --git a/specs/010-java-services/contracts/operation-homes.md b/specs/010-java-services/contracts/operation-homes.md index a3dcd754..7203d2b5 100644 --- a/specs/010-java-services/contracts/operation-homes.md +++ b/specs/010-java-services/contracts/operation-homes.md @@ -28,7 +28,7 @@ The decision record of FR-001 and FR-002. It covers every operation in the two s | GET | `/data/discover/{identifier}` | node — to port | graph or search index | | GET | `/data/diseases` | node — to port | graph or search index | | GET | `/data/diseases/doid` | node — to port | graph or search index | -| GET | `/data/entity/{id}/componentOf` | node — to port | graph or search index | +| GET | `/data/entity/{id}/componentOf` | node — ported | graph or search index | | GET | `/data/entity/{id}/in-depth` | node — ported | graph or search index | | GET | `/data/entity/{id}/otherForms` | node — ported | graph or search index | | GET | `/data/event/{id}/ancestors` | node — ported | graph or search index | diff --git a/tools/content-node/component-of.spec.mjs b/tools/content-node/component-of.spec.mjs new file mode 100644 index 00000000..15a5b924 --- /dev/null +++ b/tools/content-node/component-of.spec.mjs @@ -0,0 +1,94 @@ +// @vitest-environment node +// +// /data/entity/{id}/componentOf -- what an entity or event is part of: the +// complexes, sets and reactions above it, grouped by the relationship -- with +// a stand-in graph. PhysicalEntityController over graph-core's +// ComponentOfLinkageRepository, whose query is kept as written; rows come in +// the order Neo4j gives them, unsorted, as in Java. The rows here are invented. +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +const asked = []; +let rows = []; +vi.mock('./graph.mjs', () => ({ + read: async (cypher, params) => { + asked.push([cypher, params]); + return rows; + }, + configured: () => true, + close: async () => {}, + envSetting: () => '', +})); + +const { app } = await import('./service.mjs'); + +let base; +let server; +beforeAll(async () => { + await new Promise((resolve) => { + server = app().listen(0, '127.0.0.1', resolve); + }); + base = `http://127.0.0.1:${server.address().port}`; +}); +afterAll(() => new Promise((resolve) => server.close(resolve))); + +const get = (id) => fetch(`${base}/ContentService/data/entity/${id}/componentOf`); +const last = () => asked[asked.length - 1]; +const oneLine = (cypher) => cypher.replace(/\s+/g, ' ').trim(); + +describe('/data/entity/{id}/componentOf', () => { + it("runs graph-core's query as written and writes each group as Java does", async () => { + rows = [ + { + type: 'output', + schemaClasses: ['Reaction', ''], + names: ['B', 'A'], + stIds: ['R-HSA-2', ''], + species: ['Homo sapiens', ''], + }, + { + type: 'hasComponent', + schemaClasses: ['Complex'], + names: ['C'], + stIds: ['R-HSA-3'], + species: ['Homo sapiens'], + }, + ]; + asked.length = 0; + const response = await get('R-HSA-1'); + expect(await response.text()).toBe( + '[{"type":"output","names":["B","A"],"stIds":["R-HSA-2",""],"schemaClasses":["Reaction",""],"species":["Homo sapiens",""]},' + + '{"type":"hasComponent","names":["C"],"stIds":["R-HSA-3"],"schemaClasses":["Complex"],"species":["Homo sapiens"]}]' + ); + expect(oneLine(last()[0])).toBe( + "MATCH (n:DatabaseObject{stId: $id})<-[r:hasEvent|input|output|hasComponent|hasMember|hasCandidate|repeatedUnit|proteinMarker|RNAMarker]-(m) RETURN DISTINCT(TYPE(r)) AS type, COLLECT(COALESCE(m.schemaClass, '')) AS schemaClasses, COLLECT(COALESCE(m.displayName, '')) AS names, COLLECT(COALESCE(m.stId, '')) AS stIds, COLLECT(COALESCE(m.speciesName, '')) as species" + ); + expect(last()[1]).toEqual({ id: 'R-HSA-1' }); + }); + + it('matches a dbId as a number', async () => { + rows = [ + { + type: 'input', + schemaClasses: ['Reaction'], + names: ['A'], + stIds: ['R-HSA-2'], + species: ['H'], + }, + ]; + asked.length = 0; + await get('199420'); + expect(oneLine(last()[0])).toMatch(/^MATCH \(n:DatabaseObject\{dbId: toInteger\(\$id\)\}\)<-/); + }); + + it('is a 404 naming the id, for one that is neither or is part of nothing', async () => { + rows = []; + for (const id of ['xyz', 'R-HSA-162582']) { + const response = await get(id); + expect([id, response.status, (await response.json()).messages]).toEqual([ + id, + 404, + [`Id: ${id} has not been found in the System`], + ]); + } + }); +}); diff --git a/tools/content-node/service.mjs b/tools/content-node/service.mjs index 0edd7407..99e41047 100644 --- a/tools/content-node/service.mjs +++ b/tools/content-node/service.mjs @@ -1310,6 +1310,54 @@ export const endpoints = [ }; }, }, + { + path: '/ContentService/data/entity/{id}/componentOf', + // PTEN: the reactions it comes out of, goes into and catalyses, and the + // complexes and sets it is part of. + sample: 'R-HSA-199420', + differs: [/^\/ContentService\/data\/entity\/[^/]+\/componentOf: \.url: /], + /** + * What an entity or event is directly part of -- the reactions, complexes, + * sets and pathways above it -- one group per relationship, each with the + * names, stable ids, classes and species of its members, '' for what one + * lacks. The details page's "Component of" table. + * + * graph-core's ComponentOfLinkageRepository.getComponentsOf, kept as + * written: the groups and their members come in the order Neo4j gives + * them, unsorted (ComponentOf.sortByName is never called), and a member + * reached by two relationships of one type -- a complex with two of + * something -- is listed twice. The id is read as graph-core reads it + * (graphCoreId); none found, or part of nothing, is Java's 404. + */ + handler: async (request) => { + const raw = pathId(rawSegment(request, 4)); + const missing = () => notFound(request, `Id: ${raw} has not been found in the System`); + const id = await graphCoreId(raw); + if (!id) return missing(); + const rows = await read( + `MATCH (n:DatabaseObject${id.object})<-[r:hasEvent|input|output|hasComponent|hasMember|hasCandidate|repeatedUnit|proteinMarker|RNAMarker]-(m) + RETURN DISTINCT(TYPE(r)) AS type, + COLLECT(COALESCE(m.schemaClass, '')) AS schemaClasses, + COLLECT(COALESCE(m.displayName, '')) AS names, + COLLECT(COALESCE(m.stId, '')) AS stIds, + COLLECT(COALESCE(m.speciesName, '')) as species`, + id.params + ); + if (rows.length === 0) return missing(); + return { + status: 200, + // ComponentOf's fields in declaration order; none is ever empty. + body: rows.map(({ type, names, stIds, schemaClasses, species }) => ({ + type, + names, + stIds, + schemaClasses, + species, + })), + type: 'application/json', + }; + }, + }, { path: '/ContentService/data/complex/{id}/subunits', // A set: 354 entities under it -- complexes, sets, proteins and small molecules. From 3a421162a90b8e9f7ec9891027a8f21af5f92f47 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 17 Oct 2026 19:22:35 +0000 Subject: [PATCH 2/3] Review of T076: correct two comments; pin id decoding and the 404 envelope Co-Authored-By: Claude Opus 5.5 --- tools/content-node/component-of.spec.mjs | 26 ++++++++++++++++++++++-- tools/content-node/service.mjs | 8 +++----- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/tools/content-node/component-of.spec.mjs b/tools/content-node/component-of.spec.mjs index 15a5b924..60e160d9 100644 --- a/tools/content-node/component-of.spec.mjs +++ b/tools/content-node/component-of.spec.mjs @@ -65,6 +65,21 @@ describe('/data/entity/{id}/componentOf', () => { expect(last()[1]).toEqual({ id: 'R-HSA-1' }); }); + it('reads the id as Spring reads a path variable: decoded, a matrix parameter cut', async () => { + rows = [ + { + type: 'input', + schemaClasses: ['Reaction'], + names: ['A'], + stIds: ['R-HSA-2'], + species: ['H'], + }, + ]; + asked.length = 0; + await get('R-HSA-69620;x=1'); + expect(last()[1]).toEqual({ id: 'R-HSA-69620' }); + }); + it('matches a dbId as a number', async () => { rows = [ { @@ -84,10 +99,17 @@ describe('/data/entity/{id}/componentOf', () => { rows = []; for (const id of ['xyz', 'R-HSA-162582']) { const response = await get(id); - expect([id, response.status, (await response.json()).messages]).toEqual([ + const body = await response.json(); + expect([id, response.status, body]).toEqual([ id, 404, - [`Id: ${id} has not been found in the System`], + { + code: 404, + reason: 'NOT_FOUND', + url: expect.stringMatching(/\/componentOf$/), + messages: [`Id: ${id} has not been found in the System`], + targets: null, + }, ]); } }); diff --git a/tools/content-node/service.mjs b/tools/content-node/service.mjs index 99e41047..6ca2989b 100644 --- a/tools/content-node/service.mjs +++ b/tools/content-node/service.mjs @@ -1312,8 +1312,8 @@ export const endpoints = [ }, { path: '/ContentService/data/entity/{id}/componentOf', - // PTEN: the reactions it comes out of, goes into and catalyses, and the - // complexes and sets it is part of. + // PTEN: the reactions it comes out of and goes into, and the complexes and + // sets it is part of. sample: 'R-HSA-199420', differs: [/^\/ContentService\/data\/entity\/[^/]+\/componentOf: \.url: /], /** @@ -1324,9 +1324,7 @@ export const endpoints = [ * * graph-core's ComponentOfLinkageRepository.getComponentsOf, kept as * written: the groups and their members come in the order Neo4j gives - * them, unsorted (ComponentOf.sortByName is never called), and a member - * reached by two relationships of one type -- a complex with two of - * something -- is listed twice. The id is read as graph-core reads it + * them, unsorted (ComponentOf.sortByName is never called). The id is read as graph-core reads it * (graphCoreId); none found, or part of nothing, is Java's 404. */ handler: async (request) => { From 253fee4afe2232d69a03b6dcf3103fb8aca244b4 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 17 Oct 2026 19:34:19 +0000 Subject: [PATCH 3/3] Tasks 006: T076 carries #515 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 39fed8b6..152d7a80 100644 --- a/specs/006-content-service-to-node/tasks.md +++ b/specs/006-content-service-to-node/tasks.md @@ -81,7 +81,7 @@ Each port follows the eight steps in [plan.md](plan.md#how-one-endpoint-is-porte - [x] T074 `GET /data/schema/{className}` — #511 - [x] T074b `GET /data/schema/{className}/reference` — #512 - [x] T075 `GET /data/complex/{id}/subunits`, `GET /data/complexes/{resource}/{identifier}` — #514 -- [ ] T076 `GET /data/entity/{id}/componentOf` +- [x] T076 `GET /data/entity/{id}/componentOf` — #515 - [ ] T077 `GET /data/participants/{id}/participatingPhysicalEntities` - [ ] T078 `GET /data/pathway/{id}/containedEvents`, `GET /data/pathway/{id}/containedEvents/{attributeName}` — Java maps the whole hasEvent subgraph into Events (Neo4jTemplate.findAll over `RETURN e, COLLECT(r), COLLECT(p)`), and `{attributeName}` reads a getter by reflection: do with T070/T072's getter table - [x] T079 `GET /data/pathways/low/entity/{id}/allForms`, `GET /data/pathways/low/diagram/entity/{id}`, `GET /data/pathways/low/diagram/entity/{id}/allForms` — #513