diff --git a/specs/015-graph-exports/tasks.md b/specs/015-graph-exports/tasks.md index 3414b199..c1a14540 100644 --- a/specs/015-graph-exports/tasks.md +++ b/specs/015-graph-exports/tasks.md @@ -95,7 +95,7 @@ Both exporters' adoption pull requests found that their own checks do not run ye It takes a species file as well as an event's. Show it failing on a changed stoichiometry and a removed xref. -- [ ] T032 The private graph copy (R8), for the release being compared, recreated whenever a comparison needs it and removed afterwards. The legacy answers (T004) and the library must be of one release. The legacy service reads the relational database at release 97 (R4) while beta's graph is at 98, so the copy for T049 is at the legacy database's release, and T004 is taken again only when that database moves. Beta keeps no release-97 volume or image any more (`next-release.sh` removes the previous release's), so the copy needs release 97's graph image pulled from its registry (about 4.5 GB, and as much again for its store), with the disk's room checked first; or, if the relational database is moved to release 98 instead, T004 is taken again and the copy is at 98. Which of the two is recorded in T049. +- [ ] T032 The private graph copy (R8), for the release being compared, recreated whenever a comparison needs it and removed afterwards. The legacy answers (T004) and the library must be of one release. The legacy service reads the relational database at release 97 (R4) while beta's graph is at 98, so the copy for T049 is at the legacy database's release, and T004 is taken again only when that database moves. Beta keeps no release-97 volume or image any more (`next-release.sh cleanup` removed the previous release's), so the copy needs release 97's graph image pulled from its registry (about 4.5 GB, and as much again for its store), with the disk's room checked first; or, if the relational database is moved to release 98 instead, T004 is taken again and the copy is at 98. Which of the two is recorded in T049. - [ ] T033 (`biopax.zip` and `homo_sapiens.3.1.sbml.tgz` done for release 97, recorded in `today.md`; `all_species.3.1.sbml.tgz` when T041 needs it.) Fetch the release's bulk references from its download directory, `download.reactome.org/{release}/`: `biopax.zip`, `all_species.3.1.sbml.tgz` and `homo_sapiens.3.1.sbml.tgz`. One at a time, kept outside the repository (they hold people's names). Record each file's size, digest and list of entries in `today.md`, never their content. - [ ] T034 Run the validators on today's files and record the baselines in research.md R5: - libSBML 5.21.2's `checkConsistency()` over T004's SBML and a sample of T033's archive, with the units and modelling-practice categories off; @@ -272,7 +272,7 @@ Both exporters' adoption pull requests found that their own checks do not run ye - **Memory and time:** the worst case the limits allow, two of the largest BioPAX conversions (Signal Transduction or Metabolism, measured) and two of SBML's at once, runs within T064's heap; or a cap is shared across the operations, or the heap and limit are raised, with the measurement in the PR. The largest pathway's cold conversion stays under node's 290 s (T065). - **Health** reports the new pin. - **Checked:** unit tests, each seen failing first; and the CI job runs the image over `BioPAX3Exporter`'s fixture graph at the pinned commit, each answer contained in that commit's expected species file by its `ci/contained.py`. -- [ ] T070 [US2] Node answers `/ContentService/exporter/event/{id}.biopax`, slashed form included, in `exporters-endpoints.ts`, as T065 does SBML: +- [x] T070 [US2] Node answers `/ContentService/exporter/event/{id}.biopax`, slashed form included, in `exporters-endpoints.ts`, as T065 does SBML: - R6's headers: `application/rdf+xml`, the download named by stable id; - 404 and 400 in the envelope, decided from the object's labels; - the body streamed; @@ -280,12 +280,12 @@ Both exporters' adoption pull requests found that their own checks do not run ye - 503 in the envelope when the service cannot answer, and never a fallback; - cached per release under spec 011. - Tests against a stub service, each seen failing first. + Tests against a stub service, each seen failing first. — #754. SBML's answer is generalised (`exportAnswer`, a format each), and `/exporter/event/{id}` answers `.biopax` from the service's `/biopax/` and `.sbml` as before. The service's address is still `SBML_SERVICE`, which names the library service for both. `parameters.ts`'s kind for the id takes `.biopax` too, so the answer is in standard form and cached per release. `biopax-export.spec.ts`, 10 tests; each of six mutants made one fail: the shares shared, an `.owl` download name, the service asked for SBML, the dispatch always SBML, a `text/plain` type, and `.biopax` out of standard form. - [ ] T071 [US2] The route, in nginx (`dev.conf` under the render budget, before its `routes.conf` include, and `local.conf`) and the dev proxy alike, as T066, once T049 and T050 are recorded. `routes.spec.ts` follows; `nginx -t` for every deployment. The release's and production's files follow when `BioPAX3Exporter` is the release's (T055), as T068 does for SBML. - [ ] T072 [US2] Check on beta, before and after the switch, over the sample, as T067: - statuses, headers and errors as R6 decided (`application/rdf+xml`, the download named by stable id, 404 and 400 in the envelope), against `contracts/differences.md`'s BioPAX rows, since the legacy service's differ on purpose; - - each body contained in the library's own species file at beta's graph's release (made by the library's command line on a private copy at that release, as `BioPAX3Exporter`'s T020 did; human takes about 16 minutes), by `ci/contained.py` as T069's CI does. Not the published `biopax.zip`, which differs from the library's files by the differences `BioPAX3Exporter` records (beta's graph and the legacy database are of different releases, so the bodies are compared with the legacy answers in T049, not here); + - each body contained in the library's own species file at beta's graph's release (made by the library's command line on a private copy at that release, as `BioPAX3Exporter`'s T020 did; human took 15 min 44 s there, [BioPAX3Exporter#77](https://github.com/reactome/BioPAX3Exporter/pull/77)), by `ci/contained.py` as T069's CI does. Not the published `biopax.zip`, which differs from the library's files by the differences `BioPAX3Exporter` records (beta's graph and the legacy database are of different releases, so the bodies are compared with the legacy answers in T049, not here); - the largest pathway's time, cold and cached, and the service's memory. - [ ] T053 [US2] Replace #634's Tomcat location for the legacy path with node's 301, resolving the dbId and redirecting to the stable-id URL (`/ContentService/exporter/event/{stId}.biopax`): - in `deploy/nginx/dev.conf`, `routes-core.conf` and the dev proxy, with `routes.spec.ts` and `serve-prod.spec.ts` following; @@ -325,7 +325,7 @@ Both exporters' adoption pull requests found that their own checks do not run ye - T030 and T031 come before any comparison. - T032 and T033 come before the comparisons that use them. - **SBML (US1):** T036 → T037 → T038 → T039 → T040 → T041. T042 follows T036. T063 → T064 → T065 → T066 → T067 → T068 (once FR-009's open differences are settled); T066 needs T030, T039, T037's whole-sample run and every file's validity (FR-009 on beta). -- **BioPAX (US2):** T045–T048 are done in `BioPAX3Exporter`. T049 and T050 need T069's image, run against T032's copy rather than beta's service. T052 is T069 → T070 → T071 → T072; T071 needs T049 and T050, and spec 010's repository is not a prerequisite. T053 and T054 follow T052. +- **BioPAX (US2):** T045–T048 are done in `BioPAX3Exporter`. T049 and T050 need T069's image, run against T032's copy rather than beta's service; T072 needs a T032 copy at beta's release for the library's own species file. T052 is T069 → T070 → T071 → T072; T071 needs T049 and T050, and spec 010's repository is not a prerequisite. T053 and T054 follow T052. - **US4:** T055 after T049. T057 after T041. - **The libraries:** T061 comes before the website's switches (T066, T052). `sbml-exporter`'s half is done (its T022, sbml-exporter #58–#63), and T066 went ahead on it, on beta only; `BioPAX3Exporter`'s is done too (its T009, #31). diff --git a/tools/content-node/biopax-export.spec.ts b/tools/content-node/biopax-export.spec.ts new file mode 100644 index 00000000..c492ce35 --- /dev/null +++ b/tools/content-node/biopax-export.spec.ts @@ -0,0 +1,220 @@ +// @vitest-environment node +// +// /exporter/event/{id}.biopax (spec 015 T070): node writes research R6's +// answer around the library service's BioPAX file (T069). Java has no BioPAX +// URL, so R6 chose the answers: application/rdf+xml, a {stId}.biopax download, +// and SBML's 404 and 400 in the envelope. +import { createServer, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { describe, expect, it, beforeAll, afterAll, afterEach } from 'vitest'; +import { + biopaxAnswer, + eventExportAnswer, + exportersEndpoints, + sbmlAnswer, + type ExportEvent, +} from './exporters-endpoints.ts'; +import type { Answer, Request } from './endpoint.ts'; + +/** The graph as these cases need it: one pathway, one object that is no event. */ +const find = async (raw: string): Promise => { + const id = raw.split('.')[0]; + if (id === 'R-HSA-1' || id === '1') return { stId: 'R-HSA-1', isEvent: true }; + if (id === '9626065') return { stId: null, isEvent: false }; + return null; +}; + +const request = (path: string): Request => + ({ + originalUrl: `/ContentService/exporter/${path}`, + protocol: 'http', + get: () => 'localhost', + }) as unknown as Request; + +const envelope = (answer: Answer) => { + const body = JSON.parse(String(answer.body)) as { reason: string; messages: string[] }; + return [answer.status, body.reason, body.messages]; +}; + +// The library service, as each case needs it to behave. +let behave: (path: string, response: ServerResponse) => void; +let asked: string[] = []; +let service: Server; +let serviceUrl: string; + +beforeAll(async () => { + service = createServer((req, res) => { + asked.push(String(req.url)); + behave(String(req.url), res); + }); + await new Promise((resolve) => service.listen(0, '127.0.0.1', () => resolve())); + serviceUrl = `http://127.0.0.1:${(service.address() as AddressInfo).port}`; +}); +afterAll(() => { + service.closeAllConnections(); + return new Promise((resolve) => service.close(() => resolve())); +}); +afterEach(() => { + delete process.env['SBML_SERVICE']; + asked = []; +}); + +const withService = () => (process.env['SBML_SERVICE'] = serviceUrl); +const answers = (path: string, headersMs?: number) => biopaxAnswer(request(path), find, headersMs); + +async function text(answer: Answer) { + const chunks: Buffer[] = []; + for await (const chunk of answer.body as AsyncIterable) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf8'); +} + +describe('before any file is made', () => { + it('names an id nothing has, as given, in a 404', async () => { + withService(); + expect(envelope(await answers('event/R-HSA-0.3.biopax'))).toEqual([ + 404, + 'NOT_FOUND', + ["Identifier 'R-HSA-0.3' not found"], + ]); + expect(asked).toEqual([]); + }); + + it('refuses an object that is no pathway or reaction with a 400', async () => { + withService(); + expect(envelope(await answers('event/9626065.biopax'))).toEqual([ + 400, + 'BAD_REQUEST', + ["The identifier '9626065' does not correspond to a pathway or reaction"], + ]); + expect(asked).toEqual([]); + }); + + it('answers 503 without the service, never another implementation', async () => { + expect(envelope(await answers('event/R-HSA-1.biopax'))).toEqual([ + 503, + 'SERVICE_UNAVAILABLE', + ['The BioPAX service is not configured'], + ]); + }); +}); + +describe('the file', () => { + it('passes the service’s BioPAX on, as RDF named {stId}.biopax, from any form of the id', async () => { + withService(); + behave = (_path, res) => { + res.writeHead(200, { 'Content-Type': 'application/rdf+xml;charset=UTF-8' }); + res.end(''); + }; + for (const form of ['R-HSA-1', 'R-HSA-1.4', '1']) { + const answer = await answers(`event/${form}.biopax`); + expect(answer.status).toBe(200); + expect(answer.type).toBe('application/rdf+xml;charset=UTF-8'); + expect(answer.headers?.['Content-Disposition']).toBe('attachment; filename="R-HSA-1.biopax"'); + expect(await text(answer)).toBe(''); + } + // The service is asked for BioPAX, by the bare stable id + expect(asked).toEqual(['/biopax/R-HSA-1', '/biopax/R-HSA-1', '/biopax/R-HSA-1']); + }); + + it('the service’s 404 is a 404, and its failure a 500 without its own reason', async () => { + withService(); + behave = (_path, res) => { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end('{"error":"No event"}'); + }; + expect((await answers('event/R-HSA-1.biopax')).status).toBe(404); + behave = (_path, res) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end('{"error":"/cache/tmp/biopax/x cannot be written"}'); + }; + expect(envelope(await answers('event/R-HSA-1.biopax'))).toEqual([ + 500, + 'INTERNAL_SERVER_ERROR', + ['The BioPAX of R-HSA-1 cannot be made'], + ]); + }); + + it('a service that sends no headers in time is a 503', async () => { + withService(); + behave = () => undefined; + expect(envelope(await answers('event/R-HSA-1.biopax', 50))).toEqual([ + 503, + 'SERVICE_UNAVAILABLE', + ['The BioPAX file took too long to make'], + ]); + service.closeAllConnections(); + }); +}); + +describe('BioPAX’s own share of two', () => { + it('refuses a third file, and is not taken by SBML’s', async () => { + withService(); + const held: ServerResponse[] = []; + behave = (_path, res) => { + held.push(res); + }; + const biopax = [answers('event/R-HSA-1.biopax'), answers('event/R-HSA-1.biopax')]; + const sbml = [ + sbmlAnswer(request('event/R-HSA-1.sbml'), 'sbml', find), + sbmlAnswer(request('event/R-HSA-1.sbml'), 'sbml', find), + ]; + await expect.poll(() => held.length).toBe(4); + // SBML's two do not count against BioPAX's share, and BioPAX's own are full + const third = await answers('event/R-HSA-1.biopax'); + expect(envelope(third)).toEqual([ + 503, + 'SERVICE_UNAVAILABLE', + ['Too many BioPAX files are being made; try again shortly'], + ]); + expect(third.headers?.['Retry-After']).toBe('5'); + for (const res of held) { + res.writeHead(200); + res.end('x'); + } + for (const answer of [...(await Promise.all(biopax)), ...(await Promise.all(sbml))]) + await text(answer); + // A place is given back once a file has been passed on + behave = (_path, res) => { + res.writeHead(200); + res.end(''); + }; + await expect.poll(async () => (await answers('event/R-HSA-1.biopax')).status).toBe(200); + }); +}); + +describe('/exporter/event/{id}', () => { + it('answers .biopax with BioPAX and .sbml with SBML, and leaves .sbgn to Java', async () => { + withService(); + behave = (_path, res) => { + res.writeHead(200); + res.end('x'); + }; + const biopax = await eventExportAnswer(request('event/R-HSA-1.biopax'), find); + expect(biopax.headers?.['Content-Disposition']).toBe('attachment; filename="R-HSA-1.biopax"'); + await text(biopax); + const sbml = await eventExportAnswer(request('event/R-HSA-1.sbml'), find); + expect(sbml.headers?.['Content-Disposition']).toBe('attachment; filename="R-HSA-1.sbml"'); + await text(sbml); + expect((await eventExportAnswer(request('event/R-HSA-1.sbgn'), find)).status).toBe(404); + expect(asked).toEqual(['/biopax/R-HSA-1', '/sbml/R-HSA-1']); + }); + + it('is the endpoint’s handler', () => { + const endpoint = exportersEndpoints.find( + (candidate) => candidate.path === '/ContentService/exporter/event/{id}' + ); + expect(String(endpoint?.handler)).toContain('eventExportAnswer'); + }); +}); + +describe('standard form, which decides what is cached per release (spec 011)', () => { + it('takes .biopax and .sbml on /exporter/event/, and nothing else', async () => { + const { standardForm } = await import('./parameters.ts'); + const form = (path: string) => + standardForm('/ContentService/exporter/event/{id}', `/ContentService/exporter/event/${path}`); + expect(form('R-HSA-1.biopax')).toEqual({ ok: true }); + expect(form('R-HSA-1.sbml')).toEqual({ ok: true }); + expect(form('R-HSA-1.owl')).toEqual({ ok: false, rule: 'path:id' }); + expect(form('R-HSA-1.biopax.sbml')).toEqual({ ok: false, rule: 'path:id' }); + }); +}); diff --git a/tools/content-node/exporters-endpoints.ts b/tools/content-node/exporters-endpoints.ts index 3b458695..89f1e2f2 100644 --- a/tools/content-node/exporters-endpoints.ts +++ b/tools/content-node/exporters-endpoints.ts @@ -347,8 +347,8 @@ async function renderFigure(request: Request, url: string | URL, ext: string) { }; } -/** An event an SBML file is asked for: its stable id, and whether it is one SBML is made of. */ -export interface SbmlEvent { +/** An event an export is asked for: its stable id, and whether it is one an export is made of. */ +export interface ExportEvent { stId: string | null; isEvent: boolean; } @@ -359,10 +359,10 @@ export interface SbmlEvent { * answers 400 for anything else. Decided from its labels, where Java loaded * every relationship of the object to decide it (about 30 s for ATP). */ -async function sbmlEvent(raw: string): Promise { +async function exportEvent(raw: string): Promise { const id = await graphCoreId(raw); if (!id) return null; - const [found] = await read( + const [found] = await read( `MATCH (n:DatabaseObject ${id.object}) RETURN n.stId AS stId, (n:Pathway OR n:ReactionLikeEvent) AS isEvent LIMIT 1`, id.params ); @@ -370,21 +370,56 @@ async function sbmlEvent(raw: string): Promise { } /** - * SBML's own share (research R6): Java made one file at a time, and the - * service two (its backstop); past it, a 503 with Retry-After, as the deep - * pool answers. Separate from the deep pool, so a burst of SBML cannot starve - * the queries the pages need. + * Each export's own share (research R6): Java made one file at a time, and the + * service two at once across its exports (its backstop, spec 015 T069); past + * a share, a 503 with Retry-After, as the deep pool answers. Separate from the + * deep pool and from each other, so a burst of one cannot starve the queries + * the pages need, or the other export. */ const SBML_SHARE = deepShare(2); +const BIOPAX_SHARE = deepShare(2); /** * How long the service has to send its headers: under nginx's 300 s, so the * reader gets this envelope rather than nginx's page. Once the body flows it * is not timed: it is a file the service has already written. */ -const SBML_HEADERS_MS = 290_000; +const EXPORT_HEADERS_MS = 290_000; -const SBML_TYPE = 'application/sbml+xml;charset=UTF-8'; +/** What differs between the library service's exports: the rest is one answer. */ +interface ExportFormat { + /** As the service's path and the messages name it. */ + path: 'sbml' | 'biopax'; + label: 'SBML' | 'BioPAX'; + type: string; + /** The download's extension. */ + download: string; + share: ReturnType; + /** The reader's message for the service's 503: SBML's is a diagram file it could not have. */ + unavailable: string; +} + +const SBML: ExportFormat = { + path: 'sbml', + label: 'SBML', + type: 'application/sbml+xml;charset=UTF-8', + download: 'sbml', + share: SBML_SHARE, + unavailable: "The pathway's diagram cannot be had just now; try again shortly", +}; + +/** + * BioPAX Level 3 (research R6): `application/rdf+xml`, named `{stId}.biopax`. + * No URL of Java's, so no download guard to imitate: the name is the server's. + */ +const BIOPAX: ExportFormat = { + path: 'biopax', + label: 'BioPAX', + type: 'application/rdf+xml;charset=UTF-8', + download: 'biopax', + share: BIOPAX_SHARE, + unavailable: 'The BioPAX file cannot be made just now; try again shortly', +}; /** * The service's answer, once its headers are in, or a rejection: 'late' when @@ -394,7 +429,7 @@ const SBML_TYPE = 'application/sbml+xml;charset=UTF-8'; * back. An IncomingMessage closes either way. Plain HTTP: the service is on * loopback. */ -function sbmlResponse(url: string, headersMs: number): Promise { +function exportResponse(url: string, headersMs: number): Promise { return new Promise((resolve, reject) => { // A connection per file: a kept-alive one the service has since closed // would fail the next file as unreachable. @@ -435,18 +470,54 @@ function bodyText(response: IncomingMessage): Promise { /** * An event's SBML, as Java's /exporter/event/{id}.sbml answers it -- its 404, - * 400, type and download name -- with the body from the SBML service + * 400, type and download name -- with the body from the library service * (deploy/java-library, spec 015 T063), passed on as it arrives. Never made * by another implementation: without the service, a 503. - * - * The service's reasons name its own paths, so they are logged here and - * never passed on. */ -export async function sbmlAnswer( +export function sbmlAnswer( request: Request, ext: 'sbml' | 'xml', - find: (raw: string) => Promise = sbmlEvent, - headersMs = SBML_HEADERS_MS + find: (raw: string) => Promise = exportEvent, + headersMs = EXPORT_HEADERS_MS +): Promise { + return exportAnswer(request, ext, SBML, find, headersMs); +} + +/** + * An event's BioPAX Level 3 at /exporter/event/{id}.biopax (spec 015 T070), + * from the library service (T069), answered as SBML is: the same 404 and 400, + * R6's type and a `{stId}.biopax` download. + */ +export function biopaxAnswer( + request: Request, + find: (raw: string) => Promise = exportEvent, + headersMs = EXPORT_HEADERS_MS +): Promise { + return exportAnswer(request, 'biopax', BIOPAX, find, headersMs); +} + +/** /exporter/event/{id}: BioPAX for `.biopax`, SBML for the rest (whose other extensions are Java's). */ +export function eventExportAnswer( + request: Request, + find: (raw: string) => Promise = exportEvent, + headersMs = EXPORT_HEADERS_MS +): Promise { + return splitExportName(pathId(rawSegment(request, 4)))?.ext === 'biopax' + ? biopaxAnswer(request, find, headersMs) + : sbmlAnswer(request, 'sbml', find, headersMs); +} + +/** + * The library service's file for an event, answered as Java's SBML export is. + * The service's reasons name its own paths, so they are logged here and never + * passed on. + */ +async function exportAnswer( + request: Request, + ext: string, + format: ExportFormat, + find: (raw: string) => Promise, + headersMs: number ): Promise { const name = splitExportName(pathId(rawSegment(request, 4))); // Another extension on /exporter/event/ is Java's (SBGN, the PDF), which @@ -463,16 +534,22 @@ export async function sbmlAnswer( 'BAD_REQUEST', `The identifier '${name.identifier}' does not correspond to a pathway or reaction` ); + // The library service's address: SBML_SERVICE names it, for both its exports. const service = settings.sbmlService(); if (!service) - return javaError(request, 503, 'SERVICE_UNAVAILABLE', 'The SBML service is not configured'); - if (!SBML_SHARE.enter()) + return javaError( + request, + 503, + 'SERVICE_UNAVAILABLE', + `The ${format.label} service is not configured` + ); + if (!format.share.enter()) return { ...javaError( request, 503, 'SERVICE_UNAVAILABLE', - 'Too many SBML files are being made; try again shortly' + `Too many ${format.label} files are being made; try again shortly` ), headers: { 'Retry-After': '5' }, }; @@ -481,49 +558,49 @@ export async function sbmlAnswer( try { let response: IncomingMessage; try { - response = await sbmlResponse(`${service.replace(/\/$/, '')}/sbml/${event.stId}`, headersMs); + response = await exportResponse( + `${service.replace(/\/$/, '')}/${format.path}/${event.stId}`, + headersMs + ); } catch (caught) { return javaError( request, 503, 'SERVICE_UNAVAILABLE', (caught as Failure).message === 'late' - ? 'The SBML file took too long to make' - : 'The SBML service cannot be reached' + ? `The ${format.label} file took too long to make` + : `The ${format.label} service cannot be reached` ); } const status = response.statusCode ?? 0; if (status !== 200) { const reason = await bodyText(response); if (status === 404) return notFound(request, `Identifier '${name.identifier}' not found`); - console.error(`SBML for ${event.stId}: the service answered ${status} ${reason}`); + console.error(`${format.label} for ${event.stId}: the service answered ${status} ${reason}`); return status === 503 ? { - ...javaError( - request, - 503, - 'SERVICE_UNAVAILABLE', - "The pathway's diagram cannot be had just now; try again shortly" - ), + ...javaError(request, 503, 'SERVICE_UNAVAILABLE', format.unavailable), headers: { 'Retry-After': '30' }, } : javaError( request, 500, 'INTERNAL_SERVER_ERROR', - `The SBML of ${event.stId} cannot be made` + `The ${format.label} of ${event.stId} cannot be made` ); } - response.once('close', () => SBML_SHARE.leave()); + response.once('close', () => format.share.leave()); streaming = true; return { status: 200, - type: SBML_TYPE, + type: format.type, body: response, - headers: { 'Content-Disposition': `attachment; filename="${event.stId}.sbml"` }, + headers: { + 'Content-Disposition': `attachment; filename="${event.stId}.${format.download}"`, + }, }; } finally { - if (!streaming) SBML_SHARE.leave(); + if (!streaming) format.share.leave(); } } @@ -592,11 +669,12 @@ export const exportersEndpoints: Endpoint[] = [ sample: '9626065.sbml', samplesOnly: true, /** - * An event's SBML (spec 015 T065), from the SBML service. Routed on beta - * (dev.conf, T066), locally and under ng serve; the release and - * production keep Java's until T068. + * An event's SBML (spec 015 T065) or BioPAX Level 3 (T070), from the + * library service. SBML is routed on beta (dev.conf, T066), locally and + * under ng serve; the release and production keep Java's until T068. + * BioPAX is routed by T071, and Java has no BioPAX here. */ - handler: (request) => sbmlAnswer(request, 'sbml'), + handler: (request) => eventExportAnswer(request), }, { path: '/ContentService/exporter/sbml/{id}', diff --git a/tools/content-node/parameters.ts b/tools/content-node/parameters.ts index 1cc0e763..b41dfbac 100644 --- a/tools/content-node/parameters.ts +++ b/tools/content-node/parameters.ts @@ -94,9 +94,11 @@ export const PATH_KINDS: Record = { // Never stored whatever it is (`cache` below). any: () => true, }; -// An event's SBML (spec 015): an id as `oid` takes it, and the extension. -PATH_KINDS['sbml'] = (text) => - text.endsWith('.sbml') && Boolean(PATH_KINDS['oid']?.(text.slice(0, -5))); +// An event's SBML or BioPAX (spec 015): an id as `oid` takes it, and the extension. +PATH_KINDS['eventExport'] = (text) => { + const ext = /\.(sbml|biopax)$/.exec(text); + return Boolean(ext) && Boolean(PATH_KINDS['oid']?.(text.slice(0, -(ext?.[0].length ?? 0)))); +}; PATH_KINDS['sbmlXml'] = (text) => text.endsWith('.xml') && Boolean(PATH_KINDS['oid']?.(text.slice(0, -4))); @@ -404,11 +406,11 @@ export const RECORD: Record = { query: [], cache: 'private', }, - // An event's SBML, the same file for every reader of a release (spec 015). + // An event's SBML or BioPAX, the same file for every reader of a release (spec 015). '/ContentService/exporter/event/{id}': { java: null, - why: 'Java documents it as /exporter/event/{identifier}.sbml, with includeRef and view, which it never reads: any value is the same file.', - path: { id: 'sbml' }, + why: "Java documents it as /exporter/event/{identifier}.sbml, with includeRef and view, which it never reads: any value is the same file. The .biopax form is no URL of Java's (spec 015 research R6).", + path: { id: 'eventExport' }, query: [], }, '/ContentService/exporter/sbml/{id}': { diff --git a/tools/content-node/sbml-export.spec.ts b/tools/content-node/sbml-export.spec.ts index 9371c8a4..8b65010f 100644 --- a/tools/content-node/sbml-export.spec.ts +++ b/tools/content-node/sbml-export.spec.ts @@ -9,12 +9,12 @@ import { createServer, type Server, type ServerResponse } from 'node:http'; import express from 'express'; import type { AddressInfo } from 'node:net'; import { describe, expect, it, beforeAll, afterAll, afterEach } from 'vitest'; -import { sbmlAnswer, type SbmlEvent } from './exporters-endpoints.ts'; +import { sbmlAnswer, type ExportEvent } from './exporters-endpoints.ts'; import { writeResult } from './server.ts'; import type { Answer, Request } from './endpoint.ts'; /** The graph as these cases need it: one pathway, one object that is no event. */ -const find = async (raw: string): Promise => { +const find = async (raw: string): Promise => { const id = raw.split('.')[0]; if (id === 'R-HSA-1' || id === '1') return { stId: 'R-HSA-1', isEvent: true }; if (id === '9626065') return { stId: null, isEvent: false };