Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions deploy/nginx/common/routes-core.conf
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,16 @@ 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 (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;
}

# 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.
Expand Down
4 changes: 4 additions & 0 deletions projects/website-angular/src/scripts/proxy-order.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions proxy.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion specs/006-content-service-to-node/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion specs/010-java-services/contracts/operation-homes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
269 changes: 269 additions & 0 deletions tools/content-node/discover.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
// @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';

// The stand-in graph answers each query by what it asks for.
let answer = () => [];
const asked = [];
vi.mock('./graph.mjs', () => ({
read: async (cypher, params) => {
asked.push(cypher);
return answer(cypher, params);
},
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('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(['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'],
]);
});
});

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)));

// 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 () => {
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 () => {
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 () => {
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));
expect(load).toBeDefined();
expect(load).not.toMatch(/ORDER BY|OPTIONAL/);
});
});
Loading
Loading