Skip to content

Commit

Permalink
refactor(website): use zod schema for validation instead of custom code
Browse files Browse the repository at this point in the history
  • Loading branch information
fengelniederhammer committed Dec 14, 2023
1 parent a376cf7 commit c5289c9
Show file tree
Hide file tree
Showing 13 changed files with 125 additions and 92 deletions.
3 changes: 2 additions & 1 deletion website/src/components/Edit/EditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Row } from './InputField.tsx';
import { getClientLogger } from '../../clientLogger.ts';
import { routes } from '../../routes.ts';
import { backendClientHooks } from '../../services/serviceHooks.ts';
import { ACCESSION_FIELD } from '../../settings.ts';
import type { MetadataRecord, ProcessingAnnotationSourceType, SequenceEntryToEdit } from '../../types/backend.ts';
import type { ClientConfig } from '../../types/runtimeConfig.ts';
import { createAuthorizationHeader } from '../../utils/createAuthorizationHeader.ts';
Expand Down Expand Up @@ -91,7 +92,7 @@ const InnerEditPage: FC<EditPageProps> = ({ organism, dataToEdit, clientConfig,
<tbody className='w-full'>
<Subtitle title='Original Data' bold />
<EditableOriginalData
editedMetadata={editedMetadata.filter(({ key }) => key !== 'accession')}
editedMetadata={editedMetadata.filter(({ key }) => key !== ACCESSION_FIELD)}
setEditedMetadata={setEditedMetadata}
/>
<EditableOriginalSequences
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
---
import { getLatestAccessionVersion } from './getTableData';
import { routes } from '../../routes.ts';
import type { SequenceEntryHistory } from '../../services/lapisClient';
import { siloVersionStatuses, type SiloVersionStatus } from '../../services/lapisClient.ts';
import { type SequenceEntryHistory, type SiloVersionStatus, siloVersionStatuses } from '../../types/lapis';
import { getAccessionVersionString } from '../../utils/extractAccessionVersion';
interface Props {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { sentenceCase } from 'change-case';
import { routes } from '../../routes';
import type { SequenceEntryHistory } from '../../services/lapisClient';
import { type SequenceEntryHistory } from '../../types/lapis';
import { getAccessionVersionString } from '../../utils/extractAccessionVersion';
import { getVersionStatusColor } from '../../utils/getVersionStatusColor';
import { BackButton } from '../Navigation/BackButton';
Expand Down
30 changes: 29 additions & 1 deletion website/src/components/SequenceDetailsPage/getTableData.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { err, ok } from 'neverthrow';
import { beforeEach, describe, expect, test } from 'vitest';

import { getTableData } from './getTableData.ts';
import { getTableData, getVersionStatus } from './getTableData.ts';
import { mockRequest, testConfig } from '../../../vitest.setup.ts';
import { LapisClient } from '../../services/lapisClient.ts';
import { VERSION_STATUS_FIELD } from '../../settings.ts';
import type { Schema } from '../../types/config.ts';
import { siloVersionStatuses } from '../../types/lapis.ts';

const schema: Schema = {
instanceName: 'instance name',
Expand Down Expand Up @@ -189,6 +191,32 @@ describe('getTableData', () => {
});
});

describe('getVersionStatus', () => {
test('should return status for correct status', () => {
const versionStatus = getVersionStatus([
{
label: 'does not matter',
name: VERSION_STATUS_FIELD,
value: siloVersionStatuses.latestVersion,
},
]);

expect(versionStatus).toStrictEqual(siloVersionStatuses.latestVersion);
});

test('should throw error for unknown status', () => {
expect(() =>
getVersionStatus([
{
label: 'does not matter',
name: VERSION_STATUS_FIELD,
value: 'unknown status',
},
]),
).toThrowError(/Invalid version status: "unknown status"/);
});
});

const nucleotideMutations = [
{ count: 0, proportion: 0, mutation: 'nucleotideMutation1' },
{ count: 0, proportion: 0, mutation: 'nucleotideDeletion1-' },
Expand Down
32 changes: 16 additions & 16 deletions website/src/components/SequenceDetailsPage/getTableData.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { sentenceCase } from 'change-case';
import { err, Result } from 'neverthrow';

import { type LapisClient } from '../../services/lapisClient.ts';
import { VERSION_STATUS_FIELD } from '../../settings.ts';
import type { AccessionVersion, ProblemDetail } from '../../types/backend.ts';
import type { Schema } from '../../types/config.ts';
import {
isSiloVersionStatus,
type LapisClient,
type Details,
type DetailsResponse,
type InsertionCount,
type MutationProportionCount,
type SequenceEntryHistory,
type SiloVersionStatus,
siloVersionStatuses,
} from '../../services/lapisClient.ts';
import type { AccessionVersion, ProblemDetail } from '../../types/backend.ts';
import type { Schema } from '../../types/config.ts';
import type { Details, DetailsResponse, InsertionCount, MutationProportionCount } from '../../types/lapis.ts';
siloVersionStatusSchema,
} from '../../types/lapis.ts';

export type TableDataEntry = { label: string; name: string; value: string | number };

Expand Down Expand Up @@ -50,18 +53,15 @@ export async function getTableData(
}

export function getVersionStatus(tableData: TableDataEntry[]): SiloVersionStatus {
const versionStatus = tableData.find((pred) => pred.name === 'versionStatus')?.value.toString() ?? undefined;
const versionStatus = tableData.find((pred) => pred.name === VERSION_STATUS_FIELD)?.value.toString() ?? undefined;

if (isSiloVersionStatus(versionStatus) === false) {
throw new Error(
'Invalid version status: ' +
JSON.stringify(versionStatus) +
' not in ' +
JSON.stringify(Object.values(siloVersionStatuses)),
);
const parsedStatus = siloVersionStatusSchema.safeParse(versionStatus);

if (parsedStatus.success) {
return parsedStatus.data;
}

return versionStatus as SiloVersionStatus;
throw new Error('Invalid version status: ' + JSON.stringify(versionStatus) + ': ' + parsedStatus.error.toString());
}

export function getLatestAccessionVersion(sequenceEntryHistory: SequenceEntryHistory): AccessionVersion | undefined {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import {
} from '../../../../components/SequenceDetailsPage/getTableData.ts';
import { getSchema } from '../../../../config.ts';
import { routes } from '../../../../routes.ts';
import { LapisClient, type SequenceEntryHistory, type SiloVersionStatus } from '../../../../services/lapisClient.ts';
import { LapisClient } from '../../../../services/lapisClient.ts';
import type { ProblemDetail } from '../../../../types/backend.ts';
import type { SequenceEntryHistory, SiloVersionStatus } from '../../../../types/lapis.ts';
import { parseAccessionVersionFromString } from '../../../../utils/extractAccessionVersion.ts';

export enum SequenceDetailsTableResultType {
Expand Down
79 changes: 22 additions & 57 deletions website/src/services/lapisClient.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,16 @@
import type { Narrow } from '@zodios/core/lib/utils.types';
import { err, ok, Result } from 'neverthrow';
import { z } from 'zod';
import { err, ok, type Result } from 'neverthrow';

import { lapisApi } from './lapisApi.ts';
import { ZodiosWrapperClient } from './zodiosWrapperClient.ts';
import { getLapisUrl, getRuntimeConfig, getSchema } from '../config.ts';
import { getInstanceLogger, type InstanceLogger } from '../logger.ts';
import { ACCESSION_FIELD, VERSION_FIELD, VERSION_STATUS_FIELD } from '../settings.ts';
import { accessionVersion, type AccessionVersion, type ProblemDetail } from '../types/backend.ts';
import type { Schema } from '../types/config.ts';
import { sequenceEntryHistory, type SequenceEntryHistory, siloVersionStatuses } from '../types/lapis.ts';
import type { BaseType } from '../utils/sequenceTypeHelpers.ts';

export const siloVersionStatuses = {
revoked: 'REVOKED',
revised: 'REVISED',
latestVersion: 'LATEST_VERSION',
} as const;

export type SiloVersionStatus = (typeof siloVersionStatuses)[keyof typeof siloVersionStatuses];
export function isSiloVersionStatus(status: string | undefined | null): status is SiloVersionStatus {
if (status === undefined || status === null) {
return false;
}
return Object.values(siloVersionStatuses).includes(status as SiloVersionStatus);
}

export type SequenceEntryHistory = SequenceEntryHistoryEntry[];

const sequenceEntryHistoryEntry = accessionVersion.merge(
z.object({
versionStatus: z.string().refine((status) => isSiloVersionStatus(status), {
message: `Invalid version status`,
}) as z.ZodType<SiloVersionStatus>,
}),
);

type SequenceEntryHistoryEntry = z.infer<typeof sequenceEntryHistoryEntry>;

export class LapisClient extends ZodiosWrapperClient<typeof lapisApi> {
constructor(
url: string,
Expand Down Expand Up @@ -71,44 +46,40 @@ export class LapisClient extends ZodiosWrapperClient<typeof lapisApi> {
const result = await this.call('details', {
accession,
versionStatus: siloVersionStatuses.latestVersion,
fields: ['accession', 'version'],
fields: [ACCESSION_FIELD, VERSION_FIELD],
});

if (result.isOk()) {
const data = result.value.data;
return result.andThen(({ data }) => {
if (data.length !== 1) {
const problemDetail: ProblemDetail = {
return err({
type: 'about:blank',
title: 'Unexpected number of results',
detail: `Expected 1 result, got ${data.length}`,
status: 500,
instance: 'LapisClient/getLatestAccessionVersion',
};
return err(problemDetail);
});
}
const parsedAccessionversion = accessionVersion.safeParse(data[0]);
if (!parsedAccessionversion.success) {
const problemDetail: ProblemDetail = {
return err({
type: 'about:blank',
title: 'Could not parse accession version',
detail: `Expected 1 result, got ${data.length}`,
status: 500,
instance: 'LapisClient/getLatestAccessionVersion',
};
return err(problemDetail);
});
}
return ok(parsedAccessionversion.data);
}
return result;
});
}

public async getAllSequenceEntryHistoryForAccession(
accession: string,
): Promise<Result<SequenceEntryHistory, ProblemDetail>> {
const result = await this.call('details', {
accession,
fields: ['accession', 'version', 'versionStatus'],
orderBy: ['version'],
fields: [ACCESSION_FIELD, VERSION_FIELD, VERSION_STATUS_FIELD],
orderBy: [VERSION_FIELD],
});

const createSequenceHistoryProblemDetail = (detail: string): ProblemDetail => ({
Expand All @@ -119,22 +90,16 @@ export class LapisClient extends ZodiosWrapperClient<typeof lapisApi> {
detail,
});

return result.match(
(data) =>
Result.combine(
data.data.map((entry) => {
const parsedHistory = sequenceEntryHistoryEntry.safeParse(entry);
return parsedHistory.success
? ok(parsedHistory.data)
: err(
createSequenceHistoryProblemDetail(
`Validation error for ${accession}: ${parsedHistory.error.errors}`,
),
);
}),
),
(error) => err(error),
);
return result.andThen(({ data }) => {
const parseResult = sequenceEntryHistory.safeParse(data);
return parseResult.success
? ok(parseResult.data)
: err(
createSequenceHistoryProblemDetail(
`Validation error for ${accession}: ${parseResult.error.toString()}`,
),
);
});
}

public getSequenceMutations(accessionVersion: string, type: BaseType) {
Expand Down
7 changes: 5 additions & 2 deletions website/src/services/zodiosWrapperClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Zodios, type ZodiosEndpointDefinitions, type ZodiosInstance } from '@zo
import type { Narrow } from '@zodios/core/lib/utils.types';
import type { Aliases, ZodiosAliases } from '@zodios/core/lib/zodios.types';
import type { AxiosError, AxiosResponse } from 'axios';
import { type Err, err, ok } from 'neverthrow';
import { type Err, err, ok, type Result } from 'neverthrow';

import { type InstanceLogger } from '../logger.ts';
import { problemDetail, type ProblemDetail } from '../types/backend.ts';
Expand All @@ -29,7 +29,10 @@ export class ZodiosWrapperClient<Api extends ZodiosEndpointDefinitions> {
this.zodios = new Zodios(url, api);
}

public call<Method extends ZodiosMethods<Api>>(method: Method, ...args: ZodiosMethod<Api, Method>['parameters']) {
public call<Method extends ZodiosMethods<Api>>(
method: Method,
...args: ZodiosMethod<Api, Method>['parameters']
): Promise<Result<Awaited<ZodiosMethod<Api, Method>['response']>, ProblemDetail>> {
const zodiosMethod = this.zodios[method] as ZodiosAliases<Api>[Method];
const zodiosResponse = zodiosMethod(...(args as TypeThatCanBeUsedAsArgs)) as ZodiosMethod<
Api,
Expand Down
11 changes: 9 additions & 2 deletions website/src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { siloVersionStatuses } from './types/lapis.ts';

export const pageSize = 100;

export const ACCESSION_FIELD = 'accession';
export const VERSION_FIELD = 'version';
export const VERSION_STATUS_FIELD = 'versionStatus';
export const IS_REVOCATION_FIELD = 'isRevocation';

export const hiddenDefaultSearchFilters = [
{ name: 'versionStatus', filterValue: 'LATEST_VERSION', type: 'string' as const },
{ name: 'isRevocation', filterValue: 'false', type: 'string' as const },
{ name: VERSION_STATUS_FIELD, filterValue: siloVersionStatuses.latestVersion, type: 'string' as const },
{ name: IS_REVOCATION_FIELD, filterValue: 'false', type: 'string' as const },
];
26 changes: 25 additions & 1 deletion website/src/types/lapis.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import z, { type ZodTypeAny } from 'zod';

import type { ProblemDetail } from './backend.ts';
import { accessionVersion, type ProblemDetail } from './backend.ts';

export const lapisBaseRequest = z
.object({
Expand Down Expand Up @@ -50,3 +50,27 @@ function makeLapisResponse<T extends ZodTypeAny>(data: T) {
export type LapisError = {
error: ProblemDetail;
};

export const siloVersionStatuses = {
revoked: 'REVOKED',
revised: 'REVISED',
latestVersion: 'LATEST_VERSION',
} as const;

export const siloVersionStatusSchema = z.enum([
siloVersionStatuses.revoked,
siloVersionStatuses.revised,
siloVersionStatuses.latestVersion,
]);

export type SiloVersionStatus = z.infer<typeof siloVersionStatusSchema>;

export const sequenceEntryHistory = z.array(
accessionVersion.merge(
z.object({
versionStatus: siloVersionStatusSchema,
}),
),
);

export type SequenceEntryHistory = z.infer<typeof sequenceEntryHistory>;
4 changes: 2 additions & 2 deletions website/src/utils/getVersionStatusColor.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { type SiloVersionStatus } from '../services/lapisClient.ts';
import { type SiloVersionStatus, siloVersionStatuses } from '../types/lapis.ts';

export const getVersionStatusColor = (versionStatus: SiloVersionStatus) => {
switch (versionStatus) {
case 'LATEST_VERSION':
case siloVersionStatuses.latestVersion:
return 'text-green-500';
default:
return 'text-red-500';
Expand Down
14 changes: 9 additions & 5 deletions website/tests/playwrightSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import sortBy from 'lodash/sortBy.js';

import { e2eLogger, getToken, lapisUrl, testUser, testUserPassword } from './e2e.fixture.ts';
import { prepareDataToBe } from './util/prepareDataToBe.ts';
import { LapisClient, siloVersionStatuses } from '../src/services/lapisClient.ts';
import { LapisClient } from '../src/services/lapisClient.ts';
import { ACCESSION_FIELD, IS_REVOCATION_FIELD, VERSION_FIELD, VERSION_STATUS_FIELD } from '../src/settings.ts';
import { siloVersionStatuses } from '../src/types/lapis.ts';

enum LapisStateBeforeTests {
NoSequencesInLapis = 'NoSequencesInLapis',
Expand Down Expand Up @@ -73,7 +75,7 @@ async function checkLapisState(lapisClient: LapisClient): Promise<LapisStateBefo
return LapisStateBeforeTests.NoSequencesInLapis;
}

const fields = ['accession', 'version', 'versionStatus', 'isRevocation'];
const fields = [ACCESSION_FIELD, VERSION_FIELD, VERSION_STATUS_FIELD, IS_REVOCATION_FIELD];
const [
shouldBeLatestVersionResult,
// When SILO can process revocation_entries, we expect two versions.
Expand All @@ -85,12 +87,14 @@ async function checkLapisState(lapisClient: LapisClient): Promise<LapisStateBefo
lapisClient.call('details', { accession: '21', fields }),
]);

const shouldBeLatestVersionAndNotRevoked = sortBy(shouldBeLatestVersionResult._unsafeUnwrap().data, ['version']);
const shouldBeLatestVersionAndNotRevoked = sortBy(shouldBeLatestVersionResult._unsafeUnwrap().data, [
VERSION_FIELD,
]);
const shouldBeTwoVersionsAndOneRevoked = sortBy(shouldBeTwoVersionsAndOneRevokedResult._unsafeUnwrap().data, [
'version',
VERSION_FIELD,
]);
const shouldBeTwoVersionsAndOneRevised = sortBy(shouldBeTwoVersionsAndOneRevisedResult._unsafeUnwrap().data, [
'version',
VERSION_FIELD,
]);

const expectedLatestVersion = [
Expand Down
Loading

0 comments on commit c5289c9

Please sign in to comment.