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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ response:

An endpoint for querying survey data, as stored in `/data/stave/<version>/survey_data.parquet`.

The optional `data_release` parameter defaults to the data release that the latest model release (as configured via `config.ts`) depends on.

Note that this endpoint actually returns multiple entries per STAVE survey - that is, we have one entry per variant per STAVE survey. Thus these objects match the STAVE concept of a '[count](https://mrc-ide.github.io/STAVE/articles/howto_counts_table.html)' (which counts a particular mutation) a bit more closely than the concept of a '[survey](https://mrc-ide.github.io/STAVE/articles/howto_surveys_table.html)' (which would collect multiple genetic variants).

Example:
Expand Down Expand Up @@ -124,6 +126,8 @@ response:

An endpoint for querying model outputs, as stored in `/data/model/<version>/admin<level>.parquet`.

The optional `model_release` parameter defaults to the latest model release, as configured via `config.ts`.

The `admin_level` query parameter determines the granularity of the model outputs, while the query parameters `admin0`, `admin1` and `admin2` scope the results to a particular region. Thus for example, to request results within the `admin0` region of Mali (`MLI`), at the finest level of granularity:

request:
Expand Down
16 changes: 8 additions & 8 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import express, { type Express, type Request, type Response } from 'express';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { join } from 'node:path';
import config from './config/config.ts';
import { errorHandler } from './middlewares/errorHandler.ts';
import { globalBounds, modelVersions } from './constants.ts';
import type { QueryParams } from './types.ts';
import { validateModelRelease } from './utils/validators.ts';
import { getDataRelease, getModelRelease, readModelMetadata } from './utils/releases.ts';
import { validateSurveysRequest, validatePrevalencesRequest } from './utils/endpoints.ts';
import { executeParquetQuery } from './utils/data.ts';
import { getMutationsByGene } from './utils/metadata.ts';
Expand All @@ -14,12 +14,11 @@ export const createApp = (): Express => {
const app: Express = express();

app.get('/metadata', async (req: Request, res: Response) => {
const modelVersion = (req.query['model_release'] ?? config.latestModelVersion) as string;
if (!validateModelRelease(modelVersion, res)) return;
if (!validateModelRelease(req, res)) return;

const modelVersion = getModelRelease(req);
const mutationsByGene = await getMutationsByGene(modelVersion);
const metadataPath = pathToFileURL(resolve(config.dataDir, "model", modelVersion, "metadata.json")).href;
const { default: modelMetadata } = await import(metadataPath, { with: { type: "json" } });
const modelMetadata = await readModelMetadata(modelVersion);
const dataVersion = modelMetadata.data_release;

res.send({
Expand All @@ -39,7 +38,7 @@ export const createApp = (): Express => {
app.get('/surveys', async (req: Request, res: Response) => {
if (!validateSurveysRequest(req, res)) return;

const dataVersion = req.query['data_release'] as string;
const dataVersion = getDataRelease(req);
const surveyDataParquet = join(config.dataDir, "stave", dataVersion, "survey_data.parquet");

const result = await executeParquetQuery(req.query as QueryParams, "/surveys", surveyDataParquet, res);
Expand All @@ -51,11 +50,12 @@ export const createApp = (): Express => {
app.get('/prevalences', async (req: Request, res: Response) => {
if (!validatePrevalencesRequest(req, res)) return;

const modelVersion = getModelRelease(req);
const queryParams = req.query as QueryParams;

// Client may request results at any of the available levels of granularity.
const adminLevel = queryParams.admin_level as string;
const prevalencesParquet = join(config.dataDir, "model", queryParams.model_release!, `admin${adminLevel}.parquet`);
const prevalencesParquet = join(config.dataDir, "model", modelVersion, `admin${adminLevel}.parquet`);

const result = await executeParquetQuery(queryParams, "/prevalences", prevalencesParquet, res);
if (!result) return;
Expand Down
4 changes: 1 addition & 3 deletions src/utils/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const validateSurveysRequest = (req: Request, res: Response) => {

export const validatePrevalencesRequest = (req: Request, res: Response) => {
return validateRequiredQueryParams(req, res)
&& validateModelRelease(req.query['model_release'] as string, res)
&& validateModelRelease(req, res)
&& validateDateParams(req, res)
&& validateAdminLevel(req, res)
};
Expand Down Expand Up @@ -43,7 +43,6 @@ export interface EndpointConfig<T extends Column = Column> {
export const endpointConfigs: Record<Endpoint, EndpointConfig> = {
"/surveys": {
requiredParams: [
"data_release",
"properties",
SURVEY_COLUMNS.GENE,
SURVEY_COLUMNS.MUTATION,
Expand All @@ -56,7 +55,6 @@ export const endpointConfigs: Record<Endpoint, EndpointConfig> = {
},
"/prevalences": {
requiredParams: [
"model_release",
"admin_level",
"properties",
PREVALENCE_COLUMNS.GENE,
Expand Down
27 changes: 27 additions & 0 deletions src/utils/releases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Request } from "express";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import config from "../config/config.ts";

export interface ModelMetadata {
version: string;
data_release: string;
shapefile_source: string;
}

export const readModelMetadata = async (modelVersion: string): Promise<ModelMetadata> => {
const metadataPath = join(config.dataDir, "model", modelVersion, "metadata.json");
return JSON.parse(await readFile(metadataPath, "utf8")) as ModelMetadata;
};

export const defaultModelRelease = config.latestModelVersion;

// Each model release depends on a specific data release, so the default data release
// is the one that the default model release depends on.
export const defaultDataRelease = (await readModelMetadata(defaultModelRelease)).data_release;

export const getModelRelease = (req: Request): string =>
(req.query['model_release'] ?? defaultModelRelease) as string;

export const getDataRelease = (req: Request): string =>
(req.query['data_release'] ?? defaultDataRelease) as string;
7 changes: 4 additions & 3 deletions src/utils/validators.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type Request, type Response } from 'express';
import { modelVersions, dataVersions, adminLevels } from '../constants.ts';
import type { Column } from '../types.ts';
import { getDataRelease, getModelRelease } from './releases.ts';
import { endpointConfigs, type DateFormat, type Endpoint } from './endpoints.ts';

const dateRegexes: Record<DateFormat, RegExp> = {
Expand Down Expand Up @@ -45,7 +46,8 @@ export const validateRequestedProperties = (
// The release-version validators below are intended to guard against SQL injection
// by checking the requested version is a filepath within the relevant data directory.

export const validateModelRelease = (modelVersion: string, res: Response): boolean => {
export const validateModelRelease = (req: Request, res: Response): boolean => {
const modelVersion = getModelRelease(req);
if (!modelVersions.includes(modelVersion)) {
res.status(404).send({ error: `Unknown model release: ${modelVersion}` });
return false;
Expand All @@ -54,8 +56,7 @@ export const validateModelRelease = (modelVersion: string, res: Response): boole
};

export const validateDataRelease = (req: Request, res: Response): boolean => {
const dataVersion = req.query['data_release'] as string;

const dataVersion = getDataRelease(req);
if (!dataVersions.includes(dataVersion)) {
res.status(404).send({ error: `Unknown data release requested: ${dataVersion}` });
return false;
Expand Down
24 changes: 23 additions & 1 deletion tests/integration/prevalences.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,32 @@ describe('GET /prevalences', () => {

expect(response.status).toBe(400);
expect(response.body.error).toBe(
'Missing required query parameters: model_release, properties, gene, mutation',
'Missing required query parameters: properties, gene, mutation',
);
});

it('uses the latest model release when none is specified', async () => {
const query = { gene: baseQuery.gene, mutation: baseQuery.mutation, admin_level: '0', properties: 'admin0,date,median' };

const defaultResponse = await request(app).get('/prevalences').query(query);
const explicitResponse = await request(app)
.get('/prevalences')
.query({ ...query, model_release: fixtureConfig.modelRelease });

expect(defaultResponse.status).toBe(200);
expect(defaultResponse.body.median.length).toBeGreaterThan(0);
expect(defaultResponse.body).toEqual(explicitResponse.body);
});

it('rejects an unknown model release', async () => {
const response = await request(app)
.get('/prevalences')
.query({ ...baseQuery, model_release: '../private', admin_level: '0', properties: 'median' });

expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Unknown model release: ../private' });
});

it('rejects a containing region more granular than the requested results', async () => {
const response = await request(app)
.get('/prevalences')
Expand Down
13 changes: 13 additions & 0 deletions tests/integration/surveys.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ describe('GET /surveys', () => {
expect(response.body).toEqual({ error: 'ISO code not found: ZZZ' });
});

it('uses the data release of the latest model release when none is specified', async () => {
const query = { gene: baseQuery.gene, mutation: baseQuery.mutation, properties: pointProperties };

const defaultResponse = await request(app).get('/surveys').query(query);
const explicitResponse = await request(app)
.get('/surveys')
.query({ ...query, data_release: fixtureConfig.dataRelease });

expect(defaultResponse.status).toBe(200);
expect(defaultResponse.body.length).toBeGreaterThan(0);
expect(defaultResponse.body).toEqual(explicitResponse.body);
});

it('rejects an unknown data release', async () => {
const response = await request(app)
.get('/surveys')
Expand Down
18 changes: 14 additions & 4 deletions tests/unit/validators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,19 @@ describe('validateRequestedProperties', () => {

describe('release validators', () => {
it('accepts a known model release', () => {
const { res } = mockReqRes({});
expect(validateModelRelease(fixtureConfig.modelRelease, res)).toBe(true);
const { req, res } = mockReqRes({ model_release: fixtureConfig.modelRelease });
expect(validateModelRelease(req, res)).toBe(true);
});

it('accepts a missing model release by falling back to the default', () => {
const { req, res } = mockReqRes({});
expect(validateModelRelease(req, res)).toBe(true);
});

it('rejects an unknown model release', () => {
const { res } = mockReqRes({ model_release: '../private' });
const { req, res } = mockReqRes({ model_release: '../private' });

expect(validateModelRelease("../private", res)).toBe(false);
expect(validateModelRelease(req, res)).toBe(false);
expect(res.status).toHaveBeenCalledWith(404);
});

Expand All @@ -121,6 +126,11 @@ describe('release validators', () => {
expect(validateDataRelease(req, res)).toBe(true);
});

it('accepts a missing data release by falling back to the default', () => {
const { req, res } = mockReqRes({});
expect(validateDataRelease(req, res)).toBe(true);
});

it('rejects an unknown data release', () => {
const { req, res } = mockReqRes({ data_release: '../private' });

Expand Down
Loading