From 2a876361cbf4964a8c1eed42b7baa4b403b155e0 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 17 Oct 2026 20:33:33 +0000 Subject: [PATCH 1/3] Port /data/discover/{identifier} to content-node An event as a schema.org Dataset: DiscoverController over graph-core's SchemaDataSet, field by field (schemaDataSet). The event is loaded with graph-core's own findByStId query -- every relationship, both ways, unsorted -- and mapped by mapResult, because that load decides the order of creators and citations: r.order, relationship ids and one-type expands each disagree with Java on some event. Each edit's authors and each author's affiliations come as graph-core's LazyFetchAspect loads them (lazyCollectionQuery), repeated by stoichiometry. Not found is Java's 404, a non-event its 422 with a null message. Byte-identical to Java over 30 requests. Routed in nginx and the dev proxy. Spec 006 T082. Co-Authored-By: Claude Opus 5.5 --- deploy/nginx/common/routes-core.conf | 9 + .../src/scripts/proxy-order.spec.ts | 4 + proxy.conf.js | 2 + .../contracts/operation-homes.md | 2 +- tools/content-node/discover.spec.mjs | 184 +++++++++++++++++ tools/content-node/service.mjs | 191 ++++++++++++++++++ 6 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 tools/content-node/discover.spec.mjs diff --git a/deploy/nginx/common/routes-core.conf b/deploy/nginx/common/routes-core.conf index 014780be..37df59bc 100644 --- a/deploy/nginx/common/routes-core.conf +++ b/deploy/nginx/common/routes-core.conf @@ -351,6 +351,15 @@ location ~ ^/ContentService/data/pathways/low/(diagram/)?entity/[^/]+(/allForms) include /etc/nginx/common/content-node-proxy.conf; } +# An event as a schema.org Dataset, for search engines: graph-core's +# SchemaDataSet over its findByStId, whose unsorted load decides the order of +# creators and citations, and the lazy loads behind each author. Byte-identical +# to Java over 30 requests -- pathways and reactions, every id form, Java's +# 404, 422 and 500 -- bar an error body's `url`, as above. +location ~ ^/ContentService/data/discover/[^/]+/?$ { + include /etc/nginx/common/content-node-proxy.conf; +} + # What a complex or set is made of, all the way down (?excludeStructures for # only what is neither), and the complexes an identifier in an outside # resource is part of, the resource a regular expression as Java reads it. diff --git a/projects/website-angular/src/scripts/proxy-order.spec.ts b/projects/website-angular/src/scripts/proxy-order.spec.ts index 68bc6100..82d992ab 100644 --- a/projects/website-angular/src/scripts/proxy-order.spec.ts +++ b/projects/website-angular/src/scripts/proxy-order.spec.ts @@ -83,6 +83,8 @@ describe('the dev server proxy', () => { '/ContentService/data/complex/5672601/subunits?excludeStructures=true', '/ContentService/data/complexes/UniProt/P00533', '/ContentService/data/complexes/(%3Fi)uniprot/P00533', + '/ContentService/data/discover/R-HSA-109581', + '/ContentService/data/discover/109581/', ]) { it(`sends ${url} to the content service`, async () => { await expectContentNode(url); @@ -330,6 +332,8 @@ describe('the dev server proxy', () => { // The curated top-level pathways and the database's other info are not. '/ContentService/data/pathways/top/9606/curated', '/ContentService/data/database/info', + // Only an event's Dataset, not a path below it. + '/ContentService/data/discover/R-HSA-109581/x', // Only a complex's subunits, and the complexes of an identifier. '/ContentService/data/complex/R-HSA-5672601', '/ContentService/data/complexes/UniProt', diff --git a/proxy.conf.js b/proxy.conf.js index 16399117..df526f4b 100644 --- a/proxy.conf.js +++ b/proxy.conf.js @@ -173,6 +173,8 @@ module.exports = { // species: the entity popup and the participating-pathways list. Of every // form of it too, and the diagrams either is drawn in. '^/ContentService/data/pathways/low/(diagram/)?entity/[^/?]+(/allForms)?/?(\\?|$)': contentNode, + // An event as a schema.org Dataset, for search engines; the site never asks. + '^/ContentService/data/discover/[^/?]+/?(\\?|$)': contentNode, // What a complex is made of, and the complexes an identifier is part of. '^/ContentService/data/(complex/[^/?]+/subunits|complexes/[^/?]+/[^/?]+)/?(\\?|$)': contentNode, // The reference entities behind an event's participants (the molecule list diff --git a/specs/010-java-services/contracts/operation-homes.md b/specs/010-java-services/contracts/operation-homes.md index e7a190b3..abc79e2f 100644 --- a/specs/010-java-services/contracts/operation-homes.md +++ b/specs/010-java-services/contracts/operation-homes.md @@ -25,7 +25,7 @@ The decision record of FR-001 and FR-002. It covers every operation in the two s | GET | `/data/content/toc` | node — ported | graph or search index | | GET | `/data/database/name` | node — ported | graph or search index | | GET | `/data/database/version` | node — ported | graph or search index | -| GET | `/data/discover/{identifier}` | node — to port | graph or search index | +| GET | `/data/discover/{identifier}` | node — ported | 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 — ported | graph or search index | diff --git a/tools/content-node/discover.spec.mjs b/tools/content-node/discover.spec.mjs new file mode 100644 index 00000000..cf423bf6 --- /dev/null +++ b/tools/content-node/discover.spec.mjs @@ -0,0 +1,184 @@ +// @vitest-environment node +// +// /data/discover/{identifier} -- an event as a schema.org Dataset, for search +// engines -- as DiscoverController builds it with graph-core's SchemaDataSet. +// The builder is tested on its own with invented objects and a stand-in for +// graph-core's lazy loading; the handler with a stand-in graph, for Java's +// errors. Java's answers were measured 17 Oct 2026. +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +let rows = []; +const asked = []; +vi.mock('./graph.mjs', () => ({ + read: async (cypher) => { + asked.push(cypher); + return rows; + }, + configured: () => true, + close: async () => {}, + envSetting: () => '', +})); + +const { app, schemaDataSet, lazyCollectionQuery } = await import('./service.mjs'); + +describe('schemaDataSet', () => { + const event = { + schemaClass: 'Pathway', + properties: { dbId: 109581, stId: 'R-HSA-109581', displayName: 'Apoptosis' }, + summations: ['First.', 'Second.'], + references: [ + { schemaClass: 'LiteratureReference', displayName: 'A paper', pubMedIdentifier: 123 }, + { schemaClass: 'LiteratureReference', displayName: 'No PubMed id' }, + { schemaClass: 'Book', displayName: 'A book', ISBN: '978-0' }, + { schemaClass: 'Book', displayName: 'No ISBN' }, + { schemaClass: 'URL', displayName: 'A page' }, + ], + // The edits that authored it, in the order the event's own load gives. + authored: [10, 11], + }; + // What graph-core's lazy loading finds: each edit's authors (one listed + // twice: stoichiometry 2), each person's affiliations. + const related = { + 'author 10': [[{ dbId: 20, firstname: 'Ann', surname: 'Lee', orcidId: '0000-1' }, 2]], + 'author 11': [[{ dbId: 21, surname: 'Roe' }, 1]], + 'affiliation 20': [[{ dbId: 30, displayName: 'An institute' }, 1]], + 'affiliation 21': [], + }; + const lazy = async (dbId, type) => related[`${type} ${dbId}`] ?? []; + + it("writes graph-core's Dataset, in Jackson's order", async () => { + const json = await schemaDataSet(event, 97, lazy); + expect(Object.keys(json)).toEqual([ + 'name', + 'description', + 'url', + 'version', + 'keywords', + 'creator', + 'includedInDataCatalog', + 'distribution', + 'citation', + 'license', + '@context', + '@type', + ]); + expect(json).toMatchObject({ + name: 'Apoptosis', + description: 'First.', + url: 'https://reactome.org/PathwayBrowser/#/R-HSA-109581', + version: '97', + keywords: ['Pathway'], + includedInDataCatalog: { + name: 'Reactome', + url: 'https://reactome.org', + '@type': 'DataCatalog', + }, + license: 'https://creativecommons.org/licenses/by/4.0/', + '@context': 'http://schema.org/', + '@type': 'DataSet', + }); + }); + + it('lists every author of every edit, as often as the edit names them, with their affiliations', async () => { + const { creator } = await schemaDataSet(event, 97, lazy); + const first = { + givenName: 'Ann', + familyName: 'Lee', + affiliation: [{ name: 'An institute', '@type': 'Organization' }], + url: 'http://europepmc.org/authors/0000-1', + '@type': 'Person', + }; + expect(creator).toEqual([ + first, + first, + { familyName: 'Roe', url: 'http://europepmc.org/authors/null', '@type': 'Person' }, + ]); + expect(Object.keys(creator[0])).toEqual(Object.keys(first)); + }); + + it('offers the seven downloads by dbId, and cites each reference that has an address', async () => { + const { distribution, citation } = await schemaDataSet(event, 97, lazy); + expect(distribution.map((d) => [d.fileFormat, d.contentUrl])).toEqual([ + ['SBML', 'https://reactome.org/ContentService/exporter/sbml/109581.xml'], + ['SBGN', 'https://reactome.org/ReactomeRESTfulAPI/RESTfulWS/sbgnExporter/109581'], + ['BIOPAX2', 'https://reactome.org/ReactomeRESTfulAPI/RESTfulWS/biopaxExporter/Level2/109581'], + ['BIOPAX3', 'https://reactome.org/ReactomeRESTfulAPI/RESTfulWS/biopaxExporter/Level3/109581'], + ['PDF', 'https://reactome.org/cgi-bin/pdfexporter?DB=gk_current&ID=109581'], + ['DOCX', 'https://reactome.org/cgi-bin/rtfexporter?DB=gk_current&ID=109581'], + ['OWL', 'https://reactome.org/cgi-bin/protegeexporter?DB=gk_current&ID=109581'], + ]); + expect(Object.keys(distribution[0])).toEqual([ + 'contentUrl', + 'fileFormat', + 'encodingFormat', + '@type', + ]); + expect(citation).toEqual(['http://www.ncbi.nlm.nih.gov/pubmed/123', '978-0', 'A page']); + }); + + it('describes an event with no summation as the empty string, and omits what it lacks', async () => { + const bare = { + schemaClass: 'Reaction', + properties: { dbId: 1, stId: 'R-HSA-1', displayName: 'r' }, + }; + const json = await schemaDataSet(bare, null, async () => []); + expect([json.description, json.version, 'creator' in json, 'citation' in json]).toEqual([ + '', + 'null', + false, + false, + ]); + }); +}); + +describe('lazyCollectionQuery', () => { + it("is graph-core's queryRelationshipTypesByDbId text, by direction", () => { + expect(lazyCollectionQuery('author', 'INCOMING', 'Person')).toBe( + 'MATCH (a:DatabaseObject{dbId:$dbId})<-[r:author]-(m:Person) RETURN m, r.stoichiometry as n ORDER BY r.order' + ); + expect(lazyCollectionQuery('affiliation', 'OUTGOING', 'Affiliation')).toBe( + 'MATCH (a:DatabaseObject{dbId:$dbId})-[r:affiliation]->(m:Affiliation) RETURN m, r.stoichiometry as n ORDER BY r.order' + ); + }); +}); + +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))); + +describe("/data/discover/{identifier}: Java's errors", () => { + it('is a 404 naming the id as given, for one not found', async () => { + rows = []; + const response = await fetch(`${base}/ContentService/data/discover/xyz`); + expect([response.status, (await response.json()).messages]).toEqual([404, ['xyz not found']]); + }); + + it('is a 422 with a null message for an object that is not an event', async () => { + rows = [ + { + labels: ['DatabaseObject', 'PhysicalEntity', 'EntityWithAccessionedSequence'], + props: { dbId: 1 }, + summations: [], + references: [], + authored: [], + }, + ]; + const response = await fetch(`${base}/ContentService/data/discover/R-HSA-199420`); + expect([response.status, (await response.json()).messages]).toEqual([422, [null]]); + }); + + it("loads an event with graph-core's findByStId: every relationship, both ways, unsorted", async () => { + rows = [{ labels: ['DatabaseObject', 'Event', 'Pathway'] }]; + asked.length = 0; + await fetch(`${base}/ContentService/data/discover/R-HSA-1`); + const load = asked.find((cypher) => /MATCH \(n\)-\[r\]-\(m\)/.test(cypher)); + expect(load).toBeDefined(); + expect(load).not.toMatch(/ORDER BY|OPTIONAL/); + }); +}); diff --git a/tools/content-node/service.mjs b/tools/content-node/service.mjs index 83b93751..5fe66b6b 100644 --- a/tools/content-node/service.mjs +++ b/tools/content-node/service.mjs @@ -1384,6 +1384,76 @@ export const endpoints = [ }; }, }, + { + // Java's path variable is {identifier}; named {id} here so a dbId past a + // long's range is Java's 500 naming it (failureAnswer). + path: '/ContentService/data/discover/{id}', + // Apoptosis: five creators, seven citations. + sample: 'R-HSA-109581', + differs: [/^\/ContentService\/data\/discover\/[^/]+: \.url: /], + /** + * An event as a schema.org Dataset, for search engines: DiscoverController + * over graph-core's DatabaseObjectService.findById and SchemaDataSet + * (schemaDataSet). + * + * That findById is DatabaseObjectRepository's findByStId / findByDbId, + * not the ordered findById(id, OUTGOING) of /query/{id}: every + * relationship, both ways, unsorted, mapped as SDN maps any result + * (mapResult). Its lists -- summations, references, the edits that + * authored the event -- are in the order the store gives them; r.order, + * relationship ids and a one-type expand each disagree with Java on some + * event. The class is known from the labels before the object is loaded, + * so it is checked first: the answer is the same. + * + * Not found is Java's 404, "{id} not found"; an object that is not an + * event is the 422 its ClassCastException becomes, with a null message. + */ + handler: async (request) => { + const raw = pathId(rawSegment(request, 4)); + const missing = () => notFound(request, `${raw} not found`); + const { where, params } = resolveId(raw); + if (where === 'false') return missing(); + const [found] = await read(rootLabelsQuery(where), params); + if (!found) return missing(); + if (!isA(schemaClassOf(found.labels), 'Event')) + return javaError(request, 422, 'UNPROCESSABLE_ENTITY', null); + // graph-core's findByStId / findByDbId, as written but for the id's + // test: every relationship, both ways, in the order the store gives. + const [row] = await read( + `MATCH (n:DatabaseObject) WHERE ${where} + WITH n LIMIT 1 + MATCH (n)-[r]-(m) + RETURN {id: id(n), labels: labels(n), props: properties(n)} AS root, + COLLECT({id: id(r), type: type(r), start: id(startNode(r)), + end: id(endNode(r)), props: properties(r)}) AS relationships, + COLLECT({id: id(m), labels: labels(m), props: properties(m)}) AS nodes`, + params + ); + // MATCH, not OPTIONAL MATCH: an object with no relationship at all is + // not found, as in Java. + if (!row) return missing(); + const event = mapResult(row); + const [info] = await read('MATCH (n:DBInfo) RETURN n.releaseNumber AS version LIMIT 1'); + return { + status: 200, + body: await schemaDataSet( + { + schemaClass: event.schemaClass, + properties: event.properties, + summations: (event.fields.summation ?? []).map((summation) => summation.fields.text), + references: (event.fields.literatureReference ?? []).map((reference) => ({ + schemaClass: reference.schemaClass, + ...reference.fields, + })), + authored: (event.fields.authored ?? []).map((edit) => edit.properties.dbId), + }, + info?.version ?? null, + lazyCollection + ), + type: 'application/json', + }; + }, + }, { path: '/ContentService/data/complex/{id}/subunits', // A set: 354 entities under it -- complexes, sets, proteins and small molecules. @@ -7144,6 +7214,127 @@ async function graphCoreId(raw) { return { object: byDbId ? '{dbId: toInteger($id)}' : '{stId: $id}', params, byDbId }; } +/** + * graph-core's AdvancedDatabaseObjectRepository.queryRelationshipTypesByDbId, + * the query its LazyFetchAspect runs when a collection getter finds nothing + * loaded: the objects of one relationship, in the relationship's order. + */ +export function lazyCollectionQuery(type, direction, schemaClass) { + const [left, right] = direction === 'INCOMING' ? ['<-', '-'] : ['-', '->']; + return `MATCH (a:DatabaseObject{dbId:$dbId})${left}[r:${type}]${right}(m:${schemaClass}) RETURN m, r.stoichiometry as n ORDER BY r.order`; +} + +/** The fields schemaDataSet reads lazily, as graph-core's domain declares them. */ +const LAZY_FIELDS = { + author: ['INCOMING', 'Person'], + affiliation: ['OUTGOING', 'Affiliation'], +}; + +/** + * What LazyFetchAspect loads for one field of the object with this dbId: each + * object with its relationship's stoichiometry, as [properties, n]. + * findCollectionByRelationship adds an object that many times. + */ +async function lazyCollection(dbId, field) { + const [direction, schemaClass] = LAZY_FIELDS[field]; + const rows = await read(lazyCollectionQuery(field, direction, schemaClass), { + dbId: neo4j.int(dbId), + }); + return rows.map((row) => [row.m, row.n ?? 1]); +} + +/** graph-core's SchemaDataDownloadType, in declaration order: format, address. */ +const SCHEMA_DOWNLOADS = [ + ['SBML', 'https://reactome.org/ContentService/exporter/sbml/__ID__.xml'], + ['SBGN', 'https://reactome.org/ReactomeRESTfulAPI/RESTfulWS/sbgnExporter/__ID__'], + ['BIOPAX2', 'https://reactome.org/ReactomeRESTfulAPI/RESTfulWS/biopaxExporter/Level2/__ID__'], + ['BIOPAX3', 'https://reactome.org/ReactomeRESTfulAPI/RESTfulWS/biopaxExporter/Level3/__ID__'], + ['PDF', 'https://reactome.org/cgi-bin/pdfexporter?DB=gk_current&ID=__ID__'], + ['DOCX', 'https://reactome.org/cgi-bin/rtfexporter?DB=gk_current&ID=__ID__'], + ['OWL', 'https://reactome.org/cgi-bin/protegeexporter?DB=gk_current&ID=__ID__'], +]; + +/** + * graph-core's SchemaDataSet(event, version), written as Jackson writes it: + * fields in declaration order, then the getters license, @context and @type; + * a null or empty value omitted. `lazy(dbId, field)` stands for + * LazyFetchAspect (lazyCollection). + * + * - description: the first summation's text, "" when there is none. + * - version: String.valueOf of the release number -- "null" without one. + * - creator: for each edit that authored the event, each of its authors, as + * often as the relationship's stoichiometry says (SchemaPerson). The edits + * come with the event, in the derived query's order; their authors, and + * each author's affiliations, are not loaded and come lazily, in + * relationship order. Measured on twelve events: any other mix of the two + * orders disagrees with Java on one. + * - citation: each reference's address -- a LiteratureReference's PubMed URL + * (its computed getUrl), a Book's ISBN, anything else its displayName -- + * skipping one that has none. + * + * `event` is the object as the handler reads it: its schemaClass and + * properties, its summations' texts, its references and the dbIds of the + * edits that authored it, each list in the order findById's derived query + * gives. + */ +export async function schemaDataSet(event, version, lazy) { + const expand = (pairs) => pairs.flatMap(([object, n]) => Array(n).fill(object)); + const creator = []; + for (const edit of event.authored ?? []) { + for (const person of expand(await lazy(edit, 'author'))) { + const affiliation = expand(await lazy(person.dbId, 'affiliation')).map((org) => ({ + ...(org.displayName != null && { name: org.displayName }), + '@type': 'Organization', + })); + creator.push({ + ...(person.firstname != null && { givenName: person.firstname }), + ...(person.surname != null && { familyName: person.surname }), + ...(affiliation.length && { affiliation }), + url: `http://europepmc.org/authors/${person.orcidId ?? 'null'}`, + '@type': 'Person', + }); + } + } + const citation = []; + for (const reference of event.references ?? []) { + const cited = + reference.schemaClass === 'LiteratureReference' + ? reference.pubMedIdentifier == null + ? null + : `http://www.ncbi.nlm.nih.gov/pubmed/${reference.pubMedIdentifier}` + : reference.schemaClass === 'Book' + ? reference.ISBN + : reference.displayName; + if (cited != null) citation.push(cited); + } + const summations = event.summations ?? []; + const description = summations.length ? summations[0] : ''; + const dbId = event.properties.dbId; + return { + ...(event.properties.displayName != null && { name: event.properties.displayName }), + ...(description != null && { description }), + url: `https://reactome.org/PathwayBrowser/#/${event.properties.stId ?? 'null'}`, + version: String(version ?? 'null'), + keywords: [event.schemaClass], + ...(creator.length && { creator }), + includedInDataCatalog: { + name: 'Reactome', + url: 'https://reactome.org', + '@type': 'DataCatalog', + }, + distribution: SCHEMA_DOWNLOADS.map(([fileFormat, address]) => ({ + contentUrl: address.replace('__ID__', String(dbId)), + fileFormat, + encodingFormat: 'UTF-8', + '@type': 'DataDownload', + })), + ...(citation.length && { citation }), + license: 'https://creativecommons.org/licenses/by/4.0/', + '@context': 'http://schema.org/', + '@type': 'DataSet', + }; +} + /** Every relationship from an event down to its participants, as graph-core lists them. */ const PARTICIPANT_OF = 'regulatedBy|regulator|catalystActivity|physicalEntity|entityFunctionalStatus|diseaseEntity|hasMember|hasCandidate|hasComponent|repeatedUnit|input|output|proteinMarker|RNAMarker*'; From 579b1a903b3a3baaee637f22335e0b91442a5eca Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 17 Oct 2026 20:43:09 +0000 Subject: [PATCH 2/3] Review of T082: load before the class check; NON_EMPTY; test the handler An object with no relationship is null to graph-core's findByStId, so Java answers 404 whatever its class; node checked the class first and answered 422 for an orphaned non-event. The class is now checked after the load. Jackson's NON_EMPTY drops "" as well as null and [], so an empty description or name is left out. The handler's 200 path, its 404 after the load and a null stoichiometry are tested; no `differs`, since the Dataset's own url is content. Co-Authored-By: Claude Opus 5.5 --- deploy/nginx/common/routes-core.conf | 3 +- tools/content-node/discover.spec.mjs | 119 +++++++++++++++++++++++---- tools/content-node/service.mjs | 43 +++++----- 3 files changed, 128 insertions(+), 37 deletions(-) diff --git a/deploy/nginx/common/routes-core.conf b/deploy/nginx/common/routes-core.conf index 37df59bc..8360aefa 100644 --- a/deploy/nginx/common/routes-core.conf +++ b/deploy/nginx/common/routes-core.conf @@ -355,7 +355,8 @@ location ~ ^/ContentService/data/pathways/low/(diagram/)?entity/[^/]+(/allForms) # SchemaDataSet over its findByStId, whose unsorted load decides the order of # creators and citations, and the lazy loads behind each author. Byte-identical # to Java over 30 requests -- pathways and reactions, every id form, Java's -# 404, 422 and 500 -- bar an error body's `url`, as above. +# 404 (an object with no relationship included), 422 and 500 -- bar an error +# body's `url`, as above. location ~ ^/ContentService/data/discover/[^/]+/?$ { include /etc/nginx/common/content-node-proxy.conf; } diff --git a/tools/content-node/discover.spec.mjs b/tools/content-node/discover.spec.mjs index cf423bf6..eb1ad223 100644 --- a/tools/content-node/discover.spec.mjs +++ b/tools/content-node/discover.spec.mjs @@ -7,12 +7,13 @@ // errors. Java's answers were measured 17 Oct 2026. import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -let rows = []; +// The stand-in graph answers each query by what it asks for. +let answer = () => []; const asked = []; vi.mock('./graph.mjs', () => ({ - read: async (cypher) => { + read: async (cypher, params) => { asked.push(cypher); - return rows; + return answer(cypher, params); }, configured: () => true, close: async () => {}, @@ -116,18 +117,35 @@ describe('schemaDataSet', () => { expect(citation).toEqual(['http://www.ncbi.nlm.nih.gov/pubmed/123', '978-0', 'A page']); }); - it('describes an event with no summation as the empty string, and omits what it lacks', async () => { + it('leaves out what NON_EMPTY leaves out: no summation, no creator, no citation', async () => { const bare = { schemaClass: 'Reaction', properties: { dbId: 1, stId: 'R-HSA-1', displayName: 'r' }, }; const json = await schemaDataSet(bare, null, async () => []); - expect([json.description, json.version, 'creator' in json, 'citation' in json]).toEqual([ - '', + expect(['description' in json, json.version, 'creator' in json, 'citation' in json]).toEqual([ + false, 'null', false, false, ]); + // And an empty text or name, as Jackson's NON_EMPTY drops "". + const blank = await schemaDataSet( + { + ...bare, + properties: { ...bare.properties, displayName: '' }, + summations: [''], + authored: [1], + }, + 1, + async (dbId, field) => + field === 'author' ? [[{ dbId: 2, firstname: '', surname: 'S' }, 1]] : [] + ); + expect(['name' in blank, 'description' in blank, Object.keys(blank.creator[0])]).toEqual([ + false, + false, + ['familyName', 'url', '@type'], + ]); }); }); @@ -152,29 +170,96 @@ beforeAll(async () => { }); afterAll(() => new Promise((resolve) => server.close(resolve))); +// A pathway with a summation, a book and one authoring edit, as graph-core's +// findByStId returns it, and the lazy loads behind the edit. +const ROOT = { + id: 1, + labels: ['DatabaseObject', 'Event', 'Pathway'], + props: { dbId: 5, stId: 'R-HSA-5', displayName: 'P', schemaClass: 'Pathway' }, +}; +const LOADED = { + root: ROOT, + nodes: [ + { + id: 2, + labels: ['DatabaseObject', 'Summation'], + props: { dbId: 6, text: 'T', schemaClass: 'Summation' }, + }, + { + id: 3, + labels: ['DatabaseObject', 'Publication', 'Book'], + props: { dbId: 7, ISBN: '978', displayName: 'B', schemaClass: 'Book' }, + }, + { + id: 4, + labels: ['DatabaseObject', 'InstanceEdit'], + props: { dbId: 8, displayName: 'E', schemaClass: 'InstanceEdit' }, + }, + ], + relationships: [ + { id: 10, type: 'summation', start: 1, end: 2, props: {} }, + { id: 11, type: 'literatureReference', start: 1, end: 3, props: {} }, + { id: 12, type: 'authored', start: 4, end: 1, props: {} }, + ], +}; +const graph = + ({ load = [LOADED] } = {}) => + (cypher, params) => { + if (/MATCH \(n\)-\[r\]-\(m\)/.test(cypher)) return load; + if (/DBInfo/.test(cypher)) return [{ version: 97 }]; + // The edit's author, with no stoichiometry on the relationship: once. + if (/<-\[r:author\]-/.test(cypher) && Number(params.dbId) === 8) + return [{ m: { dbId: 20, surname: 'Roe' }, n: null }]; + return []; + }; + +describe('/data/discover/{identifier}', () => { + it('writes the Dataset of the event graph-core loads, with its lazy loads', async () => { + answer = graph(); + const response = await fetch(`${base}/ContentService/data/discover/R-HSA-5`); + expect(await response.json()).toMatchObject({ + name: 'P', + description: 'T', + url: 'https://reactome.org/PathwayBrowser/#/R-HSA-5', + version: '97', + keywords: ['Pathway'], + creator: [{ familyName: 'Roe', url: 'http://europepmc.org/authors/null', '@type': 'Person' }], + citation: ['978'], + }); + }); + + it('is not found when graph-core loads nothing: an object with no relationship, whatever its class', async () => { + answer = graph({ load: [] }); + const response = await fetch(`${base}/ContentService/data/discover/12345`); + expect([response.status, (await response.json()).messages]).toEqual([404, ['12345 not found']]); + }); +}); + describe("/data/discover/{identifier}: Java's errors", () => { it('is a 404 naming the id as given, for one not found', async () => { - rows = []; + answer = () => []; const response = await fetch(`${base}/ContentService/data/discover/xyz`); expect([response.status, (await response.json()).messages]).toEqual([404, ['xyz not found']]); }); it('is a 422 with a null message for an object that is not an event', async () => { - rows = [ - { - labels: ['DatabaseObject', 'PhysicalEntity', 'EntityWithAccessionedSequence'], - props: { dbId: 1 }, - summations: [], - references: [], - authored: [], - }, - ]; + answer = graph({ + load: [ + { + ...LOADED, + root: { + ...ROOT, + labels: ['DatabaseObject', 'PhysicalEntity', 'EntityWithAccessionedSequence'], + }, + }, + ], + }); const response = await fetch(`${base}/ContentService/data/discover/R-HSA-199420`); expect([response.status, (await response.json()).messages]).toEqual([422, [null]]); }); it("loads an event with graph-core's findByStId: every relationship, both ways, unsorted", async () => { - rows = [{ labels: ['DatabaseObject', 'Event', 'Pathway'] }]; + answer = graph(); asked.length = 0; await fetch(`${base}/ContentService/data/discover/R-HSA-1`); const load = asked.find((cypher) => /MATCH \(n\)-\[r\]-\(m\)/.test(cypher)); diff --git a/tools/content-node/service.mjs b/tools/content-node/service.mjs index 5fe66b6b..b1bc8f13 100644 --- a/tools/content-node/service.mjs +++ b/tools/content-node/service.mjs @@ -1390,7 +1390,8 @@ export const endpoints = [ path: '/ContentService/data/discover/{id}', // Apoptosis: five creators, seven citations. sample: 'R-HSA-109581', - differs: [/^\/ContentService\/data\/discover\/[^/]+: \.url: /], + // No `differs`: the Dataset's own `url` is content, and an error body's + // is the only difference the sample never meets. /** * An event as a schema.org Dataset, for search engines: DiscoverController * over graph-core's DatabaseObjectService.findById and SchemaDataSet @@ -1402,21 +1403,17 @@ export const endpoints = [ * (mapResult). Its lists -- summations, references, the edits that * authored the event -- are in the order the store gives them; r.order, * relationship ids and a one-type expand each disagree with Java on some - * event. The class is known from the labels before the object is loaded, - * so it is checked first: the answer is the same. + * event. * - * Not found is Java's 404, "{id} not found"; an object that is not an - * event is the 422 its ClassCastException becomes, with a null message. + * Not found -- an object with no relationship included -- is Java's 404, + * "{id} not found"; an object that is not an event is the 422 its + * ClassCastException becomes, with a null message. */ handler: async (request) => { const raw = pathId(rawSegment(request, 4)); const missing = () => notFound(request, `${raw} not found`); const { where, params } = resolveId(raw); if (where === 'false') return missing(); - const [found] = await read(rootLabelsQuery(where), params); - if (!found) return missing(); - if (!isA(schemaClassOf(found.labels), 'Event')) - return javaError(request, 422, 'UNPROCESSABLE_ENTITY', null); // graph-core's findByStId / findByDbId, as written but for the id's // test: every relationship, both ways, in the order the store gives. const [row] = await read( @@ -1430,8 +1427,10 @@ export const endpoints = [ params ); // MATCH, not OPTIONAL MATCH: an object with no relationship at all is - // not found, as in Java. + // not found, as in Java -- whatever its class. if (!row) return missing(); + if (!isA(schemaClassOf(row.root.labels), 'Event')) + return javaError(request, 422, 'UNPROCESSABLE_ENTITY', null); const event = mapResult(row); const [info] = await read('MATCH (n:DBInfo) RETURN n.releaseNumber AS version LIMIT 1'); return { @@ -7260,7 +7259,8 @@ const SCHEMA_DOWNLOADS = [ * a null or empty value omitted. `lazy(dbId, field)` stands for * LazyFetchAspect (lazyCollection). * - * - description: the first summation's text, "" when there is none. + * - description: the first summation's text, "" when there is none -- + * which NON_EMPTY then leaves out. * - version: String.valueOf of the release number -- "null" without one. * - creator: for each edit that authored the event, each of its authors, as * often as the relationship's stoichiometry says (SchemaPerson). The edits @@ -7278,18 +7278,23 @@ const SCHEMA_DOWNLOADS = [ * gives. */ export async function schemaDataSet(event, version, lazy) { + // Jackson's NON_EMPTY, ContentService's setting: no null, "" or []. + const kept = (name, value) => + value === null || value === undefined || value === '' || (Array.isArray(value) && !value.length) + ? {} + : { [name]: value }; const expand = (pairs) => pairs.flatMap(([object, n]) => Array(n).fill(object)); const creator = []; for (const edit of event.authored ?? []) { for (const person of expand(await lazy(edit, 'author'))) { const affiliation = expand(await lazy(person.dbId, 'affiliation')).map((org) => ({ - ...(org.displayName != null && { name: org.displayName }), + ...kept('name', org.displayName), '@type': 'Organization', })); creator.push({ - ...(person.firstname != null && { givenName: person.firstname }), - ...(person.surname != null && { familyName: person.surname }), - ...(affiliation.length && { affiliation }), + ...kept('givenName', person.firstname), + ...kept('familyName', person.surname), + ...kept('affiliation', affiliation), url: `http://europepmc.org/authors/${person.orcidId ?? 'null'}`, '@type': 'Person', }); @@ -7311,12 +7316,12 @@ export async function schemaDataSet(event, version, lazy) { const description = summations.length ? summations[0] : ''; const dbId = event.properties.dbId; return { - ...(event.properties.displayName != null && { name: event.properties.displayName }), - ...(description != null && { description }), + ...kept('name', event.properties.displayName), + ...kept('description', description), url: `https://reactome.org/PathwayBrowser/#/${event.properties.stId ?? 'null'}`, version: String(version ?? 'null'), keywords: [event.schemaClass], - ...(creator.length && { creator }), + ...kept('creator', creator), includedInDataCatalog: { name: 'Reactome', url: 'https://reactome.org', @@ -7328,7 +7333,7 @@ export async function schemaDataSet(event, version, lazy) { encodingFormat: 'UTF-8', '@type': 'DataDownload', })), - ...(citation.length && { citation }), + ...kept('citation', citation), license: 'https://creativecommons.org/licenses/by/4.0/', '@context': 'http://schema.org/', '@type': 'DataSet', From b2343b6dcca59fbf679896afa249ac66a493dd48 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 17 Oct 2026 20:49:55 +0000 Subject: [PATCH 3/3] Tasks 006: T082 carries #519 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 e522c7ad..e62542cf 100644 --- a/specs/006-content-service-to-node/tasks.md +++ b/specs/006-content-service-to-node/tasks.md @@ -87,7 +87,7 @@ Each port follows the eight steps in [plan.md](plan.md#how-one-endpoint-is-porte - [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 - [ ] T080 `GET /data/mapping/{resource}/{identifier}/pathways`, `GET /data/mapping/{resource}/{identifier}/reactions` - [x] T081 `GET /data/diseases`, `GET /data/diseases/doid` — #510 -- [ ] T082 `GET /data/discover/{identifier}` +- [x] T082 `GET /data/discover/{identifier}` — #519 - [ ] T083 `GET /references/mapping/{identifier}`, `GET /references/mapping/{identifier}/xrefs`, `POST /references/mapping/xrefs` - [ ] T084 `GET /interactors/static/molecule/{acc}/pathways`, `GET /interactors/static/molecule/{acc}/summary`, `POST /interactors/static/molecules/summary` - [ ] T085 `POST /contact` — its human check then runs where the site's secrets are (research D2)