diff --git a/website/src/pages/[organism]/seq/[accessionVersion].fa/index.ts b/website/src/pages/[organism]/seq/[accessionVersion].fa/index.ts new file mode 100644 index 000000000..d62cb2d55 --- /dev/null +++ b/website/src/pages/[organism]/seq/[accessionVersion].fa/index.ts @@ -0,0 +1,102 @@ +import type { APIRoute } from 'astro'; +import { err, type Result } from 'neverthrow'; + +import { getReferenceGenomes } from '../../../../config.ts'; +import { routes } from '../../../../routes.ts'; +import { LapisClient } from '../../../../services/lapisClient.ts'; +import type { ProblemDetail } from '../../../../types/backend.ts'; +import { parseAccessionVersionFromString } from '../../../../utils/extractAccessionVersion.ts'; +import { fastaEntryToString, parseFasta } from '../../../../utils/parseFasta.ts'; + +export const GET: APIRoute = async ({ params, redirect }) => { + const accessionVersion = params.accessionVersion!; + const organism = params.organism!; + + const result = await getSequenceDetailsUnalignedFasta(accessionVersion, organism); + if (!result.isOk()) { + return new Response(undefined, { + status: 404, + }); + } + + if (result.value.type === ResultType.REDIRECT) { + return redirect(result.value.redirectUrl); + } + + return new Response(result.value.fasta, { + headers: { + 'Content-Type': 'text/x-fasta', + }, + }); +}; + +enum ResultType { + DATA = 'data', + REDIRECT = 'redirect', +} + +type Data = { + type: ResultType.DATA; + fasta: string; +}; + +type Redirect = { + type: ResultType.REDIRECT; + redirectUrl: string; +}; + +const getSequenceDetailsUnalignedFasta = async ( + accessionVersion: string, + organism: string, +): Promise> => { + const { accession, version } = parseAccessionVersionFromString(accessionVersion); + + const lapisClient = LapisClient.createForOrganism(organism); + + if (version === undefined) { + const latestVersionResult = await lapisClient.getLatestAccessionVersion(accession); + return latestVersionResult.map((latestVersion) => ({ + type: ResultType.REDIRECT, + redirectUrl: routes.sequencesFastaPage(organism, latestVersion), + })); + } + + const referenceGenomes = getReferenceGenomes(organism); + const segmentNames = referenceGenomes.nucleotideSequences.map((s) => s.name); + const isMultiSegmented = segmentNames.length > 1; + + const fastaResult: Result = !isMultiSegmented + ? await lapisClient.getUnalignedSequences(accessionVersion) + : (await lapisClient.getUnalignedSequencesMultiSegment(accessionVersion, segmentNames)).map((segmentFastas) => + segmentFastas + .map((fasta, i) => { + const parsed = parseFasta(fasta); + if (parsed.length === 0) { + return ''; + } + const withSegmentSuffix = { + name: `${parsed[0].name}_${segmentNames[i]}`, + sequence: parsed[0].sequence, + }; + return fastaEntryToString([withSegmentSuffix]); + }) + .join('\n'), + ); + if (fastaResult.isOk()) { + if (fastaResult.value.trim().length === 0) { + return err({ + type: 'about:blank', + title: 'Not Found', + status: 0, + detail: 'No data found for accession version ' + accessionVersion, + instance: '/seq/' + accessionVersion + '.fa', + }); + } + } + const withNewLineTermination = fastaResult.map((fasta) => `${fasta}\n`); + + return withNewLineTermination.map((fasta) => ({ + type: ResultType.DATA, + fasta, + })); +}; diff --git a/website/src/routes.ts b/website/src/routes.ts index d556b1695..f6ff7dcf7 100644 --- a/website/src/routes.ts +++ b/website/src/routes.ts @@ -24,6 +24,8 @@ export const routes = { `/${organism}/seq/${getAccessionVersionString(accessionVersion)}`, sequencesVersionsPage: (organism: string, accessionVersion: AccessionVersion | string) => `/${organism}/seq/${getAccessionVersionString(accessionVersion)}/versions`, + sequencesFastaPage: (organism: string, accessionVersion: AccessionVersion | string) => + `${routes.sequencesDetailsPage(organism, accessionVersion)}.fa`, submitPage: (organism: string) => withOrganism(organism, '/submit'), revisePage: (organism: string) => withOrganism(organism, '/revise'), editPage: (organism: string, accessionVersion: AccessionVersion) => diff --git a/website/src/services/lapisClient.ts b/website/src/services/lapisClient.ts index 1037d5505..3aaa77826 100644 --- a/website/src/services/lapisClient.ts +++ b/website/src/services/lapisClient.ts @@ -1,5 +1,5 @@ import type { Narrow } from '@zodios/core/lib/utils.types'; -import { err, ok, type Result } from 'neverthrow'; +import { err, ok, Result } from 'neverthrow'; import { lapisApi } from './lapisApi.ts'; import { ZodiosWrapperClient } from './zodiosWrapperClient.ts'; @@ -134,4 +134,25 @@ export class LapisClient extends ZodiosWrapperClient { [this.schema.primaryKey]: accessionVersion, }); } + + public getUnalignedSequences(accessionVersion: string) { + return this.call('unalignedNucleotideSequences', { + [this.schema.primaryKey]: accessionVersion, + }); + } + + public async getUnalignedSequencesMultiSegment(accessionVersion: string, segmentNames: string[]) { + const results = await Promise.all( + segmentNames.map((segment) => + this.call( + 'unalignedNucleotideSequencesMultiSegment', + { + [this.schema.primaryKey]: accessionVersion, + }, + { params: { segment } }, + ), + ), + ); + return Result.combine(results); + } } diff --git a/website/tests/e2e.fixture.ts b/website/tests/e2e.fixture.ts index 9880816a5..b4b97e8ba 100644 --- a/website/tests/e2e.fixture.ts +++ b/website/tests/e2e.fixture.ts @@ -49,6 +49,7 @@ export const testSequenceEntry = { name: '1.1', accession: '1', version: 1, + unaligned: 'A'.repeat(123), orf1a: 'QRFEINSA', }; export const revokedSequenceEntry = { diff --git a/website/tests/pages/sequences/accession.fa.spec.ts b/website/tests/pages/sequences/accession.fa.spec.ts new file mode 100644 index 000000000..525f7bc04 --- /dev/null +++ b/website/tests/pages/sequences/accession.fa.spec.ts @@ -0,0 +1,10 @@ +import { routes } from '../../../src/routes.ts'; +import { baseUrl, dummyOrganism, expect, test, testSequenceEntry } from '../../e2e.fixture'; + +test.describe('The sequence.fa page', () => { + test('can load and show fasta file', async () => { + const url = `${baseUrl}${routes.sequencesFastaPage(dummyOrganism.key, testSequenceEntry)}`; + const content = await (await fetch(url)).text(); + expect(content).toBe(`>${testSequenceEntry.name}\n${testSequenceEntry.unaligned}\n`); + }); +}); diff --git a/website/tests/util/preprocessingPipeline.ts b/website/tests/util/preprocessingPipeline.ts index 0dc049361..1a3a86df4 100644 --- a/website/tests/util/preprocessingPipeline.ts +++ b/website/tests/util/preprocessingPipeline.ts @@ -103,7 +103,7 @@ const handleError = (error: unknown): Error => { const sequenceData = { unalignedNucleotideSequences: { - main: 'A'.repeat(123), + main: testSequenceEntry.unaligned, }, alignedNucleotideSequences: { main: 'N' + 'A'.repeat(29902),