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
9 changes: 6 additions & 3 deletions deploy/nginx/common/routes-core.conf
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,13 @@ location ~ ^/ContentService/data/pathways/top/[^/]+/?$ {
# other endpoints. 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.
# `ids` is not an id: it is Java's POST /query/ids, a batch node does not
# serve, and a location matches whatever the method.
# Including POST /query/ids, a batch of objects by id, and its map form
# below: node answers both, and a GET of either as Java does.
location ~ ^/ContentService/data/query/[^/]+/?$ {
include /etc/nginx/common/content-node-proxy.conf;
}
# Quoted: unquoted, nginx reads the ';' as the end of the directive.
location ~ "^/ContentService/data/query/(?!ids(;[^/]*)?/?$)[^/]+/?$" {
location ~ "^/ContentService/data/query/ids(;[^/]*)?/map/?$" {
include /etc/nginx/common/content-node-proxy.conf;
}

Expand Down
9 changes: 4 additions & 5 deletions projects/website-angular/src/scripts/proxy-order.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ describe('the dev server proxy', () => {
'/ContentService/data/instance/R-HSA-141433/referrers',
// The PSICQUIC services the Pathway Browser can draw interactors from.
'/ContentService/interactors/psicquic/resources',
// A batch of objects by id (POST), with a matrix parameter too, and as a map.
'/ContentService/data/query/ids',
'/ContentService/data/query/ids;x=1',
'/ContentService/data/query/ids/map',
// Spring matches a trailing slash, and so must the route once Java is gone.
'/ContentService/data/instance/R-HSA-141433/referrers/',
]) {
Expand All @@ -256,11 +260,6 @@ describe('the dev server proxy', () => {
'/ContentService/data/query/abridged/stId',
// Java matches attribute names in any case; only the site's spelling is routed.
'/ContentService/data/query/70486/NORMALPATHWAY',
// Java's POST /query/ids (a batch of objects), which node does not serve.
'/ContentService/data/query/ids',
'/ContentService/data/query/ids/',
// With a matrix parameter, which Spring drops before matching.
'/ContentService/data/query/ids;x=1',
'/ContentService/data/query/abridged/70486',
// The search endpoints node does not serve yet.
'/ContentService/search/query/x?query=apoptosis',
Expand Down
4 changes: 3 additions & 1 deletion projects/website-angular/src/scripts/serve-prod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,9 @@ describe('serve-prod and the API roots', () => {
['/ContentService/data/query/R-HSA-70486?view=nested', 'content-node'],
['/ContentService/data/query/enhanced/R-HSA-70486', 'content-node'],
['/ContentService/data/query/enhanced/v2/R-HSA-70486', 'content-node'],
['/ContentService/data/query/ids', 'backend'],
// The batch lookup is node's too (spec 006 T071).
['/ContentService/data/query/ids', 'content-node'],
['/ContentService/data/query/ids/map', 'content-node'],
]) {
it(`sends ${url} to ${to}`, async () => {
expect(await (await fetch(base + url)).text()).toBe(`${to} ${url}`);
Expand Down
5 changes: 3 additions & 2 deletions proxy.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,9 @@ module.exports = {
// 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.
// `ids` is Java's POST /query/ids, a batch, which node does not serve.
'^/ContentService/data/query/(?!ids(;[^/?]*)?/?(\\?|$))[^/?]+/?(\\?|$)': contentNode,
// Including POST /query/ids, a batch of objects by id, and its map form.
'^/ContentService/data/query/[^/?]+/?(\\?|$)': contentNode,
'^/ContentService/data/query/ids(;[^/?]*)?/map/?(\\?|$)': contentNode,
// An object's stable id by any id Java accepts (13 bytes against 12KB for
// the whole object), and a disease pathway's normal pathway. Only these two
// attributes of Java's /data/query/{id}/{attributeName} are ported, so the
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 @@ -74,7 +74,7 @@ Each port follows the eight steps in [plan.md](plan.md#how-one-endpoint-is-porte
## Phase 7: Remaining graph and index operations → node

- [ ] T070 `GET /data/query/{id}/{attributeName}` for the attributes not yet ported (only `stId` and `normalPathway` are)
- [ ] T071 `POST /data/query/ids`, `POST /data/query/ids/map`
- [x] T071 `POST /data/query/ids`, `POST /data/query/ids/map` — #509
- [ ] T072 `GET /data/person/{id}/{attributeName}`
- [ ] T073 `GET /data/people/name/{name}`, `GET /data/people/name/{name}/exact`
- [ ] T074 `GET /data/schema/{className}`, `GET /data/schema/{className}/reference`
Expand Down
163 changes: 163 additions & 0 deletions tools/content-node/query-ids.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// @vitest-environment node
//
// POST /data/query/ids and /data/query/ids/map -- a batch of objects by id --
// with a stand-in graph that finds nothing, for Java's errors, and the two
// orders Java writes in: a HashSet of DatabaseObjects and a HashMap of ids.
// The expected orders are the JDK's own (11, the version the service runs),
// computed with graph-core's DatabaseObject.hashCode -- dbId, then stId,
// then displayName, 31 apart. Java's answers measured 17 Oct 2026.
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';

vi.mock('./graph.mjs', () => ({
read: async () => [],
configured: () => true,
close: async () => {},
envSetting: () => '',
}));

const { app, databaseObjectSet, idKeyedMap } = await import('./service.mjs');

const object = (dbId, stId, displayName) => ({ properties: { dbId, stId, displayName } });

describe('the orders Java writes', () => {
it("a HashSet of DatabaseObjects, by graph-core's hashCode", () => {
const objects = [
object(109581, 'R-HSA-109581', 'Apoptosis'),
object(1640170, 'R-HSA-1640170', 'Cell Cycle'),
object(199420, 'R-HSA-199420', 'PTEN [cytosol]'),
object(69620, 'R-HSA-69620', 'Cell Cycle Checkpoints'),
object(29358, 'R-ALL-29358', 'ATP [cytosol]'),
object(5672965, 'R-HSA-5672965', 'RAS GEFs'),
// Past an int, and with a field missing: Long.hashCode, and 0 for null.
object(9999999999, 'R-HSA-9', null),
object(1, null, 'x'),
];
expect(databaseObjectSet(objects).map((o) => o.properties.dbId)).toEqual([
1640170, 1, 109581, 29358, 5672965, 9999999999, 199420, 69620,
]);
});

it('keeps one of two equal objects, as a set does', () => {
const a = object(109581, 'R-HSA-109581', 'Apoptosis');
expect(databaseObjectSet([a, object(109581, 'R-HSA-109581', 'Apoptosis')])).toHaveLength(1);
});

it('a HashMap of the ids as sent', () => {
const entries = ['R-HSA-109581', '199420', 'REACT_578', 'R-HSA-1640170', '69620', 'xyz'].map(
(id) => [id, id]
);
expect(idKeyedMap(entries).map(([id]) => id)).toEqual([
'69620',
'REACT_578',
'R-HSA-1640170',
'R-HSA-109581',
'xyz',
'199420',
]);
});
});

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 post = (path, body, type = 'text/plain') =>
fetch(`${base}/ContentService/data/query/${path}`, {
method: 'POST',
headers: type ? { 'content-type': type } : {},
body,
});

describe("Java's errors", () => {
it('refuses a body that is not text/plain, before anything else', async () => {
const response = await post('ids', 'x', 'application/json');
expect([response.status, (await response.json()).messages]).toEqual([
415,
["Content type 'application/json' not supported"],
]);
});

for (const [path, signature] of [
[
'ids',
'public java.util.Collection<org.reactome.server.graph.domain.model.DatabaseObject> org.reactome.server.service.controller.graph.QueryObjectController.findByIds(java.lang.String)',
],
[
'ids/map',
'public java.util.Map<java.lang.String, org.reactome.server.graph.domain.model.DatabaseObject> org.reactome.server.service.controller.graph.QueryObjectController.findByIdsMap(java.lang.String)',
],
])
it(`answers a missing body at ${path} with Spring's 422`, async () => {
const response = await post(path, undefined);
expect([response.status, (await response.json()).messages]).toEqual([
422,
[`Required request body is missing: ${signature}`],
]);
});

it('names the ids it did not find, as a Java list', async () => {
for (const path of ['ids', 'ids/map']) {
const response = await post(path, 'xyz,abc');
expect([path, response.status, (await response.json()).messages]).toEqual([
path,
404,
['Ids: [xyz, abc] have not been found in the System'],
]);
}
});

it("is Java's 500 for an id of dots, whatever else the body holds", async () => {
// graph-core's trimId splits on '.', and "." splits to nothing.
for (const body of ['R-HSA-109581,.', '..', 'x;.']) {
const response = await post('ids', body);
expect([body, response.status, (await response.json()).messages]).toEqual([
body,
500,
['Index 0 out of bounds for length 0'],
]);
}
});

it('fails on the first bad id, in order, as Java looks them up one at a time', async () => {
const dots = await post('ids', '.,99999999999999999999');
expect((await dots.json()).messages).toEqual(['Index 0 out of bounds for length 0']);
const long = await post('ids', '99999999999999999999,.');
expect((await long.json()).messages).toEqual(['For input string: "99999999999999999999"']);
});

it("is Java's 500 for a dbId past a long", async () => {
const response = await post('ids', '99999999999999999999');
expect([response.status, (await response.json()).messages]).toEqual([
500,
['For input string: "99999999999999999999"'],
]);
});

it('answers a GET as the attribute route of Java does: no object called "ids"', async () => {
for (const path of ['ids/map', 'ids/']) {
const response = await fetch(`${base}/ContentService/data/query/${path}`);
expect([path, response.status, (await response.json()).messages]).toEqual([
path,
404,
['Id: ids has not been found in the System'],
]);
}
});

it('takes the trailing slash and a matrix parameter, as Spring does', async () => {
for (const path of ['ids/', 'ids;x=1', 'ids/map/']) {
const response = await post(path, 'xyz');
expect([path, response.status, (await response.json()).messages]).toEqual([
path,
404,
['Ids: [xyz] have not been found in the System'],
]);
}
});
});
Loading
Loading