diff --git a/.github/workflows/lint.yml b/.github/workflows/ci.yml similarity index 68% rename from .github/workflows/lint.yml rename to .github/workflows/ci.yml index f8d889e..20f2c15 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: lint and type check +name: CI on: push: @@ -29,3 +29,15 @@ jobs: cache: npm - run: npm ci - run: npm run lint + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm test diff --git a/.gitignore b/.gitignore index ddc3c2a..e00417f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .env node_modules/ dist/ +data/admin0-region-metadata.json +data/**/*.parquet diff --git a/README.md b/README.md new file mode 100644 index 0000000..cabff4a --- /dev/null +++ b/README.md @@ -0,0 +1,212 @@ +# PARAmap API + +This repo contains an API serving two kinds of data to be rendered by PARAmap: + +1) data points of surveys of genetic markers, which come to us stored in the [STAVE](https://mrc-ide.github.io/STAVE/index.html) schema; +2) and a surface of model outputs imputed from the survey data, which are essentially interpolated prevalences of the difference genetic markers per region. These are provided at three levels of granularity: admin levels 0, 1, and 2. + +In general, when new releases of model outputs or of survey data are created, this is treated additively: that is, we will intentionally keep around older versions. These may be requested using query parameters `model_release`/`data_release`. Each model release has a dependency on a specific data release. + +## Endpoints + +There are three endpoints, all read-only, which provide customisable slices of the data, which is read from parquet files. + +The `/surveys` and `/prevalences` endpoints correspond to the two kinds of data referred to above. They share a common request format* whereby the query parameter `properties` specifies which parquet columns should be returned per entry, while several other query parameters are used to filter the data. To a first approximation, this is translated into an SQL query of the form `SELECT FROM WHERE `, though not all filters are expressible as `WHERE` clauses (e.g. they may instead entail reading a different source parquet file). By the use of these query parameters, we enable clients to flexibly thin the response sizes to precisely those rows and columns that are required. + +*This is controlled by the const `endpointConfigs` in `dataHelpers.ts`. + +1. /metadata + +This endpoint returns: +- All available model releases +- The global/initial bounding box for the map +- Metadata pertaining to a specific model release (this is specified by an optional `model_release` parameter, which defaults to latest, as configured via `config.ts`): + - The model release label ('version') + - The corresponding data release for the model release + - The available genes for the model release, each with their available 'mutations' (encoding position and allele), and the range of dates for which prevalence is modelled for each mutation. + +Example: + +request: +`GET /metadata` + +response: +```json +{ + "model_releases": ["v1", "v2"], + "prevalences": { + "version": "v1", + "data_release": "v1.0.0", + "variants": [ + { + "gene": "k13", + "mutations": [ + { + "mutation": "469Y", + "date_range": { + "start": "2004-05-01", + "end": "2030-09-01", + }, + }, + { + "mutation": "469F", + "date_range": { + "start": "2004-05-01", + "end": "2030-09-01", + }, + }, + ], + }, + { + "gene": "crt", + "mutations": [ + { + "mutation": "76K", + "date_range": { + "start": "2004-05-01", + "end": "2030-09-01", + }, + }, + ], + } + ] + }, + "bounds": { + "min": { + "lng": -70.0635, + "lat": 12.4124 + }, + "max": { + "lng": -69.8654, + "lat": 12.624 + } + }, +} +``` + +2. /surveys + +An endpoint for querying survey data, as stored in `/data/stave//survey_data.parquet`. + +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: + +request: +``` +GET /surveys? + &data_release=v1.0.0 + &date_from=2010-01-01 + &date_to=2010-02-01 + &gene=k13 + &mutation=469Y + &properties=survey_id,lat,lng,collection_day,denominator +``` + +response: +```json +[ + { + "survey_id": "Dama_2017_Bamako_2014", + "lat": 12.612900, + "lng": -8.13560, + "collection_day": "2010-01-15", + "denominator": 130, + }, + // ... +] +``` + +3. /prevalences + +An endpoint for querying model outputs, as stored in `/data/model//admin.parquet`. + +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: +``` +GET /prevalences? + &model_release=v2 + &admin_level=2 + &admin0=MLI + &gene=k13 + &mutation=469Y + &date=2024-05-01 + &properties=median,admin2 +``` + +response: +```json +[ + { + "admin2": "MLI.1.1_1", + "median": 0.76470588235 + }, + // ... +] +``` + + +## First-time development set-up + +1. Process STAVE data + +```sh +Rscript ./scripts/process_stave.R 2026.03.17 +``` + +2. Generate example model outputs + +Currently, we generate example model outputs using a script. These example outputs are partly based on the (real) STAVE data. + +```sh +Rscript scripts/create_example_model_outputs.R +``` + +3. Fetch admin0 region metadata from Grout + +```sh +ts-node --esm scripts/fetch_admin0_region_metadata.ts +``` + +4. Optionally run tests + +```sh +npm run test +``` + +5. Start the app + +```sh +npm run dev +``` + +## How to update the data + +NB The list of in-scope genes and mutations will vary over time, with model releases (rather than with STAVE data releases); thus it is not something to hard-code as a constant. Every model release has a dependency on one STAVE data release. + +### STAVE data + +When a new STAVE data release is provided, it should be given a version name e.g. "2026.03.17", and committed in `scripts/input/stave//stave_data.rds`. Then, run the [process_stave.R](./scripts/process_stave.R) script: + +```sh +Rscript ./scripts/process_stave.R 2026.03.17 +``` + +This will create `./data/stave//survey_data.parquet`. + +### Model outputs + +As mentioned above, early development has used example model outputs generated by a script. We will at some point have access to real model outputs. Once these are provided, we can get rid of the tooling that creates example model outputs. We may then still need to do some amount of transformation to wrangle the data into the preferred format or filetype; this transformation step should take the form of a new script, akin to `./scripts/process_stave.R`. + +As things stand now, a file `./data/model//metadata.json` must be manually created, to document the dependency of the model outputs (example or real) on a particular STAVE release. + +## Data schema details + +### Genes and mutations + +The [variantstring](https://github.com/mrc-ide/variantstring) format encodes genetic variants in three components: the gene, the locus (position), and the amino acid (sometimes written as "aa"). As far as the app is concerned, however, the variant is composed more simply of two parts: the gene (which exactly corresponds to the variantstring concept of a gene) and the mutation (which fuses the locus and amino acid. Technically we could be more accurate by calling this an 'allele' since 'mutation' implies deviation from a reference allele, and some of the variants are reference alleles). In pre-processing, derived columns for gene and mutation are appended to the STAVE data, to enable this data to be queried by variant. + +### Dates + +Model outputs ('prevalences') will be provided per-month, which we encode as the first of each month. Unlike prevalences, the dates of surveys are not snapped to the first of the month, but can be any day. diff --git a/data/model/.gitkeep b/data/model/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/model/2026.05.08/metadata.json b/data/model/2026.05.08/metadata.json new file mode 100644 index 0000000..3c1125b --- /dev/null +++ b/data/model/2026.05.08/metadata.json @@ -0,0 +1,4 @@ +{ + "version": "2026.05.08", + "data_release": "2026.03.17" +} diff --git a/data/stave/.gitkeep b/data/stave/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index 616f571..0b3dbcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,19 +9,23 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@duckdb/node-api": "^1.5.5-r.2", "dotenv": "^17.4.2", "express": "^5.2.1" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/express": "^5.0.6", - "@types/node": "^26.1.2", + "@types/node": "^24.0.0", + "@types/supertest": "^7.2.1", "eslint": "^10.8.0", "nodemon": "^3.1.14", "prettier": "^3.9.6", + "supertest": "^7.2.2", "ts-node": "^10.9.2", "typescript": "^6.0.3", - "typescript-eslint": "^8.65.0" + "typescript-eslint": "^8.65.0", + "vitest": "^4.1.11" }, "engines": { "node": ">=24.0.0" @@ -40,6 +44,138 @@ "node": ">=12" } }, + "node_modules/@duckdb/node-api": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-api/-/node-api-1.5.5-r.2.tgz", + "integrity": "sha512-PhpjvblIQV36IlnWGOWcLCVhCBPALJ+gu2d4MR5VIveVS57Bx1Hpvoe9BZqPcMzMeqWc3sQOpcr8DoZ/hKB2PQ==", + "license": "MIT", + "dependencies": { + "@duckdb/node-bindings": "1.5.5-r.2" + } + }, + "node_modules/@duckdb/node-bindings": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings/-/node-bindings-1.5.5-r.2.tgz", + "integrity": "sha512-sdLrfvfOxhFA6igCMiToaYSYaffd7D5j00ooy7Y1ACJ1PZKTVENwHV91RfECyNrlGGZItNbfqVR/yHL1jiHMcQ==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "optionalDependencies": { + "@duckdb/node-bindings-darwin-arm64": "1.5.5-r.2", + "@duckdb/node-bindings-darwin-x64": "1.5.5-r.2", + "@duckdb/node-bindings-linux-arm64": "1.5.5-r.2", + "@duckdb/node-bindings-linux-arm64-musl": "1.5.5-r.2", + "@duckdb/node-bindings-linux-x64": "1.5.5-r.2", + "@duckdb/node-bindings-linux-x64-musl": "1.5.5-r.2", + "@duckdb/node-bindings-win32-arm64": "1.5.5-r.2", + "@duckdb/node-bindings-win32-x64": "1.5.5-r.2" + } + }, + "node_modules/@duckdb/node-bindings-darwin-arm64": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-darwin-arm64/-/node-bindings-darwin-arm64-1.5.5-r.2.tgz", + "integrity": "sha512-huZfnM/awM62hpPlK7ckx0u0twW3zc00Bs0qfY8ldhjwVn+Jf764pxFdMmUujYWtMdsSZIk4FvMy8ZQijALOPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@duckdb/node-bindings-darwin-x64": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-darwin-x64/-/node-bindings-darwin-x64-1.5.5-r.2.tgz", + "integrity": "sha512-/TlqNBOqg2EbBJ2MjVDkJg6IKQHE1SkCC41cn4e5CaJVxj7re1A77W6o1V7Xa/25jftO1HxvGBlHGP15dmjMxQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@duckdb/node-bindings-linux-arm64": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-arm64/-/node-bindings-linux-arm64-1.5.5-r.2.tgz", + "integrity": "sha512-hlyuOvjOShhgsTguCGv9A6M5QZf9NiFW/fmq9aZO7lhlz0P0cBVOiCca+CAhcnePrbZmzeGZ21fwiBs6T12StA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-linux-arm64-musl": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-arm64-musl/-/node-bindings-linux-arm64-musl-1.5.5-r.2.tgz", + "integrity": "sha512-78lhr3GBzscZXA3iHqOIIo5+zRbtSA+lNHUBHZPLUICn0+boeGic4JP/KE3Y2MTNAe4OqG4ctfl8CX1tBArjQA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-linux-x64": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-x64/-/node-bindings-linux-x64-1.5.5-r.2.tgz", + "integrity": "sha512-e8SypIiYX3wzvB2CfEr3zTfoCOqkqt0zvcc025ukVno95W+hnH37NJOEdhC7RfSCEwgaPrkWGChhZ3x3EVZsTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-linux-x64-musl": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-x64-musl/-/node-bindings-linux-x64-musl-1.5.5-r.2.tgz", + "integrity": "sha512-eKvMBSEyd3Vq4CiPGTXAb4Ww+l9XPuTR3WzXbHQMwJjEEGbnFhnHaqjtLyr3Chqg0iDWe41empP3IC7XWPBpiQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-win32-arm64": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-win32-arm64/-/node-bindings-win32-arm64-1.5.5-r.2.tgz", + "integrity": "sha512-87wuGkEjc4VwcDZI09rSm6FXc8F9oHZWYE7so8p2l4qtmGCfsoLwSSlnhi9MHGefGqUBxshfJ0O9Zvg7A0//ag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@duckdb/node-bindings-win32-x64": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-win32-x64/-/node-bindings-win32-x64-1.5.5-r.2.tgz", + "integrity": "sha512-r5V6Q0zcv5HSHGDXsd6M+t3jakhm6S11TNH5vydKGeq8JBWj4v3ZTof/mF3R8Rly+90Z205KoI9ujblg/jN04g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -262,6 +398,308 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -301,6 +739,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -311,6 +760,20 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -364,15 +827,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "26.1.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", - "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/qs": { @@ -410,6 +880,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", @@ -641,6 +1135,119 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -729,6 +1336,30 @@ "dev": true, "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -853,6 +1484,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -891,6 +1532,29 @@ "node": ">= 6" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -913,6 +1577,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -931,6 +1602,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -977,6 +1655,16 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -986,6 +1674,26 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -1055,6 +1763,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1067,6 +1782,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1232,6 +1963,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1251,6 +1992,16 @@ "node": ">= 0.6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -1315,6 +2066,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -1400,6 +2158,64 @@ "dev": true, "license": "ISC" }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1526,6 +2342,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1696,28 +2528,289 @@ "dev": true, "license": "MIT" }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/locate-path": { @@ -1736,6 +2829,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -1777,6 +2880,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -1824,6 +2950,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -1891,6 +3036,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2001,6 +3160,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", @@ -2014,6 +3187,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2127,6 +3329,40 @@ "node": ">=8.10.0" } }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2308,6 +3544,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -2321,6 +3564,23 @@ "node": ">=10" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2330,6 +3590,49 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2343,6 +3646,23 @@ "node": ">=4" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2392,6 +3712,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2572,9 +3902,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -2613,6 +3943,200 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2629,6 +4153,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 3aa5021..6fd2798 100644 --- a/package.json +++ b/package.json @@ -5,30 +5,36 @@ "license": "ISC", "author": "", "type": "module", - "main": "app.js", + "main": "server.js", "scripts": { - "build": "tsc", - "start": "node dist/app.js", - "dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/app.ts", - "lint": "eslint 'src/**/*.ts'", - "test": "echo \"Error: no test specified\" && exit 1", + "build": "tsc -p tsconfig.build.json", + "start": "node dist/server.js", + "dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/server.ts", + "lint": "eslint 'src/**/*.ts' 'tests/**/*.ts' 'vitest.config.ts'", + "test": "vitest run", + "test:watch": "vitest", + "generate-test-fixtures": "TS_NODE_TRANSPILE_ONLY=true node --loader ts-node/esm tests/fixtures/generate-test-fixtures.ts", "docker-build-image": "docker build -t paramap-api .", "docker-run": "docker run -e PORT=3000 -p 3000:3000 paramap-api" }, "dependencies": { + "@duckdb/node-api": "^1.5.5-r.2", "dotenv": "^17.4.2", "express": "^5.2.1" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/express": "^5.0.6", - "@types/node": "^26.1.2", + "@types/node": "^24.0.0", + "@types/supertest": "^7.2.1", "eslint": "^10.8.0", "nodemon": "^3.1.14", "prettier": "^3.9.6", + "supertest": "^7.2.2", "ts-node": "^10.9.2", "typescript": "^6.0.3", - "typescript-eslint": "^8.65.0" + "typescript-eslint": "^8.65.0", + "vitest": "^4.1.11" }, "engines": { "node": ">=24.0.0" diff --git a/scripts/create_example_model_outputs.R b/scripts/create_example_model_outputs.R new file mode 100644 index 0000000..941138f --- /dev/null +++ b/scripts/create_example_model_outputs.R @@ -0,0 +1,301 @@ +# This script generates example model output data for +# testing and development purposes. +# It creates Parquet files for admin0, admin1, and admin2 levels with +# simulated prevalence data for a set of genetic variants over time. +# Once we have real model outputs, we can remove this script +# and use the real data instead. + +library(arrow) +library(cli) +library(dplyr) +library(here) +library(jsonlite) +library(purrr) +library(tidyr) +library(variantstring) + +set.seed(20260508) + +output_dir <- here("data", "model", "2026.05.08") +dir.create(output_dir, recursive = TRUE, showWarnings = FALSE) + +stave_file <- here("scripts", "input", "stave", "2026.03.17", "stave_data.rds") +if (!file.exists(stave_file)) { + cli_abort("Input file not found: {.file {stave_file}}.") +} + +stave_obj <- readRDS(stave_file) +survey_ids <- stave_obj$get_surveys() |> + pull(survey_id) |> + unique() + +if (length(survey_ids) == 0) { + cli_abort("No survey IDs found in {.file {stave_file}}.") +} + +variants <- c( + "crt:76:K", + "crt:76:T", + "k13:441:L", + "k13:441:P", + "k13:446:F", + "k13:446:I", + "k13:458:N", + "k13:469:C", + "k13:469:F", + "k13:469:Y", + "k13:476:I", + "k13:476:M", + "k13:493:H", + "k13:493:Y", + "k13:537:N", + "k13:538:G", + "k13:539:R", + "k13:539:T", + "k13:543:I", + "k13:553:L", + "k13:553:P", + "k13:561:H", + "k13:561:R", + "k13:568:G", + "k13:568:V", + "k13:574:L", + "k13:574:P", + "k13:580:C", + "k13:580:Y", + "k13:622:I", + "k13:622:R", + "k13:675:A", + "k13:675:V", + "mdr1:86:N", + "mdr1:86:Y" +) + +parsed_list <- variant_to_long(variants) +n_rows <- vapply(parsed_list, nrow, integer(1)) +if (any(n_rows != 1)) { + invalid_variants <- variants[n_rows != 1] + cli_abort(c( + "Expected each variant to parse to exactly 1 row via {.fn variant_to_long}, but got unexpected row counts for: {.val {unique(invalid_variants)}}.", + "x" = "The variant string might not be single-locus." + )) +} + +parsed_variants <- bind_rows(Map( + function(parsed_variant, variant) mutate(parsed_variant, variant = variant), + parsed_list, + variants +)) |> + transmute(variant, gene, mutation = paste0(pos, aa)) + +fetch_json <- function(url) { + response_text <- tryCatch(readLines(url, warn = FALSE), error = function(e) NULL) + if (is.null(response_text)) { + cli_abort("Could not read endpoint: {.url {url}}.") + } + fromJSON(paste(response_text, collapse = "\n"), simplifyDataFrame = TRUE) +} + +extract_data <- function(response, url) { + if (!is.list(response) || is.null(response$data)) { + cli_abort("Unexpected response structure from {.url {url}}.") + } + response$data +} + +admin0_url <- "https://mrcdata.dide.ic.ac.uk/grout/region-metadata/gadm41/admin0" +admin0_resp <- fetch_json(admin0_url) +admin0_df <- extract_data(admin0_resp, admin0_url) + +subsaharan_africa_iso <- c( + "AGO", "BDI", "BEN", "BFA", "BWA", "CAF", "CIV", "CMR", "COD", "COG", + "COM", "CPV", "DJI", "ERI", "ETH", "GAB", "GHA", "GIN", "GMB", "GNB", + "GNQ", "KEN", "LBR", "LSO", "MDG", "MLI", "MOZ", "MRT", "MUS", "MWI", + "NAM", "NER", "NGA", "RWA", "SDN", "SEN", "SLE", "SOM", "SSD", "STP", + "SWZ", "SYC", "TCD", "TGO", "TZA", "UGA", "ZAF", "ZMB", "ZWE" +) + +admin0_regions <- admin0_df |> + filter(id %in% subsaharan_africa_iso) |> + transmute(admin0 = id) + +fetch_country_level <- function(level, iso3_code) { + url <- sprintf("https://mrcdata.dide.ic.ac.uk/grout/region-metadata/gadm41/admin%d/%s", level, iso3_code) + response <- fetch_json(url) + region_data <- extract_data(response, url) + if (!is.data.frame(region_data) || nrow(region_data) == 0) { + return(tibble()) + } + if (level == 1) { + return(tibble(admin0 = iso3_code, admin1 = region_data$id)) + } + if (level == 2) { + admin1_from_admin2 <- sub("_[0-9]+$", "", region_data$id) + return(tibble(admin0 = iso3_code, admin1 = admin1_from_admin2, admin2 = region_data$id)) + } + cli_abort("Unsupported admin level: {level}.") +} + +admin1_regions <- map_dfr(subsaharan_africa_iso, ~fetch_country_level(1, .x)) |> distinct(admin0, admin1) +admin2_regions <- map_dfr(subsaharan_africa_iso, ~fetch_country_level(2, .x)) |> distinct(admin0, admin1, admin2) + +if (nrow(admin1_regions) == 0 || nrow(admin2_regions) == 0) { + cli_abort("Failed to collect admin1/admin2 regions from grout endpoint.") +} + +make_variant_months <- function(variant_values) { + # Force a subset of variants to span the full historical range so the + # generated example data always includes older start dates in meaningful volume. + n_forced <- max(1L, ceiling(length(variant_values) / 4)) + forced_variants <- variant_values[seq_len(n_forced)] + + forced_extremes <- purrr::map_dfr(forced_variants, function(variant) { + tibble( + variant = variant, + date = seq.Date(as.Date("1970-01-01"), as.Date("2030-12-01"), by = "month") + ) + }) + + sampled <- purrr::map_dfr(variant_values, function(variant) { + start_year <- sample(1970:2029, size = 1, prob = dnorm(1970:2029, mean = 2005, sd = 9)) + end_year <- sample(start_year:2030, size = 1, prob = dnorm(start_year:2030, mean = 2026, sd = 4)) + start_month <- sample(1:12, size = 1) + end_month <- sample(1:12, size = 1) + + start_date <- as.Date(sprintf("%04d-%02d-01", start_year, start_month)) + end_date <- as.Date(sprintf("%04d-%02d-01", end_year, end_month)) + if (end_date < start_date) { + end_date <- as.Date(sprintf("%04d-%02d-01", start_year, min(12, start_month + sample(1:6, 1)))) + } + months <- seq.Date(start_date, end_date, by = "month") + tibble(variant = variant, date = months) + }) + + bind_rows(sampled, forced_extremes) |> distinct(variant, date) +} + +variant_months <- make_variant_months(variants) + +build_level_chunk <- function(level, regions_tbl) { + base <- tidyr::crossing( + regions_tbl, + variant = variants + ) |> + # Each variant is duplicated on both sides (once per region on the left, + # once per month on the right), so this fan-out join is intentional. + left_join(variant_months, by = "variant", relationship = "many-to-many") |> + left_join(parsed_variants, by = "variant") + + row_count <- nrow(base) + + mean_prevalence <- rbeta(row_count, shape1 = 2.5, shape2 = 5.5) + prevalence_sd <- pmax(0.003, pmin(0.18, rnorm(row_count, mean = 0.045, sd = 0.02))) + median_prevalence <- pmin(1, pmax(0, mean_prevalence + rnorm(row_count, 0, prevalence_sd / 3))) + lower_95 <- pmax(0, mean_prevalence - 1.96 * prevalence_sd) + upper_95 <- pmin(1, mean_prevalence + 1.96 * prevalence_sd) + + output_table <- base |> + mutate( + mean = mean_prevalence, + median = median_prevalence, + SD = prevalence_sd, + lower_95 = lower_95, + upper_95 = upper_95, + exceedance_1 = 1 - pnorm(0.01, mean = mean, sd = SD), + exceedance_2 = 1 - pnorm(0.02, mean = mean, sd = SD), + exceedance_5 = 1 - pnorm(0.05, mean = mean, sd = SD), + exceedance_10 = 1 - pnorm(0.10, mean = mean, sd = SD), + no_of_informing_surveys = sample.int(35, n(), replace = TRUE) - 1L, + nearest_survey_by_date = sample(survey_ids, n(), replace = TRUE), + admin_level = as.integer(level) + ) |> + mutate(across(starts_with("exceedance_"), ~pmin(1, pmax(0, .x)))) |> + select( + variant, + gene, + mutation, + starts_with("admin"), + date, + mean, + median, + SD, + lower_95, + upper_95, + exceedance_1, + exceedance_2, + exceedance_5, + exceedance_10, + no_of_informing_surveys, + nearest_survey_by_date + ) +} + +write_level_table <- function(level, regions_tbl, target_chunk_rows = 500000L) { + output_path <- file.path(output_dir, sprintf("admin%d.parquet", level)) + temporary_path <- paste0(output_path, ".tmp") + if (file.exists(temporary_path)) { + unlink(temporary_path) + } + + # Limit each expanded table to roughly target_chunk_rows before writing it + # as one or more row groups to the same Parquet file. + regions_per_chunk <- max(1L, floor(target_chunk_rows / nrow(variant_months))) + chunk_ids <- ceiling(seq_len(nrow(regions_tbl)) / regions_per_chunk) + region_chunks <- split(seq_len(nrow(regions_tbl)), chunk_ids) + + output_stream <- NULL + writer <- NULL + total_rows <- 0 + + on.exit({ + if (!is.null(writer)) { + writer$Close() + } + if (!is.null(output_stream)) { + output_stream$close() + } + if (file.exists(temporary_path)) { + unlink(temporary_path) + } + }, add = TRUE) + + for (region_indices in region_chunks) { + output_table <- build_level_chunk(level, regions_tbl[region_indices, , drop = FALSE]) + arrow_table_chunk <- arrow_table(output_table) + + if (is.null(writer)) { + output_stream <- FileOutputStream$create(temporary_path) + properties <- ParquetWriterProperties$create(names(output_table)) + writer <- ParquetFileWriter$create( + arrow_table_chunk$schema, + output_stream, + properties = properties + ) + } + + writer$WriteTable(arrow_table_chunk, chunk_size = min(100000L, nrow(output_table))) + total_rows <- total_rows + nrow(output_table) + } + + writer$Close() + writer <- NULL + output_stream$close() + output_stream <- NULL + + if (!file.rename(temporary_path, output_path)) { + cli_abort("Failed to move completed output to {.file {output_path}}.") + } + + total_rows +} + +admin0_rows <- write_level_table(0, admin0_regions) +admin1_rows <- write_level_table(1, admin1_regions) +admin2_rows <- write_level_table(2, admin2_regions) + +cli_inform(c( + "v" = "Wrote {.file admin0.parquet} with {admin0_rows} rows.", + "v" = "Wrote {.file admin1.parquet} with {admin1_rows} rows.", + "v" = "Wrote {.file admin2.parquet} with {admin2_rows} rows.", + "i" = "Output directory: {.path {output_dir}}." +)) diff --git a/scripts/fetch_admin0_region_metadata.ts b/scripts/fetch_admin0_region_metadata.ts new file mode 100644 index 0000000..d425eb6 --- /dev/null +++ b/scripts/fetch_admin0_region_metadata.ts @@ -0,0 +1,19 @@ +// A setup script to run before starting the app. +// Sends a request to grout to get the bounding boxes for admin0 regions. + +import { mkdir, writeFile } from "fs/promises"; +import { dirname, resolve } from "path"; + +const groutMetadataUrl = "https://mrcdata.dide.ic.ac.uk/grout/region-metadata/gadm41/admin0"; +const outputPath = resolve(process.cwd(), "data/admin0-region-metadata.json"); + +const response = await fetch(groutMetadataUrl); +if (!response.ok) { + throw new Error(`Failed to fetch admin0 metadata (${response.status} ${response.statusText})`); +} + +const responseJson = await response.json(); + +await mkdir(dirname(outputPath), { recursive: true }); +await writeFile(outputPath, `${JSON.stringify(responseJson.data, null, 2)}\n`, "utf-8"); +console.log(`Wrote ${responseJson.data.length} admin0 metadata rows to ${outputPath}`); diff --git a/scripts/input/stave/2026.03.17/stave_data.rds b/scripts/input/stave/2026.03.17/stave_data.rds new file mode 100644 index 0000000..96e43ed Binary files /dev/null and b/scripts/input/stave/2026.03.17/stave_data.rds differ diff --git a/scripts/process_stave.R b/scripts/process_stave.R new file mode 100644 index 0000000..87a5e9c --- /dev/null +++ b/scripts/process_stave.R @@ -0,0 +1,95 @@ +# Converts an .rds file containing a STAVE object of 3 tables (studies, surveys, counts) +# into an expanded version of the studies table, having one row per survey per variant, +# which is saved in parquet format. +# This results in a table of about 253,000 rows at time of writing. +# Most of the work and logic is done by the two packages STAVE and variantstring, +# particularly STAVE's `$get_prevalence` function. + +library(arrow) +library(cli) +library(dplyr) +library(tidyr) +library(STAVE) +library(variantstring) +library(here) + +args <- commandArgs(trailingOnly = TRUE) +if (length(args) == 0) { + cli_abort(c( + "Usage: {.code Rscript process_stave.R }", + "i" = "Example: {.code Rscript process_stave.R 2026.03.17}" + )) +} + +current_stave_release <- args[[1]] +output_filename = "survey_data.parquet" + +input_dir <- here("scripts", "input", "stave", current_stave_release) +output_dir <- here("data", "stave", current_stave_release) + +dir.create(output_dir, recursive = TRUE, showWarnings = FALSE) + +input_file <- file.path(input_dir, "stave_data.rds") +if (!file.exists(input_file)) { + cli_abort("Input file not found: {.file {input_file}}.") +} + +stave_obj <- readRDS(input_file) + +variants <- stave_obj$get_variants() + +# For each variant, calculate imputed prevalence per survey +# (only surveys with a non-zero denominator), and combine into one tibble. +prevalence_tbl <- variants |> + lapply(function(v) { + stave_obj$get_prevalence(target_variant = v, return_full = FALSE) |> + mutate(variant = v) + }) |> + bind_rows() + +# Parse gene/locus/amino-acid from each variant string using variantstring's own parser. +# We validate that all variant strings define a single variant. +# If they do, they have only a single value between each colon. +# If they don't, `variant_to_long` will unpack the strings into multiple variants, and +# we'll catch this and abort. +parsed_list <- variant_to_long(variants) # one data.frame per variant +n_rows <- vapply(parsed_list, nrow, integer(1)) +if (any(n_rows != 1)) { + bad <- variants[n_rows != 1] + cli_abort(c( + "Expected each variant to parse to exactly 1 row via {.fn variant_to_long}, but got unexpected row counts for: {.val {unique(bad)}}.", + "x" = "The variant string might not be single-locus.", + "i" = "Did you call {.code $get_variants(report_haplo = TRUE)}?" + )) +} + +parsed <- bind_rows(Map(function(df, v) mutate(df, variant = v), parsed_list, variants)) + +prevalence_tbl <- prevalence_tbl |> + left_join( + parsed |> + transmute(variant, gene, mutation = paste0(pos, aa)), + by = "variant" + ) + +# Because of encoding errors in paper titles, we need to force conversion to UTF-8. +# This function tries UTF-8 first, and if invalid, assumes Latin-1 +fix_utf8 <- function(x) { + bad <- !validEnc(x) | is.na(iconv(x, "UTF-8", "UTF-8")) # get vector of whether utf-8 encoding works for the string + x[bad] <- iconv(x[bad], from = "latin1", to = "UTF-8") # For just the flagged entries, reinterpret the raw bytes as Latin-1 and convert to utf-8 + enc2utf8(x) # tag every string as declared-UTF-8 +} + +# Drop columns we don't need +drop_cols <- c("description", "access_level", "PMID", "country_name", + "location_method", "location_notes", "time_method", "time_notes") + +prevalence_tbl <- prevalence_tbl |> + select(-all_of(drop_cols)) |> + rename(lat = latitude, lng = longitude) |> + mutate(lat = round(lat, 4), lng = round(lng, 4)) |> # Drop <10m precision + mutate(across(where(is.character), fix_utf8)) + +write_parquet(prevalence_tbl, file.path(output_dir, output_filename)) + +cli_inform(c("v" = "Wrote {.file {output_filename}} with {nrow(prevalence_tbl)} rows to {.path {output_dir}}.")) diff --git a/src/app.ts b/src/app.ts index f74b796..142a04a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,21 +1,87 @@ import express, { type Express, type Request, type Response } from 'express'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import config from './config/config.ts'; import { errorHandler } from './middlewares/errorHandler.ts'; +import { PREVALENCE_COLUMNS, SURVEY_COLUMNS, globalBounds, modelVersions } from './constants.ts'; +import type { QueryParams } from './types.ts'; +import { validateAdminLevel, validateDataRelease, validateDateIsFirstOfMonth, validateDateParams, validateModelRelease, validateRequiredQueryParams } from './utils/validators.ts'; +import { executeParquetQuery } from './utils/dataHelpers.ts'; +import { getMutationsByGene } from './utils/metadataHelpers.ts'; -const app: Express = express(); +export const createApp = (): Express => { + const app: Express = express(); -app.get('/', (req: Request, res: Response) => { - res.send({}); -}); + app.get('/metadata', async (req: Request, res: Response) => { + const modelVersion = (req.query['model_release'] ?? config.latestModelVersion) as string; + if (!validateModelRelease(modelVersion, res)) return; -app.get('/prevalences', (req: Request, res: Response) => { - const admin_level = req.query['admin_level']; + 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 dataVersion = modelMetadata.data_release; - res.send({admin_level_query_param: admin_level}); -}); + res.send({ + model_releases: modelVersions, + prevalences: { + version: modelVersion, + data_release: dataVersion, + variants: mutationsByGene, + }, + bounds: globalBounds.bounds, + }); + }); -app.use(errorHandler); + app.get('/surveys', async (req: Request, res: Response) => { + const requiredParams = [ + "data_release", + "properties", + SURVEY_COLUMNS.GENE, + SURVEY_COLUMNS.MUTATION, + ]; + if (!validateRequiredQueryParams(req, res, requiredParams) + || !validateDataRelease(req, res) + || !validateDateParams(req, res) + ) return; -app.listen(config.port, () => { - console.log(`Server running on port ${config.port}`); -}); + const dataVersion = req.query['data_release'] as string; + const surveyDataParquet = join(config.dataDir, "stave", dataVersion, "survey_data.parquet"); + + const result = await executeParquetQuery(req.query as QueryParams, "/surveys", surveyDataParquet, res); + if (!result) return; + + res.type("json").send(result.getRowObjectsJson()); + }); + + app.get('/prevalences', async (req: Request, res: Response) => { + const queryParams = req.query as QueryParams; + // TODO: consider allowing this endpoint to have a default model release + + const requiredParams = [ + "model_release", + "admin_level", + "properties", + PREVALENCE_COLUMNS.GENE, + PREVALENCE_COLUMNS.MUTATION, + ]; + if (!validateRequiredQueryParams(req, res, requiredParams) + || !validateModelRelease(queryParams.model_release!, res) + || !validateDateParams(req, res) + || !validateDateIsFirstOfMonth(req, res) + || !validateAdminLevel(req, res) + ) return; + + // 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 result = await executeParquetQuery(queryParams, "/prevalences", prevalencesParquet, res); + if (!result) return; + + res.type("json").send(result.getColumnsObjectJson()); + }); + + app.use(errorHandler); + + return app; +}; diff --git a/src/config/config.ts b/src/config/config.ts index dc51ef2..fa52a20 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -3,16 +3,19 @@ import dotenv from 'dotenv'; dotenv.config(); interface Config { - port: number; - nodeEnv: string; + port?: number; + dataDir: string; + latestModelVersion: string; } const port = process.env.PORT; -if (!port) throw new Error('PORT is required'); const config: Config = { - port: Number(port), - nodeEnv: process.env.NODE_ENV || 'development', + port: port ? Number(port) : undefined, + dataDir: process.env.NODE_ENV === 'test' + ? 'tests/fixtures/data' + : 'data', + latestModelVersion: '2026.05.08', }; export default config; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..1e50f13 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,72 @@ +import { readdir } from "fs/promises"; +import { join } from "node:path"; +import config from "./config/config.ts"; + +export const adminLevels = ["0", "1", "2"]; + +export const globalBounds = { + "bounds": { + "min": { + "lng": -70.0635, + "lat": 12.4124 + }, + "max": { + "lng": -69.8654, + "lat": 12.624 + } + } +}; + +export const SURVEY_COLUMNS = { + COLLECTION_DAY: "collection_day", + COLLECTION_END: "collection_end", + COLLECTION_START: "collection_start", + CONTRIBUTORS: "contributors", + DENOMINATOR: "denominator", + GENE: "gene", + LAT: "lat", + LNG: "lng", + MUTATION: "mutation", + NUMERATOR: "numerator", + PREVALENCE_LOWER: "prevalence_lower", + PREVALENCE_UPPER: "prevalence_upper", + PREVALENCE: "prevalence", + REFERENCE_YEAR: "reference_year", + REFERENCE: "reference", + SITE_NAME: "site_name", + STUDY_ID: "study_id", + STUDY_LABEL: "study_label", + SURVEY_ID: "survey_id", + VARIANT: "variant", +} as const; + +export const PREVALENCE_COLUMNS = { + ADMIN_LEVEL: "admin_level", + ADMIN0: "admin0", + ADMIN1: "admin1", + ADMIN2: "admin2", + DATE: "date", + EXCEEDANCE_1: "exceedance_1", + EXCEEDANCE_10: "exceedance_10", + EXCEEDANCE_2: "exceedance_2", + EXCEEDANCE_5: "exceedance_5", + GENE: "gene", + LOWER_95: "lower_95", + MEAN: "mean", + MEDIAN: "median", + MUTATION: "mutation", + NEAREST_SURVEY_BY_DATE: "nearest_survey_by_date", + NO_OF_INFORMING_SURVEYS: "no_of_informing_surveys", // Gives a survey id + SD: "SD", + UPPER_95: "upper_95", +} as const; + +const staveFiles = await readdir(join(config.dataDir, "stave"), { withFileTypes: true }); +export const dataVersions = staveFiles + .filter(entry => entry.isDirectory()) + .map(entry => entry.name); + +const modelFiles = await readdir(join(config.dataDir, "model"), { withFileTypes: true }); +export const modelVersions = modelFiles + .filter(entry => entry.isDirectory()) + .map(entry => entry.name); diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index 4489532..58e52bf 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -8,6 +8,7 @@ export const errorHandler = ( err: AppError, req: Request, res: Response, + // eslint-disable-next-line @typescript-eslint/no-unused-vars next: NextFunction ) => { console.error(err); diff --git a/src/queryEngine.ts b/src/queryEngine.ts new file mode 100644 index 0000000..ba496bc --- /dev/null +++ b/src/queryEngine.ts @@ -0,0 +1,14 @@ +import { DuckDBInstance } from '@duckdb/node-api'; + +// On creating with in-memory database: https://duckdb.org/docs/current/clients/node_neo/overview#create-instance +// DuckDB can operate in both persistent mode, where the data is saved to disk, and in in-memory mode, where the entire dataset is stored in the main memory. +// Both persistent and in-memory databases use spilling to disk to facilitate larger-than-memory workloads (i.e., out-of-core-processing). +// In in-memory mode, no data is persisted to disk, therefore, all data is lost when the process finishes. + +// We can do partial resolution of queries using streaming, see https://duckdb.org/docs/current/clients/node_neo + +const instance = await DuckDBInstance.create(':memory:', { + parquet_metadata_cache: "true", +}); + +export const connection = await instance.connect(); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..df85097 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,12 @@ +import { createApp } from './app.ts'; +import config from './config/config.ts'; + +if (!config.port) { + throw new Error('PORT is required'); +} + +const app = createApp(); + +app.listen(config.port, () => { + console.log(`Server running on port ${config.port}`); +}); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..75c8ebf --- /dev/null +++ b/src/types.ts @@ -0,0 +1,17 @@ +import type { PREVALENCE_COLUMNS, SURVEY_COLUMNS } from "./constants.ts"; + +export interface Mutation { + mutation: string; + date_range: { + start: string; + end: string; + }; +} + +export type QueryParams = Record; + +export const metadataQueryParams = {} as QueryParams; + +export type SurveyColumn = typeof SURVEY_COLUMNS[keyof typeof SURVEY_COLUMNS]; +export type PrevalenceColumn = typeof PREVALENCE_COLUMNS[keyof typeof PREVALENCE_COLUMNS]; +export type Column = SurveyColumn | PrevalenceColumn; diff --git a/src/utils/dataHelpers.ts b/src/utils/dataHelpers.ts new file mode 100644 index 0000000..c6315ea --- /dev/null +++ b/src/utils/dataHelpers.ts @@ -0,0 +1,153 @@ +import { type Response } from 'express'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { connection } from '../queryEngine.ts'; +import config from '../config/config.ts'; +import type { Column, QueryParams } from '../types.ts'; +import { validateRequestedProperties } from './validators.ts'; +import type { DuckDBResultReader } from '@duckdb/node-api'; +import { PREVALENCE_COLUMNS, SURVEY_COLUMNS } from '../constants.ts'; + +interface Admin0RegionMetadata { + id: string; + bounds: { + min: { lat: number; lng: number }; + max: { lat: number; lng: number }; + }; +} + +const admin0RegionMetadata = JSON.parse( + await readFile(join(config.dataDir, "admin0-region-metadata.json"), "utf8"), +) as Admin0RegionMetadata[]; + +// Arbitrary alias for the parquet file in the SQL queries. +const tableName = "p"; + +const roundableColumnTypes = ["DOUBLE", "FLOAT", "DECIMAL"]; + +// column mode: filter on the admin0 column directly. +// bounds mode: translate the admin0 ISO code into lat/lng bounding-boxes. +// Survey data does not come with region metadata, so we filter it by lat/lng. +const Admin0Mode = { + BOUNDS: "bounds", + COLUMN: "column", +} as const; +type Admin0Mode = typeof Admin0Mode[keyof typeof Admin0Mode]; + +type Endpoint = "/surveys" | "/prevalences"; +interface EndpointConfig { + // An allow-list of properties that clients may request as columns. + requestableProperties: T[]; + // Query parameters that may be used to filter the rows. + filterableParams: string[]; + // The column to filter on for requests that scope by date_from/date_to. + dateColumn: T; + admin0Mode: Admin0Mode; +} + +const endpointConfigs: Record = { + "/surveys": { + requestableProperties: Object.values(SURVEY_COLUMNS), + filterableParams: ["admin0", "survey_id", "date_from", "date_to", "gene", "mutation"], + dateColumn: "collection_day", + admin0Mode: "bounds", + }, + "/prevalences": { + requestableProperties: Object.values(PREVALENCE_COLUMNS), + filterableParams: ["admin0", "admin1", "admin2", "gene", "mutation", "date", "date_from", "date_to"], + dateColumn: "date", + admin0Mode: "column", + }, +} as const; + +// Build and run an SQL query out of the requested properties and filters. +export const executeParquetQuery = async ( + queryParams: QueryParams, + path: Endpoint, + parquetPath: string, + res: Response, +): Promise => { + const config = endpointConfigs[path]; + + const parquetColumns = await inspectColumns(parquetPath); + if (!validateRequestedProperties(queryParams, config.requestableProperties, parquetColumns, res)) return; + + const properties = queryParams.properties?.split(',').map(p => p as Column) ?? []; + const selectColumns = await buildSelectColumns(parquetPath, properties); + const where = buildWhereClause(queryParams, config, res); + if (!where) return; + + const { whereClause, bindings } = where; + const sql = `SELECT ${selectColumns} FROM '${parquetPath}' ${tableName} ${whereClause}`; + const statement = await connection.prepare(sql); + statement.bind(bindings); + const result = await statement.runAndReadAll(); + return result; +}; + +const buildSelectColumns = async ( + parquetPath: string, + requestedProperties: Column[], +): Promise => { + const parquetColumns = await inspectColumns(parquetPath); + + return requestedProperties.map((p) => { + // Round to 4 decimal places for numeric columns, to reduce size of response + const columnType = parquetColumns[p]; + return roundableColumnTypes.includes(columnType) + ? `ROUND(${tableName}.${p}, 4) AS ${p}` + : `${tableName}.${p}`; + }).join(", "); +}; + + +// Ask the parquet file for its columns and their SQL types. +const inspectColumns = async (parquetPath: string): Promise> => { + const parquetColumns = await connection.runAndReadAll(`SELECT * FROM '${parquetPath}' LIMIT 1`); + const columnTypes = parquetColumns.columnTypes(); + return Object.fromEntries(parquetColumns.columnNames().map((col, index) => { + return [col, String(columnTypes[index])]; + })) as Record; +}; + +const buildWhereClause = (queryParams: QueryParams, config: EndpointConfig, res: Response): { + whereClause: string + bindings: Record +} | undefined => { + const whereClauses = []; + const bindings: Record = {}; // Map from param name to value for use in prepared statement. + const paramsToFilter = config.filterableParams.filter(param => !!queryParams[param]); + + for (const paramName of paramsToFilter) { + if (paramName === "admin0" && config.admin0Mode === "bounds") { + const region = admin0RegionMetadata.find(({ id }) => id === queryParams.admin0); + if (!region) { + res.status(400).send({ error: `ISO code not found: ${queryParams.admin0}` }); + return; + } + whereClauses.push(buildBoundsClause(region)); + continue; + } + const column = ["date_from", "date_to"].includes(paramName) ? config.dateColumn : paramName; + const equality = paramName === "date_from" ? ">=" : paramName === "date_to" ? "<=" : "="; + + const paramVal = queryParams[paramName]; + whereClauses.push(`${tableName}.${column} ${equality} $${paramName}`); + bindings[paramName] = paramVal ?? null; + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''; + + return { whereClause, bindings }; +}; + +const buildBoundsClause = (region: Admin0RegionMetadata) => { + const bounds = region.bounds; + + return [ + `${tableName}.${SURVEY_COLUMNS.LAT} >= ${bounds.min.lat}`, + `${tableName}.${SURVEY_COLUMNS.LAT} <= ${bounds.max.lat}`, + `${tableName}.${SURVEY_COLUMNS.LNG} >= ${bounds.min.lng}`, + `${tableName}.${SURVEY_COLUMNS.LNG} <= ${bounds.max.lng}` + ].join(" AND "); +}; diff --git a/src/utils/metadataHelpers.ts b/src/utils/metadataHelpers.ts new file mode 100644 index 0000000..4e59f72 --- /dev/null +++ b/src/utils/metadataHelpers.ts @@ -0,0 +1,52 @@ +// Get unique genetic variants and their associated genes and mutations. + +import { connection } from "../queryEngine.ts"; +import { join } from "node:path"; +import config from "../config/config.ts"; +import type { Mutation } from "../types.ts"; + +// Get unique genetic variants and their associated genes and mutations, +// as well as the date range for each variant, from the model outputs rectangle. +export const getMutationsByGene = async ( + modelVersion: string, +): Promise<{ + gene: string, + mutations: Mutation[], +}[]> => { + // The 'variant' column encodes both the gene and mutation, so we can + // group by that column to get unique variants. + const uniqueVariants = await connection.runAndReadAll(` + SELECT + ANY_VALUE(gene) AS gene, + ANY_VALUE(mutation) AS mutation, + variant, + STRFTIME(MIN("date"), '%Y-%m-%d') AS min_date, + STRFTIME(MAX("date"), '%Y-%m-%d') AS max_date + FROM '${join(config.dataDir, "model", modelVersion, "admin0.parquet")}' + GROUP BY variant + `); + + // Group the unique variants by gene, so that we can return a list of mutations + // for each gene in the metadata endpoint. + // We assume the date range per variant will be the same across all admin levels. + return uniqueVariants.getRowObjects().reduce((acc, row) => { + const gene = row.gene as string; + const mutationObj = { + mutation: row.mutation, + date_range: { + start: row.min_date, + end: row.max_date, + }, + } as Mutation; + const existingGene = acc.find(g => g.gene === gene); + if (existingGene) { + existingGene.mutations.push(mutationObj); + } else { + acc.push({ + gene, + mutations: [mutationObj], + }); + } + return acc; + }, [] as { gene: string, mutations: Mutation[] }[]); +}; diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 0000000..562b6f9 --- /dev/null +++ b/src/utils/validators.ts @@ -0,0 +1,117 @@ +import { type Request, type Response } from 'express'; +import { modelVersions, dataVersions, adminLevels } from '../constants.ts'; +import type { QueryParams, Column } from '../types.ts'; + +const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + +export const validateRequiredQueryParams = ( + req: Request, + res: Response, + requiredParams: string[] +): boolean => { + const missingParams = requiredParams.filter(param => !req.query[param]); + if (missingParams.length > 0) { + res.status(400).send({ error: `Missing required query parameters: ${missingParams.join(', ')}` }); + return false; + } + return true; +}; + +export const validateRequestedProperties = ( + queryParams: QueryParams, + requestableProperties: Column[], // provided by endpoint config + parquetColumns: { [K in Column]?: string }, + res: Response, +): boolean => { + const requestedProperties = queryParams.properties + ?.split(',') + .map(p => p as Column) + .filter(p => !!p) ?? []; + if (requestedProperties.length === 0) { + res.status(400).send({ error: "At least one property must be requested." }); + return false; + } + const availableColumns = Object.keys(parquetColumns); + const invalid = requestedProperties.find((p) => { + return !requestableProperties.includes(p) || !availableColumns.includes(p); + }); + if (invalid) { + res.status(400).send({ error: `Invalid property requested: ${invalid}` }); + return false; + } + return true; +}; + +// 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 => { + if (!modelVersions.includes(modelVersion)) { + res.status(400).send({ error: `Invalid model release: ${modelVersion}` }); + return false; + } + return true; +}; + +export const validateDataRelease = (req: Request, res: Response): boolean => { + const dataVersion = req.query['data_release'] as string; + + if (!dataVersions.includes(dataVersion)) { + res.status(400).send({ error: `Invalid data release requested: ${dataVersion}` }); + return false; + } + return true; +}; + +export const validateDateParams = (req: Request, res: Response): boolean => { + const queryParams = req.query as Record; + + for (const param of ["date", "date_from", "date_to"]) { + if (queryParams[param] && !dateRegex.test(queryParams[param]!)) { + res.status(400).send({ error: `Invalid date format for parameter '${param}'. Expected YYYY-MM-DD.` }); + return false; + } + } + + const date_from = queryParams.date_from; + const date_to = queryParams.date_to; + + if ((date_from && !date_to) || (date_to && !date_from)) { + res.status(400).send({ error: "Only one of 'date_to' and 'date_from' was specified." }); + return false; + } + + if (date_from && date_to && new Date(date_from) > new Date(date_to)) { + res.status(400).send({ error: "'date_from' cannot be later than 'date_to'." }); + return false; + } + return true; +}; + +export const validateDateIsFirstOfMonth = (req: Request, res: Response): boolean => { + const date = req.query['date'] as string | undefined; + if (date && new Date(date).getDate() !== 1) { + res.status(400).send({ error: "Invalid `date` parameter. The date must be the first of a month." }); + return false; + } + return true; +}; + +export const validateAdminLevel = (req: Request, res: Response): boolean => { + const adminLevel = req.query['admin_level'] as string | undefined; + if (!adminLevel || !adminLevels.includes(adminLevel)) { + res.status(400).send({ error: `Invalid admin level requested: ${adminLevel}` }); + return false; + } + // Validate admin_level against admin0, admin1, admin2 parameters if they exist. + for (const level of adminLevels) { + if (req.query[`admin${level}`] && Number(adminLevel) < Number(level)) { + res.status(400).send({ + error: "You cannot request results at a less granular level than that of the containing region.", + }); + return false; + } + } + return true; +}; + diff --git a/tests/fixtures/data/admin0-region-metadata.json b/tests/fixtures/data/admin0-region-metadata.json new file mode 100644 index 0000000..5d62e15 --- /dev/null +++ b/tests/fixtures/data/admin0-region-metadata.json @@ -0,0 +1,4448 @@ +[ + { + "id": "ABW", + "name": "Aruba", + "bounds": { + "min": { + "lng": -70.0635, + "lat": 12.4124 + }, + "max": { + "lng": -69.8654, + "lat": 12.624 + } + }, + "geojson_properties": { + "GID_0": "ABW", + "COUNTRY": "Aruba" + } + }, + { + "id": "AFG", + "name": "Afghanistan", + "bounds": { + "min": { + "lng": 60.5049, + "lat": 29.3619 + }, + "max": { + "lng": 74.8941, + "lat": 38.4904 + } + }, + "geojson_properties": { + "GID_0": "AFG", + "COUNTRY": "Afghanistan" + } + }, + { + "id": "AGO", + "name": "Angola", + "bounds": { + "min": { + "lng": 11.6687, + "lat": -18.0421 + }, + "max": { + "lng": 24.0801, + "lat": -4.3726 + } + }, + "geojson_properties": { + "GID_0": "AGO", + "COUNTRY": "Angola" + } + }, + { + "id": "AIA", + "name": "Anguilla", + "bounds": { + "min": { + "lng": -63.4278, + "lat": 18.1609 + }, + "max": { + "lng": -62.9267, + "lat": 18.5918 + } + }, + "geojson_properties": { + "GID_0": "AIA", + "COUNTRY": "Anguilla" + } + }, + { + "id": "ALA", + "name": "Åland", + "bounds": { + "min": { + "lng": 19.3047, + "lat": 59.7479 + }, + "max": { + "lng": 21.3286, + "lat": 60.6521 + } + }, + "geojson_properties": { + "GID_0": "ALA", + "COUNTRY": "Åland" + } + }, + { + "id": "ALB", + "name": "Albania", + "bounds": { + "min": { + "lng": 19.2642, + "lat": 39.6507 + }, + "max": { + "lng": 21.0491, + "lat": 42.6604 + } + }, + "geojson_properties": { + "GID_0": "ALB", + "COUNTRY": "Albania" + } + }, + { + "id": "AND", + "name": "Andorra", + "bounds": { + "min": { + "lng": 1.4142, + "lat": 42.4296 + }, + "max": { + "lng": 1.7842, + "lat": 42.6521 + } + }, + "geojson_properties": { + "GID_0": "AND", + "COUNTRY": "Andorra" + } + }, + { + "id": "ARE", + "name": "United Arab Emirates", + "bounds": { + "min": { + "lng": 51.498, + "lat": 22.6316 + }, + "max": { + "lng": 56.3812, + "lat": 26.0693 + } + }, + "geojson_properties": { + "GID_0": "ARE", + "COUNTRY": "United Arab Emirates" + } + }, + { + "id": "ARG", + "name": "Argentina", + "bounds": { + "min": { + "lng": -73.5606, + "lat": -55.0615 + }, + "max": { + "lng": -53.5918, + "lat": -21.7814 + } + }, + "geojson_properties": { + "GID_0": "ARG", + "COUNTRY": "Argentina" + } + }, + { + "id": "ARM", + "name": "Armenia", + "bounds": { + "min": { + "lng": 43.4498, + "lat": 38.8305 + }, + "max": { + "lng": 46.63, + "lat": 41.3018 + } + }, + "geojson_properties": { + "GID_0": "ARM", + "COUNTRY": "Armenia" + } + }, + { + "id": "ASM", + "name": "American Samoa", + "bounds": { + "min": { + "lng": -171.0895, + "lat": -14.3732 + }, + "max": { + "lng": -169.4187, + "lat": -11.0481 + } + }, + "geojson_properties": { + "GID_0": "ASM", + "COUNTRY": "American Samoa" + } + }, + { + "id": "ATA", + "name": "Antarctica", + "bounds": { + "min": { + "lng": -180, + "lat": -90 + }, + "max": { + "lng": 180, + "lat": -59.5937 + } + }, + "geojson_properties": { + "GID_0": "ATA", + "COUNTRY": "Antarctica" + } + }, + { + "id": "ATF", + "name": "French Southern Territories", + "bounds": { + "min": { + "lng": 40.3282, + "lat": -50.0188 + }, + "max": { + "lng": 77.6033, + "lat": -11.5137 + } + }, + "geojson_properties": { + "GID_0": "ATF", + "COUNTRY": "French Southern Territories" + } + }, + { + "id": "ATG", + "name": "Antigua and Barbuda", + "bounds": { + "min": { + "lng": -62.349, + "lat": 16.9318 + }, + "max": { + "lng": -61.6565, + "lat": 17.729 + } + }, + "geojson_properties": { + "GID_0": "ATG", + "COUNTRY": "Antigua and Barbuda" + } + }, + { + "id": "AUS", + "name": "Australia", + "bounds": { + "min": { + "lng": 112.9211, + "lat": -55.1169 + }, + "max": { + "lng": 159.1092, + "lat": -9.1422 + } + }, + "geojson_properties": { + "GID_0": "AUS", + "COUNTRY": "Australia" + } + }, + { + "id": "AUT", + "name": "Austria", + "bounds": { + "min": { + "lng": 9.531, + "lat": 46.3728 + }, + "max": { + "lng": 17.1621, + "lat": 49.0212 + } + }, + "geojson_properties": { + "GID_0": "AUT", + "COUNTRY": "Austria" + } + }, + { + "id": "AZE", + "name": "Azerbaijan", + "bounds": { + "min": { + "lng": 44.7726, + "lat": 38.3972 + }, + "max": { + "lng": 50.6078, + "lat": 41.9075 + } + }, + "geojson_properties": { + "GID_0": "AZE", + "COUNTRY": "Azerbaijan" + } + }, + { + "id": "BDI", + "name": "Burundi", + "bounds": { + "min": { + "lng": 29.0007, + "lat": -4.47 + }, + "max": { + "lng": 30.8502, + "lat": -2.3098 + } + }, + "geojson_properties": { + "GID_0": "BDI", + "COUNTRY": "Burundi" + } + }, + { + "id": "BEL", + "name": "Belgium", + "bounds": { + "min": { + "lng": 2.5554, + "lat": 49.4972 + }, + "max": { + "lng": 6.4079, + "lat": 51.5038 + } + }, + "geojson_properties": { + "GID_0": "BEL", + "COUNTRY": "Belgium" + } + }, + { + "id": "BEN", + "name": "Benin", + "bounds": { + "min": { + "lng": 0.7743, + "lat": 6.2349 + }, + "max": { + "lng": 3.8517, + "lat": 12.4184 + } + }, + "geojson_properties": { + "GID_0": "BEN", + "COUNTRY": "Benin" + } + }, + { + "id": "BES", + "name": "Bonaire, Sint Eustatius and Saba", + "bounds": { + "min": { + "lng": -68.4207, + "lat": 12.0243 + }, + "max": { + "lng": -62.9418, + "lat": 17.6507 + } + }, + "geojson_properties": { + "GID_0": "BES", + "COUNTRY": "Bonaire, Sint Eustatius and Saba" + } + }, + { + "id": "BFA", + "name": "Burkina Faso", + "bounds": { + "min": { + "lng": -5.5189, + "lat": 9.4011 + }, + "max": { + "lng": 2.4054, + "lat": 15.0826 + } + }, + "geojson_properties": { + "GID_0": "BFA", + "COUNTRY": "Burkina Faso" + } + }, + { + "id": "BGD", + "name": "Bangladesh", + "bounds": { + "min": { + "lng": 88.0106, + "lat": 20.7411 + }, + "max": { + "lng": 92.6737, + "lat": 26.6341 + } + }, + "geojson_properties": { + "GID_0": "BGD", + "COUNTRY": "Bangladesh" + } + }, + { + "id": "BGR", + "name": "Bulgaria", + "bounds": { + "min": { + "lng": 22.3438, + "lat": 41.2348 + }, + "max": { + "lng": 28.609, + "lat": 44.2127 + } + }, + "geojson_properties": { + "GID_0": "BGR", + "COUNTRY": "Bulgaria" + } + }, + { + "id": "BHR", + "name": "Bahrain", + "bounds": { + "min": { + "lng": 50.2747, + "lat": 25.5571 + }, + "max": { + "lng": 50.8246, + "lat": 26.2888 + } + }, + "geojson_properties": { + "GID_0": "BHR", + "COUNTRY": "Bahrain" + } + }, + { + "id": "BHS", + "name": "Bahamas", + "bounds": { + "min": { + "lng": -80.476, + "lat": 20.9121 + }, + "max": { + "lng": -72.7121, + "lat": 27.2711 + } + }, + "geojson_properties": { + "GID_0": "BHS", + "COUNTRY": "Bahamas" + } + }, + { + "id": "BIH", + "name": "Bosnia and Herzegovina", + "bounds": { + "min": { + "lng": 15.7274, + "lat": 42.5653 + }, + "max": { + "lng": 19.6147, + "lat": 45.2747 + } + }, + "geojson_properties": { + "GID_0": "BIH", + "COUNTRY": "Bosnia and Herzegovina" + } + }, + { + "id": "BLM", + "name": "Saint-Barthélemy", + "bounds": { + "min": { + "lng": -62.9478, + "lat": 17.8707 + }, + "max": { + "lng": -62.7893, + "lat": 17.9609 + } + }, + "geojson_properties": { + "GID_0": "BLM", + "COUNTRY": "Saint-Barthélemy" + } + }, + { + "id": "BLR", + "name": "Belarus", + "bounds": { + "min": { + "lng": 23.1783, + "lat": 51.2696 + }, + "max": { + "lng": 32.7946, + "lat": 56.1684 + } + }, + "geojson_properties": { + "GID_0": "BLR", + "COUNTRY": "Belarus" + } + }, + { + "id": "BLZ", + "name": "Belize", + "bounds": { + "min": { + "lng": -89.2242, + "lat": 15.8927 + }, + "max": { + "lng": -87.4865, + "lat": 18.4967 + } + }, + "geojson_properties": { + "GID_0": "BLZ", + "COUNTRY": "Belize" + } + }, + { + "id": "BMU", + "name": "Bermuda", + "bounds": { + "min": { + "lng": -64.8878, + "lat": 32.2467 + }, + "max": { + "lng": -64.6465, + "lat": 32.4772 + } + }, + "geojson_properties": { + "GID_0": "BMU", + "COUNTRY": "Bermuda" + } + }, + { + "id": "BOL", + "name": "Bolivia", + "bounds": { + "min": { + "lng": -69.6452, + "lat": -22.9066 + }, + "max": { + "lng": -57.4544, + "lat": -9.6709 + } + }, + "geojson_properties": { + "GID_0": "BOL", + "COUNTRY": "Bolivia" + } + }, + { + "id": "BRA", + "name": "Brazil", + "bounds": { + "min": { + "lng": -73.9897, + "lat": -33.7463 + }, + "max": { + "lng": -28.8479, + "lat": 5.2649 + } + }, + "geojson_properties": { + "GID_0": "BRA", + "COUNTRY": "Brazil" + } + }, + { + "id": "BRB", + "name": "Barbados", + "bounds": { + "min": { + "lng": -59.6507, + "lat": 13.0446 + }, + "max": { + "lng": -59.4193, + "lat": 13.3351 + } + }, + "geojson_properties": { + "GID_0": "BRB", + "COUNTRY": "Barbados" + } + }, + { + "id": "BRN", + "name": "Brunei", + "bounds": { + "min": { + "lng": 114.1281, + "lat": 4.0222 + }, + "max": { + "lng": 115.3759, + "lat": 5.046 + } + }, + "geojson_properties": { + "GID_0": "BRN", + "COUNTRY": "Brunei" + } + }, + { + "id": "BTN", + "name": "Bhutan", + "bounds": { + "min": { + "lng": 88.746, + "lat": 26.7007 + }, + "max": { + "lng": 92.1248, + "lat": 28.2477 + } + }, + "geojson_properties": { + "GID_0": "BTN", + "COUNTRY": "Bhutan" + } + }, + { + "id": "BVT", + "name": "Bouvet Island", + "bounds": { + "min": { + "lng": 3.2844, + "lat": -54.4644 + }, + "max": { + "lng": 3.4875, + "lat": -54.3864 + } + }, + "geojson_properties": { + "GID_0": "BVT", + "COUNTRY": "Bouvet Island" + } + }, + { + "id": "BWA", + "name": "Botswana", + "bounds": { + "min": { + "lng": 20, + "lat": -26.9073 + }, + "max": { + "lng": 29.3683, + "lat": -17.7808 + } + }, + "geojson_properties": { + "GID_0": "BWA", + "COUNTRY": "Botswana" + } + }, + { + "id": "CAF", + "name": "Central African Republic", + "bounds": { + "min": { + "lng": 14.4177, + "lat": 2.2205 + }, + "max": { + "lng": 27.4634, + "lat": 11.0076 + } + }, + "geojson_properties": { + "GID_0": "CAF", + "COUNTRY": "Central African Republic" + } + }, + { + "id": "CAN", + "name": "Canada", + "bounds": { + "min": { + "lng": -141.0069, + "lat": 41.6769 + }, + "max": { + "lng": -52.6189, + "lat": 83.1104 + } + }, + "geojson_properties": { + "GID_0": "CAN", + "COUNTRY": "Canada" + } + }, + { + "id": "CCK", + "name": "Cocos Islands", + "bounds": { + "min": { + "lng": 96.8169, + "lat": -12.2086 + }, + "max": { + "lng": 96.9295, + "lat": -11.8152 + } + }, + "geojson_properties": { + "GID_0": "CCK", + "COUNTRY": "Cocos Islands" + } + }, + { + "id": "CHE", + "name": "Switzerland", + "bounds": { + "min": { + "lng": 5.9561, + "lat": 45.8173 + }, + "max": { + "lng": 10.4948, + "lat": 47.8085 + } + }, + "geojson_properties": { + "GID_0": "CHE", + "COUNTRY": "Switzerland" + } + }, + { + "id": "CHL", + "name": "Chile", + "bounds": { + "min": { + "lng": -109.4549, + "lat": -55.9797 + }, + "max": { + "lng": -66.4182, + "lat": -17.4986 + } + }, + "geojson_properties": { + "GID_0": "CHL", + "COUNTRY": "Chile" + } + }, + { + "id": "CHN", + "name": "China", + "bounds": { + "min": { + "lng": 73.5577, + "lat": 18.1593 + }, + "max": { + "lng": 134.7739, + "lat": 53.56 + } + }, + "geojson_properties": { + "GID_0": "CHN", + "COUNTRY": "China" + } + }, + { + "id": "CIV", + "name": "Côte d'Ivoire", + "bounds": { + "min": { + "lng": -8.5993, + "lat": 4.3618 + }, + "max": { + "lng": -2.4949, + "lat": 10.7366 + } + }, + "geojson_properties": { + "GID_0": "CIV", + "COUNTRY": "Côte d'Ivoire" + } + }, + { + "id": "CMR", + "name": "Cameroon", + "bounds": { + "min": { + "lng": 8.4995, + "lat": 1.6523 + }, + "max": { + "lng": 16.191, + "lat": 13.0774 + } + }, + "geojson_properties": { + "GID_0": "CMR", + "COUNTRY": "Cameroon" + } + }, + { + "id": "COD", + "name": "Democratic Republic of the Congo", + "bounds": { + "min": { + "lng": 12.2015, + "lat": -13.4525 + }, + "max": { + "lng": 31.3057, + "lat": 5.3861 + } + }, + "geojson_properties": { + "GID_0": "COD", + "COUNTRY": "Democratic Republic of the Congo" + } + }, + { + "id": "COG", + "name": "Republic of the Congo", + "bounds": { + "min": { + "lng": 11.2009, + "lat": -5.0307 + }, + "max": { + "lng": 18.65, + "lat": 3.7031 + } + }, + "geojson_properties": { + "GID_0": "COG", + "COUNTRY": "Republic of the Congo" + } + }, + { + "id": "COK", + "name": "Cook Islands", + "bounds": { + "min": { + "lng": -165.8794, + "lat": -21.9591 + }, + "max": { + "lng": -157.3206, + "lat": -8.9156 + } + }, + "geojson_properties": { + "GID_0": "COK", + "COUNTRY": "Cook Islands" + } + }, + { + "id": "COL", + "name": "Colombia", + "bounds": { + "min": { + "lng": -81.8412, + "lat": -4.2284 + }, + "max": { + "lng": -66.8377, + "lat": 15.9125 + } + }, + "geojson_properties": { + "GID_0": "COL", + "COUNTRY": "Colombia" + } + }, + { + "id": "COM", + "name": "Comoros", + "bounds": { + "min": { + "lng": 43.2287, + "lat": -12.4226 + }, + "max": { + "lng": 44.541, + "lat": -11.3649 + } + }, + "geojson_properties": { + "GID_0": "COM", + "COUNTRY": "Comoros" + } + }, + { + "id": "CPV", + "name": "Cabo Verde", + "bounds": { + "min": { + "lng": -25.3618, + "lat": 14.8018 + }, + "max": { + "lng": -22.6571, + "lat": 17.2054 + } + }, + "geojson_properties": { + "GID_0": "CPV", + "COUNTRY": "Cabo Verde" + } + }, + { + "id": "CRI", + "name": "Costa Rica", + "bounds": { + "min": { + "lng": -87.1018, + "lat": 5.4986 + }, + "max": { + "lng": -82.5525, + "lat": 11.2198 + } + }, + "geojson_properties": { + "GID_0": "CRI", + "COUNTRY": "Costa Rica" + } + }, + { + "id": "CUB", + "name": "Cuba", + "bounds": { + "min": { + "lng": -84.9524, + "lat": 19.826 + }, + "max": { + "lng": -74.1312, + "lat": 23.2774 + } + }, + "geojson_properties": { + "GID_0": "CUB", + "COUNTRY": "Cuba" + } + }, + { + "id": "CUW", + "name": "Curaçao", + "bounds": { + "min": { + "lng": -69.1626, + "lat": 11.9782 + }, + "max": { + "lng": -68.6393, + "lat": 12.3926 + } + }, + "geojson_properties": { + "GID_0": "CUW", + "COUNTRY": "Curaçao" + } + }, + { + "id": "CXR", + "name": "Christmas Island", + "bounds": { + "min": { + "lng": 105.5328, + "lat": -10.5708 + }, + "max": { + "lng": 105.7131, + "lat": -10.4121 + } + }, + "geojson_properties": { + "GID_0": "CXR", + "COUNTRY": "Christmas Island" + } + }, + { + "id": "CYM", + "name": "Cayman Islands", + "bounds": { + "min": { + "lng": -81.4199, + "lat": 19.2626 + }, + "max": { + "lng": -79.7232, + "lat": 19.7568 + } + }, + "geojson_properties": { + "GID_0": "CYM", + "COUNTRY": "Cayman Islands" + } + }, + { + "id": "CYP", + "name": "Cyprus", + "bounds": { + "min": { + "lng": 32.2693, + "lat": 34.625 + }, + "max": { + "lng": 34.0882, + "lat": 35.1994 + } + }, + "geojson_properties": { + "GID_0": "CYP", + "COUNTRY": "Cyprus" + } + }, + { + "id": "CZE", + "name": "Czechia", + "bounds": { + "min": { + "lng": 12.0908, + "lat": 48.5524 + }, + "max": { + "lng": 18.8593, + "lat": 51.0554 + } + }, + "geojson_properties": { + "GID_0": "CZE", + "COUNTRY": "Czechia" + } + }, + { + "id": "DEU", + "name": "Germany", + "bounds": { + "min": { + "lng": 5.8663, + "lat": 47.2708 + }, + "max": { + "lng": 15.0418, + "lat": 55.0565 + } + }, + "geojson_properties": { + "GID_0": "DEU", + "COUNTRY": "Germany" + } + }, + { + "id": "DJI", + "name": "Djibouti", + "bounds": { + "min": { + "lng": 41.7482, + "lat": 10.9087 + }, + "max": { + "lng": 43.4176, + "lat": 12.7068 + } + }, + "geojson_properties": { + "GID_0": "DJI", + "COUNTRY": "Djibouti" + } + }, + { + "id": "DMA", + "name": "Dominica", + "bounds": { + "min": { + "lng": -61.4801, + "lat": 15.2063 + }, + "max": { + "lng": -61.2401, + "lat": 15.6401 + } + }, + "geojson_properties": { + "GID_0": "DMA", + "COUNTRY": "Dominica" + } + }, + { + "id": "DNK", + "name": "Denmark", + "bounds": { + "min": { + "lng": 8.0764, + "lat": 54.559 + }, + "max": { + "lng": 15.1925, + "lat": 57.7515 + } + }, + "geojson_properties": { + "GID_0": "DNK", + "COUNTRY": "Denmark" + } + }, + { + "id": "DOM", + "name": "Dominican Republic", + "bounds": { + "min": { + "lng": -72.0039, + "lat": 17.4704 + }, + "max": { + "lng": -68.3226, + "lat": 19.9318 + } + }, + "geojson_properties": { + "GID_0": "DOM", + "COUNTRY": "Dominican Republic" + } + }, + { + "id": "DZA", + "name": "Algeria", + "bounds": { + "min": { + "lng": -8.6739, + "lat": 18.9602 + }, + "max": { + "lng": 11.9874, + "lat": 37.0887 + } + }, + "geojson_properties": { + "GID_0": "DZA", + "COUNTRY": "Algeria" + } + }, + { + "id": "ECU", + "name": "Ecuador", + "bounds": { + "min": { + "lng": -92.0085, + "lat": -5.0158 + }, + "max": { + "lng": -75.1871, + "lat": 1.6811 + } + }, + "geojson_properties": { + "GID_0": "ECU", + "COUNTRY": "Ecuador" + } + }, + { + "id": "EGY", + "name": "Egypt", + "bounds": { + "min": { + "lng": 24.6981, + "lat": 21.7254 + }, + "max": { + "lng": 36.2485, + "lat": 31.6674 + } + }, + "geojson_properties": { + "GID_0": "EGY", + "COUNTRY": "Egypt" + } + }, + { + "id": "ERI", + "name": "Eritrea", + "bounds": { + "min": { + "lng": 36.4388, + "lat": 12.357 + }, + "max": { + "lng": 43.1376, + "lat": 18.0067 + } + }, + "geojson_properties": { + "GID_0": "ERI", + "COUNTRY": "Eritrea" + } + }, + { + "id": "ESH", + "name": "Western Sahara", + "bounds": { + "min": { + "lng": -17.1054, + "lat": 20.7696 + }, + "max": { + "lng": -8.67, + "lat": 27.6831 + } + }, + "geojson_properties": { + "GID_0": "ESH", + "COUNTRY": "Western Sahara" + } + }, + { + "id": "ESP", + "name": "Spain", + "bounds": { + "min": { + "lng": -18.1615, + "lat": 27.6374 + }, + "max": { + "lng": 4.3282, + "lat": 43.7915 + } + }, + "geojson_properties": { + "GID_0": "ESP", + "COUNTRY": "Spain" + } + }, + { + "id": "EST", + "name": "Estonia", + "bounds": { + "min": { + "lng": 21.7643, + "lat": 57.5139 + }, + "max": { + "lng": 28.209, + "lat": 59.822 + } + }, + "geojson_properties": { + "GID_0": "EST", + "COUNTRY": "Estonia" + } + }, + { + "id": "ETH", + "name": "Ethiopia", + "bounds": { + "min": { + "lng": 33.0015, + "lat": 3.3988 + }, + "max": { + "lng": 47.9582, + "lat": 14.8455 + } + }, + "geojson_properties": { + "GID_0": "ETH", + "COUNTRY": "Ethiopia" + } + }, + { + "id": "FIN", + "name": "Finland", + "bounds": { + "min": { + "lng": 20.5512, + "lat": 59.721 + }, + "max": { + "lng": 31.5828, + "lat": 70.0915 + } + }, + "geojson_properties": { + "GID_0": "FIN", + "COUNTRY": "Finland" + } + }, + { + "id": "FJI", + "name": "Fiji", + "bounds": { + "min": { + "lng": -180, + "lat": -21.0425 + }, + "max": { + "lng": 180, + "lat": -12.4619 + } + }, + "geojson_properties": { + "GID_0": "FJI", + "COUNTRY": "Fiji" + } + }, + { + "id": "FLK", + "name": "Falkland Islands", + "bounds": { + "min": { + "lng": -61.4603, + "lat": -52.919 + }, + "max": { + "lng": -57.6869, + "lat": -50.996 + } + }, + "geojson_properties": { + "GID_0": "FLK", + "COUNTRY": "Falkland Islands" + } + }, + { + "id": "FRA", + "name": "France", + "bounds": { + "min": { + "lng": -5.1438, + "lat": 41.3338 + }, + "max": { + "lng": 9.5604, + "lat": 51.0894 + } + }, + "geojson_properties": { + "GID_0": "FRA", + "COUNTRY": "France" + } + }, + { + "id": "FRO", + "name": "Faroe Islands", + "bounds": { + "min": { + "lng": -7.6833, + "lat": 61.3937 + }, + "max": { + "lng": -6.2464, + "lat": 62.3917 + } + }, + "geojson_properties": { + "GID_0": "FRO", + "COUNTRY": "Faroe Islands" + } + }, + { + "id": "FSM", + "name": "Micronesia", + "bounds": { + "min": { + "lng": 137.4256, + "lat": 1.0253 + }, + "max": { + "lng": 163.0356, + "lat": 10.0906 + } + }, + "geojson_properties": { + "GID_0": "FSM", + "COUNTRY": "Micronesia" + } + }, + { + "id": "GAB", + "name": "Gabon", + "bounds": { + "min": { + "lng": 8.6996, + "lat": -3.9907 + }, + "max": { + "lng": 14.5023, + "lat": 2.3156 + } + }, + "geojson_properties": { + "GID_0": "GAB", + "COUNTRY": "Gabon" + } + }, + { + "id": "GBR", + "name": "United Kingdom", + "bounds": { + "min": { + "lng": -8.65, + "lat": 49.8653 + }, + "max": { + "lng": 1.7644, + "lat": 60.8454 + } + }, + "geojson_properties": { + "GID_0": "GBR", + "COUNTRY": "United Kingdom" + } + }, + { + "id": "GEO", + "name": "Georgia", + "bounds": { + "min": { + "lng": 40.0111, + "lat": 41.0385 + }, + "max": { + "lng": 46.7214, + "lat": 43.5845 + } + }, + "geojson_properties": { + "GID_0": "GEO", + "COUNTRY": "Georgia" + } + }, + { + "id": "GGY", + "name": "Guernsey", + "bounds": { + "min": { + "lng": -2.6782, + "lat": 49.3999 + }, + "max": { + "lng": -2.1557, + "lat": 49.736 + } + }, + "geojson_properties": { + "GID_0": "GGY", + "COUNTRY": "Guernsey" + } + }, + { + "id": "GHA", + "name": "Ghana", + "bounds": { + "min": { + "lng": -3.2621, + "lat": 4.7388 + }, + "max": { + "lng": 1.2002, + "lat": 11.1748 + } + }, + "geojson_properties": { + "GID_0": "GHA", + "COUNTRY": "Ghana" + } + }, + { + "id": "GIB", + "name": "Gibraltar", + "bounds": { + "min": { + "lng": -5.3676, + "lat": 36.1085 + }, + "max": { + "lng": -5.3385, + "lat": 36.1572 + } + }, + "geojson_properties": { + "GID_0": "GIB", + "COUNTRY": "Gibraltar" + } + }, + { + "id": "GIN", + "name": "Guinea", + "bounds": { + "min": { + "lng": -15.0762, + "lat": 7.1936 + }, + "max": { + "lng": -7.6411, + "lat": 12.6915 + } + }, + "geojson_properties": { + "GID_0": "GIN", + "COUNTRY": "Guinea" + } + }, + { + "id": "GLP", + "name": "Guadeloupe", + "bounds": { + "min": { + "lng": -61.8101, + "lat": 15.8315 + }, + "max": { + "lng": -61.0001, + "lat": 16.5149 + } + }, + "geojson_properties": { + "GID_0": "GLP", + "COUNTRY": "Guadeloupe" + } + }, + { + "id": "GMB", + "name": "Gambia", + "bounds": { + "min": { + "lng": -16.8174, + "lat": 13.0647 + }, + "max": { + "lng": -13.7916, + "lat": 13.8269 + } + }, + "geojson_properties": { + "GID_0": "GMB", + "COUNTRY": "Gambia" + } + }, + { + "id": "GNB", + "name": "Guinea-Bissau", + "bounds": { + "min": { + "lng": -16.7149, + "lat": 10.8643 + }, + "max": { + "lng": -13.6365, + "lat": 12.6854 + } + }, + "geojson_properties": { + "GID_0": "GNB", + "COUNTRY": "Guinea-Bissau" + } + }, + { + "id": "GNQ", + "name": "Equatorial Guinea", + "bounds": { + "min": { + "lng": 5.6164, + "lat": -1.4676 + }, + "max": { + "lng": 11.3374, + "lat": 3.7887 + } + }, + "geojson_properties": { + "GID_0": "GNQ", + "COUNTRY": "Equatorial Guinea" + } + }, + { + "id": "GRC", + "name": "Greece", + "bounds": { + "min": { + "lng": 19.3724, + "lat": 34.8007 + }, + "max": { + "lng": 29.6457, + "lat": 41.748 + } + }, + "geojson_properties": { + "GID_0": "GRC", + "COUNTRY": "Greece" + } + }, + { + "id": "GRD", + "name": "Grenada", + "bounds": { + "min": { + "lng": -61.8021, + "lat": 11.9843 + }, + "max": { + "lng": -61.3782, + "lat": 12.5396 + } + }, + "geojson_properties": { + "GID_0": "GRD", + "COUNTRY": "Grenada" + } + }, + { + "id": "GRL", + "name": "Greenland", + "bounds": { + "min": { + "lng": -73.2432, + "lat": 59.7445 + }, + "max": { + "lng": -0.013, + "lat": 83.6583 + } + }, + "geojson_properties": { + "GID_0": "GRL", + "COUNTRY": "Greenland" + } + }, + { + "id": "GTM", + "name": "Guatemala", + "bounds": { + "min": { + "lng": -92.2224, + "lat": 13.7383 + }, + "max": { + "lng": -88.2257, + "lat": 17.8165 + } + }, + "geojson_properties": { + "GID_0": "GTM", + "COUNTRY": "Guatemala" + } + }, + { + "id": "GUF", + "name": "French Guiana", + "bounds": { + "min": { + "lng": -54.5418, + "lat": 2.1287 + }, + "max": { + "lng": -51.6059, + "lat": 5.751 + } + }, + "geojson_properties": { + "GID_0": "GUF", + "COUNTRY": "French Guiana" + } + }, + { + "id": "GUM", + "name": "Guam", + "bounds": { + "min": { + "lng": 144.618, + "lat": 13.2351 + }, + "max": { + "lng": 144.9569, + "lat": 13.6542 + } + }, + "geojson_properties": { + "GID_0": "GUM", + "COUNTRY": "Guam" + } + }, + { + "id": "GUY", + "name": "Guyana", + "bounds": { + "min": { + "lng": -61.3869, + "lat": 1.1768 + }, + "max": { + "lng": -56.4803, + "lat": 8.5308 + } + }, + "geojson_properties": { + "GID_0": "GUY", + "COUNTRY": "Guyana" + } + }, + { + "id": "HMD", + "name": "Heard Island and McDonald Island", + "bounds": { + "min": { + "lng": 72.5826, + "lat": -53.195 + }, + "max": { + "lng": 73.8733, + "lat": -52.9024 + } + }, + "geojson_properties": { + "GID_0": "HMD", + "COUNTRY": "Heard Island and McDonald Island" + } + }, + { + "id": "HND", + "name": "Honduras", + "bounds": { + "min": { + "lng": -89.3508, + "lat": 12.9845 + }, + "max": { + "lng": -82.4057, + "lat": 17.4185 + } + }, + "geojson_properties": { + "GID_0": "HND", + "COUNTRY": "Honduras" + } + }, + { + "id": "HRV", + "name": "Croatia", + "bounds": { + "min": { + "lng": 13.4904, + "lat": 42.3854 + }, + "max": { + "lng": 19.4352, + "lat": 46.5505 + } + }, + "geojson_properties": { + "GID_0": "HRV", + "COUNTRY": "Croatia" + } + }, + { + "id": "HTI", + "name": "Haiti", + "bounds": { + "min": { + "lng": -74.4812, + "lat": 18.0218 + }, + "max": { + "lng": -71.6181, + "lat": 20.0904 + } + }, + "geojson_properties": { + "GID_0": "HTI", + "COUNTRY": "Haiti" + } + }, + { + "id": "HUN", + "name": "Hungary", + "bounds": { + "min": { + "lng": 16.1138, + "lat": 45.7478 + }, + "max": { + "lng": 22.9056, + "lat": 48.5864 + } + }, + "geojson_properties": { + "GID_0": "HUN", + "COUNTRY": "Hungary" + } + }, + { + "id": "IDN", + "name": "Indonesia", + "bounds": { + "min": { + "lng": 95.0097, + "lat": -11.0075 + }, + "max": { + "lng": 141.0194, + "lat": 6.0767 + } + }, + "geojson_properties": { + "GID_0": "IDN", + "COUNTRY": "Indonesia" + } + }, + { + "id": "IMN", + "name": "Isle of Man", + "bounds": { + "min": { + "lng": -4.8412, + "lat": 54.0365 + }, + "max": { + "lng": -4.3064, + "lat": 54.4179 + } + }, + "geojson_properties": { + "GID_0": "IMN", + "COUNTRY": "Isle of Man" + } + }, + { + "id": "IND", + "name": "India", + "bounds": { + "min": { + "lng": 68.1862, + "lat": 6.7543 + }, + "max": { + "lng": 97.4152, + "lat": 35.5013 + } + }, + "geojson_properties": { + "COUNTRY": "India", + "GID_0": "IND" + } + }, + { + "id": "IOT", + "name": "British Indian Ocean Territory", + "bounds": { + "min": { + "lng": 71.2376, + "lat": -7.4446 + }, + "max": { + "lng": 72.4957, + "lat": -5.236 + } + }, + "geojson_properties": { + "GID_0": "IOT", + "COUNTRY": "British Indian Ocean Territory" + } + }, + { + "id": "IRL", + "name": "Ireland", + "bounds": { + "min": { + "lng": -10.6628, + "lat": 51.4199 + }, + "max": { + "lng": -5.9945, + "lat": 55.4351 + } + }, + "geojson_properties": { + "GID_0": "IRL", + "COUNTRY": "Ireland" + } + }, + { + "id": "IRN", + "name": "Iran", + "bounds": { + "min": { + "lng": 44.0473, + "lat": 25.0587 + }, + "max": { + "lng": 63.3175, + "lat": 39.7772 + } + }, + "geojson_properties": { + "GID_0": "IRN", + "COUNTRY": "Iran" + } + }, + { + "id": "IRQ", + "name": "Iraq", + "bounds": { + "min": { + "lng": 38.7968, + "lat": 29.0586 + }, + "max": { + "lng": 48.5686, + "lat": 37.378 + } + }, + "geojson_properties": { + "GID_0": "IRQ", + "COUNTRY": "Iraq" + } + }, + { + "id": "ISL", + "name": "Iceland", + "bounds": { + "min": { + "lng": -24.5328, + "lat": 63.0958 + }, + "max": { + "lng": -12.0505, + "lat": 66.5646 + } + }, + "geojson_properties": { + "GID_0": "ISL", + "COUNTRY": "Iceland" + } + }, + { + "id": "ISR", + "name": "Israel", + "bounds": { + "min": { + "lng": 34.268, + "lat": 29.4971 + }, + "max": { + "lng": 35.9009, + "lat": 33.364 + } + }, + "geojson_properties": { + "GID_0": "ISR", + "COUNTRY": "Israel" + } + }, + { + "id": "ITA", + "name": "Italy", + "bounds": { + "min": { + "lng": 6.6309, + "lat": 35.4929 + }, + "max": { + "lng": 18.5207, + "lat": 47.0927 + } + }, + "geojson_properties": { + "GID_0": "ITA", + "COUNTRY": "Italy" + } + }, + { + "id": "JAM", + "name": "Jamaica", + "bounds": { + "min": { + "lng": -78.369, + "lat": 17.0204 + }, + "max": { + "lng": -75.9701, + "lat": 18.5251 + } + }, + "geojson_properties": { + "GID_0": "JAM", + "COUNTRY": "Jamaica" + } + }, + { + "id": "JEY", + "name": "Jersey", + "bounds": { + "min": { + "lng": -2.2551, + "lat": 49.1471 + }, + "max": { + "lng": -1.9246, + "lat": 49.2929 + } + }, + "geojson_properties": { + "GID_0": "JEY", + "COUNTRY": "Jersey" + } + }, + { + "id": "JOR", + "name": "Jordan", + "bounds": { + "min": { + "lng": 34.9576, + "lat": 29.1834 + }, + "max": { + "lng": 39.3021, + "lat": 33.3682 + } + }, + "geojson_properties": { + "GID_0": "JOR", + "COUNTRY": "Jordan" + } + }, + { + "id": "JPN", + "name": "Japan", + "bounds": { + "min": { + "lng": 122.9332, + "lat": 24.0454 + }, + "max": { + "lng": 153.9869, + "lat": 45.5227 + } + }, + "geojson_properties": { + "GID_0": "JPN", + "COUNTRY": "Japan" + } + }, + { + "id": "KAZ", + "name": "Kazakhstan", + "bounds": { + "min": { + "lng": 46.4919, + "lat": 40.5517 + }, + "max": { + "lng": 87.3127, + "lat": 55.4318 + } + }, + "geojson_properties": { + "GID_0": "KAZ", + "COUNTRY": "Kazakhstan" + } + }, + { + "id": "KEN", + "name": "Kenya", + "bounds": { + "min": { + "lng": 33.9096, + "lat": -4.7204 + }, + "max": { + "lng": 41.9262, + "lat": 5.0612 + } + }, + "geojson_properties": { + "GID_0": "KEN", + "COUNTRY": "Kenya" + } + }, + { + "id": "KGZ", + "name": "Kyrgyzstan", + "bounds": { + "min": { + "lng": 69.2797, + "lat": 39.1728 + }, + "max": { + "lng": 80.2284, + "lat": 43.2382 + } + }, + "geojson_properties": { + "GID_0": "KGZ", + "COUNTRY": "Kyrgyzstan" + } + }, + { + "id": "KHM", + "name": "Cambodia", + "bounds": { + "min": { + "lng": 102.3338, + "lat": 9.9136 + }, + "max": { + "lng": 107.6277, + "lat": 14.6903 + } + }, + "geojson_properties": { + "GID_0": "KHM", + "COUNTRY": "Cambodia" + } + }, + { + "id": "KIR", + "name": "Kiribati", + "bounds": { + "min": { + "lng": -174.5434, + "lat": -17.8521 + }, + "max": { + "lng": 176.8487, + "lat": 4.6994 + } + }, + "geojson_properties": { + "GID_0": "KIR", + "COUNTRY": "Kiribati" + } + }, + { + "id": "KNA", + "name": "Saint Kitts and Nevis", + "bounds": { + "min": { + "lng": -62.864, + "lat": 17.0935 + }, + "max": { + "lng": -62.5393, + "lat": 17.4179 + } + }, + "geojson_properties": { + "GID_0": "KNA", + "COUNTRY": "Saint Kitts and Nevis" + } + }, + { + "id": "KOR", + "name": "South Korea", + "bounds": { + "min": { + "lng": 124.6097, + "lat": 33.1126 + }, + "max": { + "lng": 131.871, + "lat": 38.6118 + } + }, + "geojson_properties": { + "GID_0": "KOR", + "COUNTRY": "South Korea" + } + }, + { + "id": "KWT", + "name": "Kuwait", + "bounds": { + "min": { + "lng": 46.5426, + "lat": 28.5244 + }, + "max": { + "lng": 48.654, + "lat": 30.0839 + } + }, + "geojson_properties": { + "GID_0": "KWT", + "COUNTRY": "Kuwait" + } + }, + { + "id": "LAO", + "name": "Laos", + "bounds": { + "min": { + "lng": 100.0868, + "lat": 13.9097 + }, + "max": { + "lng": 107.635, + "lat": 22.5004 + } + }, + "geojson_properties": { + "GID_0": "LAO", + "COUNTRY": "Laos" + } + }, + { + "id": "LBN", + "name": "Lebanon", + "bounds": { + "min": { + "lng": 35.1026, + "lat": 33.055 + }, + "max": { + "lng": 36.6218, + "lat": 34.6914 + } + }, + "geojson_properties": { + "GID_0": "LBN", + "COUNTRY": "Lebanon" + } + }, + { + "id": "LBR", + "name": "Liberia", + "bounds": { + "min": { + "lng": -11.4857, + "lat": 4.3529 + }, + "max": { + "lng": -7.3651, + "lat": 8.5518 + } + }, + "geojson_properties": { + "GID_0": "LBR", + "COUNTRY": "Liberia" + } + }, + { + "id": "LBY", + "name": "Libya", + "bounds": { + "min": { + "lng": 9.3917, + "lat": 19.5082 + }, + "max": { + "lng": 25.1482, + "lat": 33.1654 + } + }, + "geojson_properties": { + "GID_0": "LBY", + "COUNTRY": "Libya" + } + }, + { + "id": "LCA", + "name": "Saint Lucia", + "bounds": { + "min": { + "lng": -61.0801, + "lat": 13.7071 + }, + "max": { + "lng": -60.8699, + "lat": 14.1104 + } + }, + "geojson_properties": { + "GID_0": "LCA", + "COUNTRY": "Saint Lucia" + } + }, + { + "id": "LIE", + "name": "Liechtenstein", + "bounds": { + "min": { + "lng": 9.4725, + "lat": 47.0474 + }, + "max": { + "lng": 9.6359, + "lat": 47.2703 + } + }, + "geojson_properties": { + "GID_0": "LIE", + "COUNTRY": "Liechtenstein" + } + }, + { + "id": "LKA", + "name": "Sri Lanka", + "bounds": { + "min": { + "lng": 79.5218, + "lat": 5.9185 + }, + "max": { + "lng": 81.8788, + "lat": 9.8357 + } + }, + "geojson_properties": { + "GID_0": "LKA", + "COUNTRY": "Sri Lanka" + } + }, + { + "id": "LSO", + "name": "Lesotho", + "bounds": { + "min": { + "lng": 27.0113, + "lat": -30.6756 + }, + "max": { + "lng": 29.4557, + "lat": -28.5708 + } + }, + "geojson_properties": { + "GID_0": "LSO", + "COUNTRY": "Lesotho" + } + }, + { + "id": "LTU", + "name": "Lithuania", + "bounds": { + "min": { + "lng": 20.9506, + "lat": 53.8901 + }, + "max": { + "lng": 26.8472, + "lat": 56.447 + } + }, + "geojson_properties": { + "GID_0": "LTU", + "COUNTRY": "Lithuania" + } + }, + { + "id": "LUX", + "name": "Luxembourg", + "bounds": { + "min": { + "lng": 5.7441, + "lat": 49.4478 + }, + "max": { + "lng": 6.5305, + "lat": 50.1816 + } + }, + "geojson_properties": { + "GID_0": "LUX", + "COUNTRY": "Luxembourg" + } + }, + { + "id": "LVA", + "name": "Latvia", + "bounds": { + "min": { + "lng": 20.9719, + "lat": 55.6637 + }, + "max": { + "lng": 28.2405, + "lat": 58.0856 + } + }, + "geojson_properties": { + "GID_0": "LVA", + "COUNTRY": "Latvia" + } + }, + { + "id": "MAF", + "name": "Saint-Martin", + "bounds": { + "min": { + "lng": -63.1538, + "lat": 18.0454 + }, + "max": { + "lng": -62.9696, + "lat": 18.1257 + } + }, + "geojson_properties": { + "GID_0": "MAF", + "COUNTRY": "Saint-Martin" + } + }, + { + "id": "MAR", + "name": "Morocco", + "bounds": { + "min": { + "lng": -13.1679, + "lat": 27.6701 + }, + "max": { + "lng": -0.9963, + "lat": 35.9226 + } + }, + "geojson_properties": { + "GID_0": "MAR", + "COUNTRY": "Morocco" + } + }, + { + "id": "MCO", + "name": "Monaco", + "bounds": { + "min": { + "lng": 7.4095, + "lat": 43.7226 + }, + "max": { + "lng": 7.4401, + "lat": 43.7509 + } + }, + "geojson_properties": { + "GID_0": "MCO", + "COUNTRY": "Monaco" + } + }, + { + "id": "MDA", + "name": "Moldova", + "bounds": { + "min": { + "lng": 26.6213, + "lat": 45.4668 + }, + "max": { + "lng": 30.1637, + "lat": 48.4902 + } + }, + "geojson_properties": { + "GID_0": "MDA", + "COUNTRY": "Moldova" + } + }, + { + "id": "MDG", + "name": "Madagascar", + "bounds": { + "min": { + "lng": 43.1885, + "lat": -25.606 + }, + "max": { + "lng": 50.4865, + "lat": -11.9487 + } + }, + "geojson_properties": { + "GID_0": "MDG", + "COUNTRY": "Madagascar" + } + }, + { + "id": "MDV", + "name": "Maldives", + "bounds": { + "min": { + "lng": 72.6382, + "lat": -0.7032 + }, + "max": { + "lng": 73.7604, + "lat": 7.1065 + } + }, + "geojson_properties": { + "GID_0": "MDV", + "COUNTRY": "Maldives" + } + }, + { + "id": "MEX", + "name": "México", + "bounds": { + "min": { + "lng": -118.3665, + "lat": 14.5351 + }, + "max": { + "lng": -86.7107, + "lat": 32.7186 + } + }, + "geojson_properties": { + "GID_0": "MEX", + "COUNTRY": "México" + } + }, + { + "id": "MHL", + "name": "Marshall Islands", + "bounds": { + "min": { + "lng": 160.7953, + "lat": 4.5725 + }, + "max": { + "lng": 172.1726, + "lat": 14.7232 + } + }, + "geojson_properties": { + "GID_0": "MHL", + "COUNTRY": "Marshall Islands" + } + }, + { + "id": "MKD", + "name": "North Macedonia", + "bounds": { + "min": { + "lng": 20.4558, + "lat": 40.8553 + }, + "max": { + "lng": 23.0061, + "lat": 42.3386 + } + }, + "geojson_properties": { + "GID_0": "MKD", + "COUNTRY": "North Macedonia" + } + }, + { + "id": "MLI", + "name": "Mali", + "bounds": { + "min": { + "lng": -12.2389, + "lat": 10.1595 + }, + "max": { + "lng": 4.245, + "lat": 25 + } + }, + "geojson_properties": { + "GID_0": "MLI", + "COUNTRY": "Mali" + } + }, + { + "id": "MLT", + "name": "Malta", + "bounds": { + "min": { + "lng": 14.1843, + "lat": 35.786 + }, + "max": { + "lng": 14.5765, + "lat": 36.0824 + } + }, + "geojson_properties": { + "GID_0": "MLT", + "COUNTRY": "Malta" + } + }, + { + "id": "MMR", + "name": "Myanmar", + "bounds": { + "min": { + "lng": 92.1725, + "lat": 8.8244 + }, + "max": { + "lng": 101.1768, + "lat": 28.5433 + } + }, + "geojson_properties": { + "GID_0": "MMR", + "COUNTRY": "Myanmar" + } + }, + { + "id": "MNE", + "name": "Montenegro", + "bounds": { + "min": { + "lng": 18.4589, + "lat": 41.8479 + }, + "max": { + "lng": 20.3176, + "lat": 43.5626 + } + }, + "geojson_properties": { + "GID_0": "MNE", + "COUNTRY": "Montenegro" + } + }, + { + "id": "MNG", + "name": "Mongolia", + "bounds": { + "min": { + "lng": 87.7497, + "lat": 41.5677 + }, + "max": { + "lng": 119.9243, + "lat": 52.1543 + } + }, + "geojson_properties": { + "GID_0": "MNG", + "COUNTRY": "Mongolia" + } + }, + { + "id": "MNP", + "name": "Northern Mariana Islands", + "bounds": { + "min": { + "lng": 144.885, + "lat": 14.1103 + }, + "max": { + "lng": 146.066, + "lat": 20.555 + } + }, + "geojson_properties": { + "GID_0": "MNP", + "COUNTRY": "Northern Mariana Islands" + } + }, + { + "id": "MOZ", + "name": "Mozambique", + "bounds": { + "min": { + "lng": 30.2194, + "lat": -26.8687 + }, + "max": { + "lng": 40.8393, + "lat": -10.4712 + } + }, + "geojson_properties": { + "GID_0": "MOZ", + "COUNTRY": "Mozambique" + } + }, + { + "id": "MRT", + "name": "Mauritania", + "bounds": { + "min": { + "lng": -17.0665, + "lat": 14.7156 + }, + "max": { + "lng": -4.83, + "lat": 27.2981 + } + }, + "geojson_properties": { + "GID_0": "MRT", + "COUNTRY": "Mauritania" + } + }, + { + "id": "MSR", + "name": "Montserrat", + "bounds": { + "min": { + "lng": -62.2418, + "lat": 16.6749 + }, + "max": { + "lng": -62.1446, + "lat": 16.8243 + } + }, + "geojson_properties": { + "GID_0": "MSR", + "COUNTRY": "Montserrat" + } + }, + { + "id": "MTQ", + "name": "Martinique", + "bounds": { + "min": { + "lng": -61.229, + "lat": 14.3882 + }, + "max": { + "lng": -60.8087, + "lat": 14.879 + } + }, + "geojson_properties": { + "GID_0": "MTQ", + "COUNTRY": "Martinique" + } + }, + { + "id": "MUS", + "name": "Mauritius", + "bounds": { + "min": { + "lng": 56.5857, + "lat": -20.5257 + }, + "max": { + "lng": 63.5032, + "lat": -10.3371 + } + }, + "geojson_properties": { + "GID_0": "MUS", + "COUNTRY": "Mauritius" + } + }, + { + "id": "MWI", + "name": "Malawi", + "bounds": { + "min": { + "lng": 32.6898, + "lat": -17.1272 + }, + "max": { + "lng": 35.915, + "lat": -9.3638 + } + }, + "geojson_properties": { + "GID_0": "MWI", + "COUNTRY": "Malawi" + } + }, + { + "id": "MYS", + "name": "Malaysia", + "bounds": { + "min": { + "lng": 99.6408, + "lat": 0.8537 + }, + "max": { + "lng": 119.2694, + "lat": 7.3806 + } + }, + "geojson_properties": { + "GID_0": "MYS", + "COUNTRY": "Malaysia" + } + }, + { + "id": "MYT", + "name": "Mayotte", + "bounds": { + "min": { + "lng": 45.0179, + "lat": -13.0063 + }, + "max": { + "lng": 45.3001, + "lat": -12.6362 + } + }, + "geojson_properties": { + "GID_0": "MYT", + "COUNTRY": "Mayotte" + } + }, + { + "id": "NAM", + "name": "Namibia", + "bounds": { + "min": { + "lng": 11.7349, + "lat": -28.9694 + }, + "max": { + "lng": 25.2677, + "lat": -16.9599 + } + }, + "geojson_properties": { + "GID_0": "NAM", + "COUNTRY": "Namibia" + } + }, + { + "id": "NCL", + "name": "New Caledonia", + "bounds": { + "min": { + "lng": 155.8692, + "lat": -22.8481 + }, + "max": { + "lng": 172.0901, + "lat": -17.3992 + } + }, + "geojson_properties": { + "GID_0": "NCL", + "COUNTRY": "New Caledonia" + } + }, + { + "id": "NER", + "name": "Niger", + "bounds": { + "min": { + "lng": 0.1663, + "lat": 11.697 + }, + "max": { + "lng": 15.9956, + "lat": 23.525 + } + }, + "geojson_properties": { + "GID_0": "NER", + "COUNTRY": "Niger" + } + }, + { + "id": "NFK", + "name": "Norfolk Island", + "bounds": { + "min": { + "lng": 167.9139, + "lat": -29.1331 + }, + "max": { + "lng": 167.9981, + "lat": -28.9939 + } + }, + "geojson_properties": { + "GID_0": "NFK", + "COUNTRY": "Norfolk Island" + } + }, + { + "id": "NGA", + "name": "Nigeria", + "bounds": { + "min": { + "lng": 2.6684, + "lat": 4.2707 + }, + "max": { + "lng": 14.6762, + "lat": 13.892 + } + }, + "geojson_properties": { + "GID_0": "NGA", + "COUNTRY": "Nigeria" + } + }, + { + "id": "NIC", + "name": "Nicaragua", + "bounds": { + "min": { + "lng": -87.691, + "lat": 10.7074 + }, + "max": { + "lng": -81.9999, + "lat": 15.0259 + } + }, + "geojson_properties": { + "GID_0": "NIC", + "COUNTRY": "Nicaragua" + } + }, + { + "id": "NIU", + "name": "Niue", + "bounds": { + "min": { + "lng": -169.9508, + "lat": -19.1561 + }, + "max": { + "lng": -169.7736, + "lat": -18.9522 + } + }, + "geojson_properties": { + "GID_0": "NIU", + "COUNTRY": "Niue" + } + }, + { + "id": "NLD", + "name": "Netherlands", + "bounds": { + "min": { + "lng": 3.3608, + "lat": 50.7235 + }, + "max": { + "lng": 7.227, + "lat": 53.5546 + } + }, + "geojson_properties": { + "GID_0": "NLD", + "COUNTRY": "Netherlands" + } + }, + { + "id": "NOR", + "name": "Norway", + "bounds": { + "min": { + "lng": 3.9047, + "lat": 57.959 + }, + "max": { + "lng": 31.162, + "lat": 71.1813 + } + }, + "geojson_properties": { + "GID_0": "NOR", + "COUNTRY": "Norway" + } + }, + { + "id": "NPL", + "name": "Nepal", + "bounds": { + "min": { + "lng": 80.0601, + "lat": 26.3475 + }, + "max": { + "lng": 88.204, + "lat": 30.447 + } + }, + "geojson_properties": { + "GID_0": "NPL", + "COUNTRY": "Nepal" + } + }, + { + "id": "NRU", + "name": "Nauru", + "bounds": { + "min": { + "lng": 166.9097, + "lat": -0.5536 + }, + "max": { + "lng": 166.9593, + "lat": -0.5021 + } + }, + "geojson_properties": { + "GID_0": "NRU", + "COUNTRY": "Nauru" + } + }, + { + "id": "NZL", + "name": "New Zealand", + "bounds": { + "min": { + "lng": -178.827, + "lat": -52.6208 + }, + "max": { + "lng": 179.0658, + "lat": -29.2318 + } + }, + "geojson_properties": { + "GID_0": "NZL", + "COUNTRY": "New Zealand" + } + }, + { + "id": "OMN", + "name": "Oman", + "bounds": { + "min": { + "lng": 52, + "lat": 16.6424 + }, + "max": { + "lng": 59.8393, + "lat": 26.5068 + } + }, + "geojson_properties": { + "GID_0": "OMN", + "COUNTRY": "Oman" + } + }, + { + "id": "PAK", + "name": "Pakistan", + "bounds": { + "min": { + "lng": 60.8994, + "lat": 23.7029 + }, + "max": { + "lng": 75.367, + "lat": 36.8898 + } + }, + "geojson_properties": { + "GID_0": "PAK", + "COUNTRY": "Pakistan" + } + }, + { + "id": "PAN", + "name": "Panama", + "bounds": { + "min": { + "lng": -83.0519, + "lat": 7.2024 + }, + "max": { + "lng": -77.1293, + "lat": 9.6474 + } + }, + "geojson_properties": { + "GID_0": "PAN", + "COUNTRY": "Panama" + } + }, + { + "id": "PCN", + "name": "Pitcairn Islands", + "bounds": { + "min": { + "lng": -130.7558, + "lat": -25.0799 + }, + "max": { + "lng": -124.7719, + "lat": -23.9079 + } + }, + "geojson_properties": { + "GID_0": "PCN", + "COUNTRY": "Pitcairn Islands" + } + }, + { + "id": "PER", + "name": "Peru", + "bounds": { + "min": { + "lng": -81.3307, + "lat": -18.3518 + }, + "max": { + "lng": -68.6522, + "lat": -0.039 + } + }, + "geojson_properties": { + "GID_0": "PER", + "COUNTRY": "Peru" + } + }, + { + "id": "PHL", + "name": "Philippines", + "bounds": { + "min": { + "lng": 116.9283, + "lat": 4.5878 + }, + "max": { + "lng": 126.6053, + "lat": 21.0699 + } + }, + "geojson_properties": { + "GID_0": "PHL", + "COUNTRY": "Philippines" + } + }, + { + "id": "PLW", + "name": "Palau", + "bounds": { + "min": { + "lng": 131.1199, + "lat": 2.9714 + }, + "max": { + "lng": 134.7214, + "lat": 8.0942 + } + }, + "geojson_properties": { + "GID_0": "PLW", + "COUNTRY": "Palau" + } + }, + { + "id": "PNG", + "name": "Papua New Guinea", + "bounds": { + "min": { + "lng": 140.8405, + "lat": -11.6554 + }, + "max": { + "lng": 157.0377, + "lat": -0.7564 + } + }, + "geojson_properties": { + "GID_0": "PNG", + "COUNTRY": "Papua New Guinea" + } + }, + { + "id": "POL", + "name": "Poland", + "bounds": { + "min": { + "lng": 14.1229, + "lat": 49.0024 + }, + "max": { + "lng": 24.1455, + "lat": 54.8364 + } + }, + "geojson_properties": { + "GID_0": "POL", + "COUNTRY": "Poland" + } + }, + { + "id": "PRI", + "name": "Puerto Rico", + "bounds": { + "min": { + "lng": -67.9518, + "lat": 17.8826 + }, + "max": { + "lng": -64.9999, + "lat": 18.516 + } + }, + "geojson_properties": { + "GID_0": "PRI", + "COUNTRY": "Puerto Rico" + } + }, + { + "id": "PRK", + "name": "North Korea", + "bounds": { + "min": { + "lng": 124.1824, + "lat": 37.676 + }, + "max": { + "lng": 130.6744, + "lat": 43.0061 + } + }, + "geojson_properties": { + "GID_0": "PRK", + "COUNTRY": "North Korea" + } + }, + { + "id": "PRT", + "name": "Portugal", + "bounds": { + "min": { + "lng": -31.2682, + "lat": 30.0302 + }, + "max": { + "lng": -6.1891, + "lat": 42.1543 + } + }, + "geojson_properties": { + "GID_0": "PRT", + "COUNTRY": "Portugal" + } + }, + { + "id": "PRY", + "name": "Paraguay", + "bounds": { + "min": { + "lng": -62.6424, + "lat": -27.6057 + }, + "max": { + "lng": -54.2586, + "lat": -19.2952 + } + }, + "geojson_properties": { + "GID_0": "PRY", + "COUNTRY": "Paraguay" + } + }, + { + "id": "PSE", + "name": "Palestine", + "bounds": { + "min": { + "lng": 34.2293, + "lat": 31.2236 + }, + "max": { + "lng": 35.5755, + "lat": 32.5516 + } + }, + "geojson_properties": { + "GID_0": "PSE", + "COUNTRY": "Palestine" + } + }, + { + "id": "PYF", + "name": "French Polynesia", + "bounds": { + "min": { + "lng": -154.7272, + "lat": -27.9006 + }, + "max": { + "lng": -134.4511, + "lat": -7.895 + } + }, + "geojson_properties": { + "GID_0": "PYF", + "COUNTRY": "French Polynesia" + } + }, + { + "id": "QAT", + "name": "Qatar", + "bounds": { + "min": { + "lng": 50.7365, + "lat": 24.4708 + }, + "max": { + "lng": 51.6474, + "lat": 26.1835 + } + }, + "geojson_properties": { + "GID_0": "QAT", + "COUNTRY": "Qatar" + } + }, + { + "id": "REU", + "name": "Réunion", + "bounds": { + "min": { + "lng": 55.2163, + "lat": -21.3899 + }, + "max": { + "lng": 55.8374, + "lat": -20.8718 + } + }, + "geojson_properties": { + "GID_0": "REU", + "COUNTRY": "Réunion" + } + }, + { + "id": "ROU", + "name": "Romania", + "bounds": { + "min": { + "lng": 20.2638, + "lat": 43.6193 + }, + "max": { + "lng": 29.7199, + "lat": 48.2656 + } + }, + "geojson_properties": { + "GID_0": "ROU", + "COUNTRY": "Romania" + } + }, + { + "id": "RUS", + "name": "Russia", + "bounds": { + "min": { + "lng": -180, + "lat": 41.1889 + }, + "max": { + "lng": 180, + "lat": 81.8562 + } + }, + "geojson_properties": { + "GID_0": "RUS", + "COUNTRY": "Russia" + } + }, + { + "id": "RWA", + "name": "Rwanda", + "bounds": { + "min": { + "lng": 28.8617, + "lat": -2.84 + }, + "max": { + "lng": 30.8991, + "lat": -1.0476 + } + }, + "geojson_properties": { + "GID_0": "RWA", + "COUNTRY": "Rwanda" + } + }, + { + "id": "SAU", + "name": "Saudi Arabia", + "bounds": { + "min": { + "lng": 34.4944, + "lat": 16.3795 + }, + "max": { + "lng": 55.6667, + "lat": 32.1543 + } + }, + "geojson_properties": { + "GID_0": "SAU", + "COUNTRY": "Saudi Arabia" + } + }, + { + "id": "SDN", + "name": "Sudan", + "bounds": { + "min": { + "lng": 21.8389, + "lat": 8.6791 + }, + "max": { + "lng": 38.849, + "lat": 23.1451 + } + }, + "geojson_properties": { + "GID_0": "SDN", + "COUNTRY": "Sudan" + } + }, + { + "id": "SEN", + "name": "Senegal", + "bounds": { + "min": { + "lng": -17.5429, + "lat": 12.3079 + }, + "max": { + "lng": -11.3426, + "lat": 16.6921 + } + }, + "geojson_properties": { + "GID_0": "SEN", + "COUNTRY": "Senegal" + } + }, + { + "id": "SGP", + "name": "Singapore", + "bounds": { + "min": { + "lng": 103.6091, + "lat": 1.1664 + }, + "max": { + "lng": 104.0858, + "lat": 1.4714 + } + }, + "geojson_properties": { + "GID_0": "SGP", + "COUNTRY": "Singapore" + } + }, + { + "id": "SGS", + "name": "South Georgia and the South Sand", + "bounds": { + "min": { + "lng": -41.8156, + "lat": -59.4843 + }, + "max": { + "lng": -26.2293, + "lat": -53.6469 + } + }, + "geojson_properties": { + "GID_0": "SGS", + "COUNTRY": "South Georgia and the South Sand" + } + }, + { + "id": "SHN", + "name": "Saint Helena, Ascension and Tris", + "bounds": { + "min": { + "lng": -14.4211, + "lat": -40.3713 + }, + "max": { + "lng": -5.6315, + "lat": -7.8894 + } + }, + "geojson_properties": { + "GID_0": "SHN", + "COUNTRY": "Saint Helena, Ascension and Tris" + } + }, + { + "id": "SJM", + "name": "Svalbard and Jan Mayen", + "bounds": { + "min": { + "lng": -9.0766, + "lat": 70.825 + }, + "max": { + "lng": 36.8078, + "lat": 82.5375 + } + }, + "geojson_properties": { + "GID_0": "SJM", + "COUNTRY": "Svalbard and Jan Mayen" + } + }, + { + "id": "SLB", + "name": "Solomon Islands", + "bounds": { + "min": { + "lng": 155.3931, + "lat": -12.3083 + }, + "max": { + "lng": 170.1923, + "lat": -4.4452 + } + }, + "geojson_properties": { + "GID_0": "SLB", + "COUNTRY": "Solomon Islands" + } + }, + { + "id": "SLE", + "name": "Sierra Leone", + "bounds": { + "min": { + "lng": -13.3035, + "lat": 6.9176 + }, + "max": { + "lng": -10.2658, + "lat": 10.0004 + } + }, + "geojson_properties": { + "GID_0": "SLE", + "COUNTRY": "Sierra Leone" + } + }, + { + "id": "SLV", + "name": "El Salvador", + "bounds": { + "min": { + "lng": -90.1249, + "lat": 13.1526 + }, + "max": { + "lng": -87.684, + "lat": 14.4506 + } + }, + "geojson_properties": { + "GID_0": "SLV", + "COUNTRY": "El Salvador" + } + }, + { + "id": "SMR", + "name": "San Marino", + "bounds": { + "min": { + "lng": 12.4012, + "lat": 43.8949 + }, + "max": { + "lng": 12.5141, + "lat": 43.9955 + } + }, + "geojson_properties": { + "GID_0": "SMR", + "COUNTRY": "San Marino" + } + }, + { + "id": "SOM", + "name": "Somalia", + "bounds": { + "min": { + "lng": 40.9785, + "lat": -1.6471 + }, + "max": { + "lng": 51.4157, + "lat": 11.9885 + } + }, + "geojson_properties": { + "GID_0": "SOM", + "COUNTRY": "Somalia" + } + }, + { + "id": "SPM", + "name": "Saint Pierre and Miquelon", + "bounds": { + "min": { + "lng": -56.4061, + "lat": 46.7489 + }, + "max": { + "lng": -56.1264, + "lat": 47.145 + } + }, + "geojson_properties": { + "GID_0": "SPM", + "COUNTRY": "Saint Pierre and Miquelon" + } + }, + { + "id": "SRB", + "name": "Serbia", + "bounds": { + "min": { + "lng": 18.8087, + "lat": 42.2276 + }, + "max": { + "lng": 22.9789, + "lat": 46.1894 + } + }, + "geojson_properties": { + "GID_0": "SRB", + "COUNTRY": "Serbia" + } + }, + { + "id": "SSD", + "name": "South Sudan", + "bounds": { + "min": { + "lng": 24.1519, + "lat": 3.481 + }, + "max": { + "lng": 35.8699, + "lat": 12.219 + } + }, + "geojson_properties": { + "GID_0": "SSD", + "COUNTRY": "South Sudan" + } + }, + { + "id": "STP", + "name": "São Tomé and Príncipe", + "bounds": { + "min": { + "lng": 6.4599, + "lat": -0.0135 + }, + "max": { + "lng": 7.4626, + "lat": 1.7015 + } + }, + "geojson_properties": { + "GID_0": "STP", + "COUNTRY": "São Tomé and Príncipe" + } + }, + { + "id": "SUR", + "name": "Suriname", + "bounds": { + "min": { + "lng": -58.0866, + "lat": 1.8311 + }, + "max": { + "lng": -53.9775, + "lat": 6.0151 + } + }, + "geojson_properties": { + "GID_0": "SUR", + "COUNTRY": "Suriname" + } + }, + { + "id": "SVK", + "name": "Slovakia", + "bounds": { + "min": { + "lng": 16.8345, + "lat": 47.7327 + }, + "max": { + "lng": 22.5678, + "lat": 49.6138 + } + }, + "geojson_properties": { + "GID_0": "SVK", + "COUNTRY": "Slovakia" + } + }, + { + "id": "SVN", + "name": "Slovenia", + "bounds": { + "min": { + "lng": 13.3821, + "lat": 45.4283 + }, + "max": { + "lng": 16.5843, + "lat": 46.8782 + } + }, + "geojson_properties": { + "GID_0": "SVN", + "COUNTRY": "Slovenia" + } + }, + { + "id": "SWE", + "name": "Sweden", + "bounds": { + "min": { + "lng": 10.9614, + "lat": 55.3363 + }, + "max": { + "lng": 24.1724, + "lat": 69.059 + } + }, + "geojson_properties": { + "GID_0": "SWE", + "COUNTRY": "Sweden" + } + }, + { + "id": "SWZ", + "name": "Swaziland", + "bounds": { + "min": { + "lng": 30.7914, + "lat": -27.3175 + }, + "max": { + "lng": 32.1367, + "lat": -25.7188 + } + }, + "geojson_properties": { + "GID_0": "SWZ", + "COUNTRY": "Swaziland" + } + }, + { + "id": "SXM", + "name": "Sint Maarten", + "bounds": { + "min": { + "lng": -63.1394, + "lat": 17.9999 + }, + "max": { + "lng": -62.9999, + "lat": 18.0635 + } + }, + "geojson_properties": { + "GID_0": "SXM", + "COUNTRY": "Sint Maarten" + } + }, + { + "id": "SYC", + "name": "Seychelles", + "bounds": { + "min": { + "lng": 46.2037, + "lat": -10.2274 + }, + "max": { + "lng": 56.2957, + "lat": -3.7126 + } + }, + "geojson_properties": { + "GID_0": "SYC", + "COUNTRY": "Seychelles" + } + }, + { + "id": "SYR", + "name": "Syria", + "bounds": { + "min": { + "lng": 35.7163, + "lat": 32.3107 + }, + "max": { + "lng": 42.385, + "lat": 37.3191 + } + }, + "geojson_properties": { + "GID_0": "SYR", + "COUNTRY": "Syria" + } + }, + { + "id": "TCA", + "name": "Turks and Caicos Islands", + "bounds": { + "min": { + "lng": -72.4829, + "lat": 21.1774 + }, + "max": { + "lng": -71.0821, + "lat": 21.9626 + } + }, + "geojson_properties": { + "GID_0": "TCA", + "COUNTRY": "Turks and Caicos Islands" + } + }, + { + "id": "TCD", + "name": "Chad", + "bounds": { + "min": { + "lng": 13.4735, + "lat": 7.4411 + }, + "max": { + "lng": 24.0025, + "lat": 23.4504 + } + }, + "geojson_properties": { + "GID_0": "TCD", + "COUNTRY": "Chad" + } + }, + { + "id": "TGO", + "name": "Togo", + "bounds": { + "min": { + "lng": -0.144, + "lat": 6.1115 + }, + "max": { + "lng": 1.8075, + "lat": 11.138 + } + }, + "geojson_properties": { + "GID_0": "TGO", + "COUNTRY": "Togo" + } + }, + { + "id": "THA", + "name": "Thailand", + "bounds": { + "min": { + "lng": 97.3455, + "lat": 5.616 + }, + "max": { + "lng": 105.6391, + "lat": 20.4631 + } + }, + "geojson_properties": { + "GID_0": "THA", + "COUNTRY": "Thailand" + } + }, + { + "id": "TJK", + "name": "Tajikistan", + "bounds": { + "min": { + "lng": 67.3871, + "lat": 36.6721 + }, + "max": { + "lng": 75.1372, + "lat": 41.0422 + } + }, + "geojson_properties": { + "GID_0": "TJK", + "COUNTRY": "Tajikistan" + } + }, + { + "id": "TKL", + "name": "Tokelau", + "bounds": { + "min": { + "lng": -172.5206, + "lat": -9.4436 + }, + "max": { + "lng": -171.1814, + "lat": -8.5322 + } + }, + "geojson_properties": { + "GID_0": "TKL", + "COUNTRY": "Tokelau" + } + }, + { + "id": "TKM", + "name": "Turkmenistan", + "bounds": { + "min": { + "lng": 52.4414, + "lat": 35.1298 + }, + "max": { + "lng": 66.6843, + "lat": 42.7956 + } + }, + "geojson_properties": { + "GID_0": "TKM", + "COUNTRY": "Turkmenistan" + } + }, + { + "id": "TLS", + "name": "Timor-Leste", + "bounds": { + "min": { + "lng": 124.045, + "lat": -9.5047 + }, + "max": { + "lng": 127.3425, + "lat": -8.1269 + } + }, + "geojson_properties": { + "GID_0": "TLS", + "COUNTRY": "Timor-Leste" + } + }, + { + "id": "TON", + "name": "Tonga", + "bounds": { + "min": { + "lng": -176.2138, + "lat": -22.3494 + }, + "max": { + "lng": -173.735, + "lat": -15.5658 + } + }, + "geojson_properties": { + "GID_0": "TON", + "COUNTRY": "Tonga" + } + }, + { + "id": "TTO", + "name": "Trinidad and Tobago", + "bounds": { + "min": { + "lng": -61.9301, + "lat": 10.0429 + }, + "max": { + "lng": -60.4921, + "lat": 11.3596 + } + }, + "geojson_properties": { + "GID_0": "TTO", + "COUNTRY": "Trinidad and Tobago" + } + }, + { + "id": "TUN", + "name": "Tunisia", + "bounds": { + "min": { + "lng": 7.5301, + "lat": 30.2368 + }, + "max": { + "lng": 11.5983, + "lat": 37.5599 + } + }, + "geojson_properties": { + "GID_0": "TUN", + "COUNTRY": "Tunisia" + } + }, + { + "id": "TUR", + "name": "Turkey", + "bounds": { + "min": { + "lng": 25.6651, + "lat": 35.8154 + }, + "max": { + "lng": 44.835, + "lat": 42.1067 + } + }, + "geojson_properties": { + "GID_0": "TUR", + "COUNTRY": "Turkey" + } + }, + { + "id": "TUV", + "name": "Tuvalu", + "bounds": { + "min": { + "lng": 176.0589, + "lat": -10.7918 + }, + "max": { + "lng": 179.8709, + "lat": -5.6428 + } + }, + "geojson_properties": { + "GID_0": "TUV", + "COUNTRY": "Tuvalu" + } + }, + { + "id": "TWN", + "name": "Taiwan", + "bounds": { + "min": { + "lng": 116.71, + "lat": 20.6975 + }, + "max": { + "lng": 122.1085, + "lat": 26.3854 + } + }, + "geojson_properties": { + "GID_0": "TWN", + "COUNTRY": "Taiwan" + } + }, + { + "id": "TZA", + "name": "Tanzania", + "bounds": { + "min": { + "lng": 29.3272, + "lat": -11.7457 + }, + "max": { + "lng": 40.4451, + "lat": -0.9858 + } + }, + "geojson_properties": { + "GID_0": "TZA", + "COUNTRY": "Tanzania" + } + }, + { + "id": "UGA", + "name": "Uganda", + "bounds": { + "min": { + "lng": 29.5715, + "lat": -1.4821 + }, + "max": { + "lng": 35.0003, + "lat": 4.2345 + } + }, + "geojson_properties": { + "GID_0": "UGA", + "COUNTRY": "Uganda" + } + }, + { + "id": "UKR", + "name": "Ukraine", + "bounds": { + "min": { + "lng": 22.1404, + "lat": 44.386 + }, + "max": { + "lng": 40.2181, + "lat": 52.375 + } + }, + "geojson_properties": { + "GID_0": "UKR", + "COUNTRY": "Ukraine" + } + }, + { + "id": "UMI", + "name": "United States Minor Outlying Isl", + "bounds": { + "min": { + "lng": -178.3036, + "lat": -0.389 + }, + "max": { + "lng": 166.6572, + "lat": 28.4017 + } + }, + "geojson_properties": { + "GID_0": "UMI", + "COUNTRY": "United States Minor Outlying Isl" + } + }, + { + "id": "URY", + "name": "Uruguay", + "bounds": { + "min": { + "lng": -58.4406, + "lat": -34.974 + }, + "max": { + "lng": -53.0942, + "lat": -30.0855 + } + }, + "geojson_properties": { + "GID_0": "URY", + "COUNTRY": "Uruguay" + } + }, + { + "id": "USA", + "name": "United States", + "bounds": { + "min": { + "lng": -179.1506, + "lat": 18.9099 + }, + "max": { + "lng": 179.7734, + "lat": 72.6875 + } + }, + "geojson_properties": { + "GID_0": "USA", + "COUNTRY": "United States" + } + }, + { + "id": "UZB", + "name": "Uzbekistan", + "bounds": { + "min": { + "lng": 55.9981, + "lat": 37.1772 + }, + "max": { + "lng": 73.1346, + "lat": 45.5711 + } + }, + "geojson_properties": { + "GID_0": "UZB", + "COUNTRY": "Uzbekistan" + } + }, + { + "id": "VAT", + "name": "Vatican City", + "bounds": { + "min": { + "lng": 12.4456, + "lat": 41.9001 + }, + "max": { + "lng": 12.4579, + "lat": 41.9076 + } + }, + "geojson_properties": { + "GID_0": "VAT", + "COUNTRY": "Vatican City" + } + }, + { + "id": "VCT", + "name": "Saint Vincent and the Grenadines", + "bounds": { + "min": { + "lng": -61.461, + "lat": 12.5787 + }, + "max": { + "lng": -61.114, + "lat": 13.3835 + } + }, + "geojson_properties": { + "GID_0": "VCT", + "COUNTRY": "Saint Vincent and the Grenadines" + } + }, + { + "id": "VEN", + "name": "Venezuela", + "bounds": { + "min": { + "lng": -73.3521, + "lat": 0.6488 + }, + "max": { + "lng": -59.8072, + "lat": 15.6729 + } + }, + "geojson_properties": { + "GID_0": "VEN", + "COUNTRY": "Venezuela" + } + }, + { + "id": "VGB", + "name": "British Virgin Islands", + "bounds": { + "min": { + "lng": -64.8501, + "lat": 18.306 + }, + "max": { + "lng": -64.2704, + "lat": 18.7488 + } + }, + "geojson_properties": { + "GID_0": "VGB", + "COUNTRY": "British Virgin Islands" + } + }, + { + "id": "VIR", + "name": "Virgin Islands, U.S.", + "bounds": { + "min": { + "lng": -65.0865, + "lat": 17.6729 + }, + "max": { + "lng": -64.5649, + "lat": 18.4157 + } + }, + "geojson_properties": { + "GID_0": "VIR", + "COUNTRY": "Virgin Islands, U.S." + } + }, + { + "id": "VNM", + "name": "Vietnam", + "bounds": { + "min": { + "lng": 102.1446, + "lat": 8.3814 + }, + "max": { + "lng": 109.469, + "lat": 23.3927 + } + }, + "geojson_properties": { + "GID_0": "VNM", + "COUNTRY": "Vietnam" + } + }, + { + "id": "VUT", + "name": "Vanuatu", + "bounds": { + "min": { + "lng": 166.5414, + "lat": -20.2532 + }, + "max": { + "lng": 170.24, + "lat": -13.0731 + } + }, + "geojson_properties": { + "GID_0": "VUT", + "COUNTRY": "Vanuatu" + } + }, + { + "id": "WLF", + "name": "Wallis and Futuna", + "bounds": { + "min": { + "lng": -178.1825, + "lat": -14.3691 + }, + "max": { + "lng": -176.1247, + "lat": -13.1828 + } + }, + "geojson_properties": { + "GID_0": "WLF", + "COUNTRY": "Wallis and Futuna" + } + }, + { + "id": "WSM", + "name": "Samoa", + "bounds": { + "min": { + "lng": -172.804, + "lat": -14.0772 + }, + "max": { + "lng": -171.398, + "lat": -13.4398 + } + }, + "geojson_properties": { + "GID_0": "WSM", + "COUNTRY": "Samoa" + } + }, + { + "id": "YEM", + "name": "Yemen", + "bounds": { + "min": { + "lng": 41.8146, + "lat": 12.1082 + }, + "max": { + "lng": 54.5354, + "lat": 19 + } + }, + "geojson_properties": { + "GID_0": "YEM", + "COUNTRY": "Yemen" + } + }, + { + "id": "ZAF", + "name": "South Africa", + "bounds": { + "min": { + "lng": 16.4519, + "lat": -34.8351 + }, + "max": { + "lng": 32.8913, + "lat": -22.1251 + } + }, + "geojson_properties": { + "GID_0": "ZAF", + "COUNTRY": "South Africa" + } + }, + { + "id": "ZMB", + "name": "Zambia", + "bounds": { + "min": { + "lng": 21.98, + "lat": -18.0792 + }, + "max": { + "lng": 33.7124, + "lat": -8.272 + } + }, + "geojson_properties": { + "GID_0": "ZMB", + "COUNTRY": "Zambia" + } + }, + { + "id": "ZWE", + "name": "Zimbabwe", + "bounds": { + "min": { + "lng": 25.238, + "lat": -22.4196 + }, + "max": { + "lng": 33.055, + "lat": -15.6073 + } + }, + "geojson_properties": { + "GID_0": "ZWE", + "COUNTRY": "Zimbabwe" + } + } +] diff --git a/tests/fixtures/data/model/2026.05.08/admin0.parquet b/tests/fixtures/data/model/2026.05.08/admin0.parquet new file mode 100644 index 0000000..e84c150 Binary files /dev/null and b/tests/fixtures/data/model/2026.05.08/admin0.parquet differ diff --git a/tests/fixtures/data/model/2026.05.08/admin1.parquet b/tests/fixtures/data/model/2026.05.08/admin1.parquet new file mode 100644 index 0000000..77ed2d6 Binary files /dev/null and b/tests/fixtures/data/model/2026.05.08/admin1.parquet differ diff --git a/tests/fixtures/data/model/2026.05.08/admin2.parquet b/tests/fixtures/data/model/2026.05.08/admin2.parquet new file mode 100644 index 0000000..f28e80b Binary files /dev/null and b/tests/fixtures/data/model/2026.05.08/admin2.parquet differ diff --git a/tests/fixtures/data/model/2026.05.08/metadata.json b/tests/fixtures/data/model/2026.05.08/metadata.json new file mode 100644 index 0000000..3c1125b --- /dev/null +++ b/tests/fixtures/data/model/2026.05.08/metadata.json @@ -0,0 +1,4 @@ +{ + "version": "2026.05.08", + "data_release": "2026.03.17" +} diff --git a/tests/fixtures/data/stave/2026.03.17/survey_data.parquet b/tests/fixtures/data/stave/2026.03.17/survey_data.parquet new file mode 100644 index 0000000..f9e01c1 Binary files /dev/null and b/tests/fixtures/data/stave/2026.03.17/survey_data.parquet differ diff --git a/tests/fixtures/fixture-config.json b/tests/fixtures/fixture-config.json new file mode 100644 index 0000000..03fc2bc --- /dev/null +++ b/tests/fixtures/fixture-config.json @@ -0,0 +1,30 @@ +{ + "modelRelease": "2026.05.08", + "dataRelease": "2026.03.17", + "countries": [ + "MLI", + "ETH" + ], + "variantWindows": [ + { + "variant": "crt:76:K", + "gene": "crt", + "mutation": "76K", + "start": "2023-05-01", + "end": "2025-05-01" + }, + { + "variant": "k13:469:Y", + "gene": "k13", + "mutation": "469Y", + "start": "2024-05-01", + "end": "2025-05-01" + } + ], + "surveyIds": [ + "s0136_Bunkpurugu_period1", + "s0006_kisii_period2", + "s0002_Gondar_Zuria", + "WWARN_16154388_mali_1998" + ] +} diff --git a/tests/fixtures/generate-test-fixtures.ts b/tests/fixtures/generate-test-fixtures.ts new file mode 100644 index 0000000..70c1eed --- /dev/null +++ b/tests/fixtures/generate-test-fixtures.ts @@ -0,0 +1,66 @@ +// Generates the committed test fixtures by slicing the real datasets. +import { access, cp, mkdir, readFile, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { DuckDBInstance } from '@duckdb/node-api'; +import fixtureConfig from './fixture-config.json' with { type: 'json' }; + +const sourceDir = 'data'; +const fixtureDir = 'tests/fixtures/data'; +const sourceModelDir = join(sourceDir, 'model', fixtureConfig.modelRelease); + +const prevalencePaths = [0, 1, 2].map(level => `model/${fixtureConfig.modelRelease}/admin${level}.parquet`); +const metadata = JSON.parse(await readFile(join(sourceModelDir, 'metadata.json'), 'utf8')); +const surveyPath = `stave/${metadata.data_release}/survey_data.parquet`; + +// Validate that the source data exists before attempting to generate fixtures from them. +const requiredPaths = [ + ...prevalencePaths.map(path => join(sourceDir, path)), + join(sourceModelDir, 'metadata.json'), + join(sourceDir, 'admin0-region-metadata.json'), + join(sourceDir, surveyPath) +]; +const missing: string[] = []; +for (const path of requiredPaths) { + await access(path).catch(() => missing.push(path)); +} +if (missing.length > 0) { + throw new Error( + `Cannot generate test fixtures; missing source data:\n${missing.map(p => ` - ${p}`).join('\n')}` + ); +} + +const connection = await (await DuckDBInstance.create(':memory:')).connect(); + +await rm(fixtureDir, { recursive: true, force: true }); +for (const path of prevalencePaths) { + const prevalenceDestination = join(fixtureDir, path); + await mkdir(dirname(prevalenceDestination), { recursive: true }); + await connection.run(` + COPY ( + SELECT * FROM '${(join(sourceDir, path))}' + WHERE + admin0 IN (${fixtureConfig.countries.map(c => `'${c}'`).join(', ')}) + AND (${fixtureConfig.variantWindows.map(w => + `(variant = '${w.variant}' AND date BETWEEN DATE '${w.start}' AND DATE '${w.end}')`, + ).join(' OR ')}) + ) + TO '${prevalenceDestination}' (FORMAT PARQUET) + `); +} + +const surveyDestination = join(fixtureDir, surveyPath); +await mkdir(dirname(surveyDestination), { recursive: true }); +await connection.run(` + COPY ( + SELECT * FROM '${(join(sourceDir, surveyPath))}' + WHERE + survey_id IN (${fixtureConfig.surveyIds.map(c => `'${c}'`).join(', ')}) + AND variant IN (${fixtureConfig.variantWindows.map(w => `'${w.variant}'`).join(', ')}) + ) + TO '${surveyDestination}' (FORMAT PARQUET) +`); + +await cp(join(sourceModelDir, 'metadata.json'), join(fixtureDir, 'model', fixtureConfig.modelRelease, 'metadata.json')); +await cp(join(sourceDir, 'admin0-region-metadata.json'), join(fixtureDir, 'admin0-region-metadata.json')); + +connection.closeSync(); diff --git a/tests/integration/metadata.integration.test.ts b/tests/integration/metadata.integration.test.ts new file mode 100644 index 0000000..3ebbdd9 --- /dev/null +++ b/tests/integration/metadata.integration.test.ts @@ -0,0 +1,63 @@ +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; +import { createApp } from '../../src/app.ts'; +import fixtureConfig from '../fixtures/fixture-config.json' with { type: 'json' }; + +const app = createApp(); + +describe('GET /metadata', () => { + it('returns metadata for the latest model release by default', async () => { + const response = await request(app).get('/metadata'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + model_releases: [fixtureConfig.modelRelease], + prevalences: { + version: fixtureConfig.modelRelease, + data_release: fixtureConfig.dataRelease, + }, + bounds: { + min: { lng: -70.0635, lat: 12.4124 }, + max: { lng: -69.8654, lat: 12.624 }, + }, + }); + + const expectedVariants = [ + { + gene: 'crt', + mutations: [{ + mutation: '76K', + date_range: { start: '2023-05-01', end: '2025-05-01' }, + }], + }, + { + gene: 'k13', + mutations: [{ + mutation: '469Y', + date_range: { start: '2024-05-01', end: '2025-05-01' }, + }], + }, + ]; + expect(response.body.prevalences.variants).toHaveLength(expectedVariants.length); + expect(response.body.prevalences.variants).toEqual(expect.arrayContaining(expectedVariants)); + }); + + it('accepts an explicit model release', async () => { + const response = await request(app) + .get('/metadata') + .query({ model_release: fixtureConfig.modelRelease }); + + expect(response.status).toBe(200); + expect(response.body.prevalences.version).toBe(fixtureConfig.modelRelease); + expect(response.body.prevalences.data_release).toBe(fixtureConfig.dataRelease); + }); + + it('rejects an unknown model release', async () => { + const response = await request(app) + .get('/metadata') + .query({ model_release: '../private' }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'Invalid model release: ../private' }); + }); +}); diff --git a/tests/integration/prevalences.integration.test.ts b/tests/integration/prevalences.integration.test.ts new file mode 100644 index 0000000..c3571e9 --- /dev/null +++ b/tests/integration/prevalences.integration.test.ts @@ -0,0 +1,202 @@ +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; +import { createApp } from '../../src/app.ts'; +import fixtureConfig from '../fixtures/fixture-config.json' with { type: 'json' }; + +const app = createApp(); +const baseQuery = { + model_release: fixtureConfig.modelRelease, + gene: 'crt', + mutation: '76K', +}; + +describe('GET /prevalences', () => { + it('returns per-region info for a global single-date view', async () => { + const response = await request(app) + .get('/prevalences') + .query({ + ...baseQuery, + admin_level: '0', + date: '2024-05-01', + properties: 'admin0,median', + }); + + expect(response.status).toBe(200); + // Expect all admin1 values to be unique + const uniqueAdmin0 = new Set(response.body.admin0); + expect(uniqueAdmin0.size).toBe(2); + expect(response.body.admin0).toEqual(['ETH', 'MLI']); + expect(response.body.median).toHaveLength(2); + expect(response.body.median).toEqual(expect.arrayContaining([ + 0.2031, + 0.2870, + ])); + }); + + it('returns results within a range of dates', async () => { + const response = await request(app) + .get('/prevalences') + .query({ + ...baseQuery, + admin_level: '0', + date_from: '2024-06-01', + date_to: '2025-01-01', + properties: 'admin0,median,date', + }); + + expect(response.status).toBe(200); + const expectedLength = 2 * 8; // 2 admin0 regions * 8 months in the date range + expect(response.body.admin0).toHaveLength(expectedLength); + expect(response.body.median).toHaveLength(expectedLength); + expect(response.body.date).toHaveLength(expectedLength); + expect(response.body.date).toContain('2024-06-01'); + expect(response.body.date).toContain('2025-01-01'); + expect(response.body.date).not.toContain('2023-01-01'); + expect(response.body.date).not.toContain('2026-01-01'); + }); + + it('returns details for one region', async () => { + const response = await request(app) + .get('/prevalences') + .query({ + ...baseQuery, + admin_level: '1', + admin1: 'MLI.1_1', + date: '2024-05-01', + properties: [ + 'median', + 'mean', + 'lower_95', + 'upper_95', + 'SD', + 'exceedance_1', + 'exceedance_2', + 'exceedance_5', + 'exceedance_10', + 'no_of_informing_surveys', + ].join(','), + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + median: [0.0745], + mean: [0.0908], + lower_95: [0], + upper_95: [0.1979], + SD: [0.0546], + exceedance_1: [0.9305], + exceedance_2: [0.9026], + exceedance_5: [0.7725], + exceedance_10: [0.4334], + no_of_informing_surveys: [32], + }); + }); + + it('can scope an admin1-level request to an admin0 region', async () => { + const response = await request(app) + .get('/prevalences') + .query({ + ...baseQuery, + admin_level: '1', + admin0: 'MLI', + date: '2024-05-01', + properties: 'median,admin1', + }); + + expect(response.status).toBe(200); + const rows = response.body.admin1.map((admin1: string, index: number) => ({ + admin1, + median: response.body.median[index], + })); + expect(rows).toHaveLength(9); + expect(rows).toEqual(expect.arrayContaining([ + { admin1: 'MLI.1_1', median: 0.0745 }, + { admin1: 'MLI.8_1', median: 0.6369 }, + ])); + }); + + it('can scope an admin2-level request to an admin0 region', async () => { + const response = await request(app) + .get('/prevalences') + .query({ + ...baseQuery, + admin_level: '2', + admin0: 'MLI', + date: '2024-05-01', + properties: 'median,admin2', + }); + + expect(response.status).toBe(200); + const rows = response.body.admin2.map((admin2: string, index: number) => ({ + admin2, + median: response.body.median[index], + })); + expect(rows).toHaveLength(50); + expect(rows).toEqual(expect.arrayContaining([ + { admin2: 'MLI.1.1_1', median: 0.1244 }, + { admin2: 'MLI.8.1_1', median: 0.396 }, + ])); + }); + + it('returns an unbounded time series when date parameters are omitted', async () => { + const response = await request(app) + .get('/prevalences') + .query({ + ...baseQuery, + admin_level: '0', + admin0: 'MLI', + properties: 'date,median,lower_95,upper_95', + }); + + expect(response.status).toBe(200); + const rows = response.body.date.map((date: string, index: number) => ({ + date, + median: response.body.median[index], + })); + expect(rows).toHaveLength(25); + expect(rows).toEqual(expect.arrayContaining([ + { date: '2023-05-01', median: 0.476 }, + { date: '2024-05-01', median: 0.287 }, + { date: '2025-05-01', median: 0.214 }, + ])); + }); + + it('reports missing required parameters', async () => { + const response = await request(app) + .get('/prevalences') + .query({ admin_level: '1' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe( + 'Missing required query parameters: model_release, properties, gene, mutation', + ); + }); + + it('rejects a containing region more granular than the requested results', async () => { + const response = await request(app) + .get('/prevalences') + .query({ + ...baseQuery, + admin_level: '1', + admin2: 'MLI.1.1_1', + properties: 'admin1', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatch(/less granular level/); + }); + + it('rejects an invalid requested property', async () => { + const response = await request(app) + .get('/prevalences') + .query({ + ...baseQuery, + admin_level: '1', + date: '2024-05-01', + properties: 'admin1,password', + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'Invalid property requested: password' }); + }); +}); diff --git a/tests/integration/surveys.integration.test.ts b/tests/integration/surveys.integration.test.ts new file mode 100644 index 0000000..23856b3 --- /dev/null +++ b/tests/integration/surveys.integration.test.ts @@ -0,0 +1,149 @@ +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; +import { createApp } from '../../src/app.ts'; +import fixtureConfig from '../fixtures/fixture-config.json' with { type: 'json' }; + +const app = createApp(); +const baseQuery = { + data_release: fixtureConfig.dataRelease, + gene: 'crt', + mutation: '76K', +}; +const pointProperties = 'survey_id,lat,lng,collection_day,denominator'; + +describe('GET /surveys', () => { + it('returns global survey points within a date range', async () => { + const response = await request(app) + .get('/surveys') + .query({ + ...baseQuery, + date_from: '2010-01-01', + date_to: '2010-02-01', + properties: pointProperties, + }); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + expect(response.body).toEqual(expect.arrayContaining([ + { + survey_id: 's0006_kisii_period2', + lat: -0.6805, + lng: 34.7771, + collection_day: '2010-01-01', + denominator: 5, + }, + { + survey_id: 's0136_Bunkpurugu_period1', + lat: 10.5418, + lng: -0.1739, + collection_day: '2010-01-01', + denominator: 72, + }, + ])); + }); + + it('returns additional surveys for a wide lazy-loading range', async () => { + const response = await request(app) + .get('/surveys') + .query({ + ...baseQuery, + date_from: '2000-01-01', + date_to: '2025-01-01', + properties: pointProperties, + }); + + expect(response.status).toBe(200); + const surveyIds = response.body.map((survey: { survey_id: string }) => survey.survey_id); + expect(surveyIds).toHaveLength(4); + expect(surveyIds).toEqual(expect.arrayContaining(fixtureConfig.surveyIds)); + }); + + it('returns the requested details for a single survey', async () => { + const response = await request(app) + .get('/surveys') + .query({ + ...baseQuery, + survey_id: 's0002_Gondar_Zuria', + properties: [ + 'lat', + 'lng', + 'site_name', + 'collection_day', + 'collection_start', + 'collection_end', + 'study_label', + 'reference_year', + 'numerator', + 'denominator', + 'prevalence', + 'prevalence_lower', + 'prevalence_upper', + ].join(','), + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual([{ + lat: 12.6739, + lng: 37.3435, + site_name: 'Gondar Zuria', + collection_day: '2022-07-02', + collection_start: '2022-01-01', + collection_end: '2023-01-01', + study_label: 'Artemisinin resistant kelch13 R622I and RDT negativity approaching predominance in northern Ethiopia and emerging C580Y of African origin threaten falciparum malaria control.', + reference_year: 2025, + numerator: 75, + denominator: 438, + prevalence: 17.1233, + prevalence_lower: 13.7134, + prevalence_upper: 20.9845, + }]); + }); + + it('filters country survey points using the country bounding box', async () => { + const response = await request(app) + .get('/surveys') + .query({ + ...baseQuery, + admin0: 'MLI', + date_from: '2000-01-01', + date_to: '2030-02-01', + properties: pointProperties, + }); + + expect(response.status).toBe(200); + const surveyIds = response.body.map((survey: { survey_id: string }) => survey.survey_id); + expect(surveyIds).toHaveLength(2); + expect(surveyIds).toEqual(expect.arrayContaining([ + 'WWARN_16154388_mali_1998', + 's0136_Bunkpurugu_period1', + ])); + }); + + it('rejects an ISO code absent from the bounds metadata', async () => { + const response = await request(app) + .get('/surveys') + .query({ + ...baseQuery, + admin0: 'ZZZ', + properties: 'survey_id', + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'ISO code not found: ZZZ' }); + }); + + it('rejects an unknown data release', async () => { + const response = await request(app) + .get('/surveys') + .query({ + ...baseQuery, + data_release: '../private', + properties: 'survey_id', + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: 'Invalid data release requested: ../private', + }); + }); +}); diff --git a/tests/unit/dataHelpers.test.ts b/tests/unit/dataHelpers.test.ts new file mode 100644 index 0000000..373fb26 --- /dev/null +++ b/tests/unit/dataHelpers.test.ts @@ -0,0 +1,226 @@ +// WHERE I GOT UP TO - reviewing changes/tests as far down as this file + +import type { Response } from 'express'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { executeParquetQuery } from '../../src/utils/dataHelpers.ts'; +import fixtureConfig from '../fixtures/fixture-config.json' with { type: 'json' }; + +const db = vi.hoisted(() => ({ + bind: vi.fn(), + prepare: vi.fn(), + runAndReadAll: vi.fn(), +})); + +vi.mock('../../src/queryEngine.ts', () => ({ + connection: { + prepare: db.prepare, + runAndReadAll: db.runAndReadAll, + } +})); + +const admin1ParquetPath = `tests/fixtures/data/model/${fixtureConfig.modelRelease}/admin1.parquet`; +const surveyPath = `tests/fixtures/data/stave/${fixtureConfig.dataRelease}/survey_data.parquet`; +const columnsTypes = { + admin1: 'VARCHAR', + gene: 'VARCHAR', + mutation: 'VARCHAR', + date: 'DATE', + collection_day: 'DATE', + median: 'DOUBLE', + no_of_informing_surveys: 'INTEGER', + survey_id: 'VARCHAR', + lat: 'DOUBLE', + lng: 'DOUBLE', +}; + +const mockResponse = () => { + const response = { + status: vi.fn(), + send: vi.fn(), + }; + response.status.mockReturnValue(response); + return response as unknown as Response; +}; + +beforeEach(() => { + vi.clearAllMocks(); + db.runAndReadAll.mockResolvedValue({ + columnNames: () => Object.keys(columnsTypes), + columnTypes: () => Object.values(columnsTypes), + }); + db.prepare.mockResolvedValue(db); +}); + +describe('executeParquetQuery SQL generation', () => { + it('uses equality filters and SQL parameter bindings', async () => { + const response = mockResponse(); + + await executeParquetQuery( + { + properties: 'admin1,median', + gene: 'crt', + mutation: '76K', + date: '2024-05-01', + }, + '/prevalences', + admin1ParquetPath, + response, + ); + + expect(db.prepare).toHaveBeenCalledWith( + `SELECT p.admin1, ROUND(p.median, 4) AS median FROM '${admin1ParquetPath}' p ` + + 'WHERE p.gene = $gene AND p.mutation = $mutation AND p.date = $date', + ); + expect(db.bind).toHaveBeenCalledWith({ + gene: 'crt', + mutation: '76K', + date: '2024-05-01', + }); + }); + + it('maps date ranges to the `date` column for the prevalences endpoint, using inequality operators', async () => { + const response = mockResponse(); + + await executeParquetQuery( + { + properties: 'admin1', + date_from: '2023-05-01', + date_to: '2025-05-01', + }, + '/prevalences', + admin1ParquetPath, + response, + ); + + expect(db.prepare).toHaveBeenCalledWith( + `SELECT p.admin1 FROM '${admin1ParquetPath}' p ` + + 'WHERE p.date >= $date_from AND p.date <= $date_to', + ); + expect(db.bind).toHaveBeenCalledWith({ + date_from: '2023-05-01', + date_to: '2025-05-01', + }); + }); + + it('maps date ranges to the `collection_day` column for the surveys endpoint, using inequality operators', async () => { + const response = mockResponse(); + + await executeParquetQuery( + { + properties: 'gene', + date_from: '2023-05-01', + date_to: '2025-05-01', + }, + '/surveys', + admin1ParquetPath, + response, + ); + + expect(db.prepare).toHaveBeenCalledWith( + `SELECT p.gene FROM '${admin1ParquetPath}' p ` + + 'WHERE p.collection_day >= $date_from AND p.collection_day <= $date_to', + ); + expect(db.bind).toHaveBeenCalledWith({ + date_from: '2023-05-01', + date_to: '2025-05-01', + }); + }); + + it('filters prevalence admin0 using the parquet column', async () => { + const response = mockResponse(); + + await executeParquetQuery( + { properties: 'admin1', admin0: 'MLI' }, + '/prevalences', + admin1ParquetPath, + response, + ); + + expect(db.prepare).toHaveBeenCalledWith( + `SELECT p.admin1 FROM '${admin1ParquetPath}' p WHERE p.admin0 = $admin0`, + ); + expect(db.bind).toHaveBeenCalledWith({ admin0: 'MLI' }); + }); + + it('expands survey admin0 into latitude and longitude bounds', async () => { + const response = mockResponse(); + + await executeParquetQuery( + { properties: 'survey_id', admin0: 'MLI', date_from: '2010-01-01', date_to: '2010-02-01' }, + '/surveys', + surveyPath, + response, + ); + + expect(db.prepare).toHaveBeenCalledWith( + `SELECT p.survey_id FROM '${surveyPath}' p WHERE ` + + 'p.lat >= 10.1595 AND p.lat <= 25 AND p.lng >= -12.2389 AND p.lng <= 4.245 ' + + 'AND p.collection_day >= $date_from AND p.collection_day <= $date_to', + ); + expect(db.bind).toHaveBeenCalledWith({ + date_from: '2010-01-01', + date_to: '2010-02-01', + }); + }); + + it('sends a 400 after getting an unknown ISO code', async () => { + const response = mockResponse(); + + const result = await executeParquetQuery( + { properties: 'survey_id', admin0: 'ZZZ' }, + '/surveys', + surveyPath, + response, + ); + + expect(result).toBeUndefined(); + expect(response.send).toHaveBeenCalledWith({ error: 'ISO code not found: ZZZ' }); + expect(db.prepare).not.toHaveBeenCalled(); + }); + + it('omits WHERE when no filterable parameters are provided', async () => { + const response = mockResponse(); + + await executeParquetQuery( + { properties: 'admin1' }, + '/prevalences', + admin1ParquetPath, + response, + ); + + expect(db.prepare).toHaveBeenCalledWith( + `SELECT p.admin1 FROM '${admin1ParquetPath}' p `, + ); + expect(db.bind).toHaveBeenCalledWith({}); + }); + + it('rounds floating-point columns', async () => { + const response = mockResponse(); + + await executeParquetQuery( + { properties: 'median,no_of_informing_surveys' }, + '/prevalences', + admin1ParquetPath, + response, + ); + + expect(db.prepare).toHaveBeenCalledWith( + `SELECT ROUND(p.median, 4) AS median, p.no_of_informing_surveys FROM '${admin1ParquetPath}' p `, + ); + }); + + it('rejects a requested property if it belongs to a different parquet schema', async () => { + const response = mockResponse(); + + const result = await executeParquetQuery( + { properties: 'admin2' }, + '/prevalences', + admin1ParquetPath, + response, + ); + + expect(result).toBeUndefined(); + expect(response.send).toHaveBeenCalledWith({ error: 'Invalid property requested: admin2' }); + expect(db.prepare).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/metadataHelpers.test.ts b/tests/unit/metadataHelpers.test.ts new file mode 100644 index 0000000..234a540 --- /dev/null +++ b/tests/unit/metadataHelpers.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getMutationsByGene } from '../../src/utils/metadataHelpers.ts'; +import fixtureConfig from '../fixtures/fixture-config.json' with { type: 'json' }; + +const runAndReadAll = vi.hoisted(() => vi.fn()); + +vi.mock('../../src/queryEngine.ts', () => { + runAndReadAll.mockImplementation(() => ({ + getRowObjects: () => [ + { + gene: 'crt', + mutation: '76K', + variant: 'crt:76:K', + min_date: '2003-05-01', + max_date: '2025-05-01', + }, + { + gene: 'k13', + mutation: '469Y', + variant: 'k13:469:Y', + min_date: '2020-05-01', + max_date: '2024-05-01', + }, + { + gene: 'k13', + mutation: '469F', + variant: 'k13:469:F', + min_date: '2021-05-01', + max_date: '2023-05-01', + }, + ], + })); + + return { connection: { runAndReadAll } }; +}); + +describe('getMutationsByGene', () => { + it('queries the parquet and groups mutations by gene', async () => { + const result = await getMutationsByGene(fixtureConfig.modelRelease); + + expect(runAndReadAll).toHaveBeenCalledOnce(); + const sql = runAndReadAll.mock.calls[0][0] as string; + expect(sql).toContain( + `FROM 'tests/fixtures/data/model/${fixtureConfig.modelRelease}/admin0.parquet'`, + ); + expect(result).toEqual([ + { + gene: 'crt', + mutations: [{ + mutation: '76K', + date_range: { start: '2003-05-01', end: '2025-05-01' }, + }], + }, + { + gene: 'k13', + mutations: [ + { + mutation: '469Y', + date_range: { start: '2020-05-01', end: '2024-05-01' }, + }, + { + mutation: '469F', + date_range: { start: '2021-05-01', end: '2023-05-01' }, + }, + ], + }, + ]); + }); +}); diff --git a/tests/unit/validators.test.ts b/tests/unit/validators.test.ts new file mode 100644 index 0000000..7c53767 --- /dev/null +++ b/tests/unit/validators.test.ts @@ -0,0 +1,203 @@ +import type { Request, Response } from 'express'; +import { describe, expect, it, vi } from 'vitest'; +import { + validateAdminLevel, + validateDataRelease, + validateDateIsFirstOfMonth, + validateDateParams, + validateModelRelease, + validateRequestedProperties, + validateRequiredQueryParams, +} from '../../src/utils/validators.ts'; +import fixtureConfig from '../fixtures/fixture-config.json' with { type: 'json' }; + +const mockReqRes = (query: Record) => { + const response = { + status: vi.fn(), + send: vi.fn(), + }; + response.status.mockReturnValue(response); + return { + req: { query } as unknown as Request, + res: response as unknown as Response, + }; +}; + +describe('validateRequiredQueryParams', () => { + it('reports every missing parameter', () => { + const { req, res } = mockReqRes({ gene: 'crt', mutation: '', properties: undefined }); + + expect( + validateRequiredQueryParams(req, res, ['gene', 'mutation', 'properties']) + ).toBe(false); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith({ + error: 'Missing required query parameters: mutation, properties', + }); + }); + + it('accepts a request containing every required parameter', () => { + const { req, res, } = mockReqRes({ gene: 'crt', mutation: '76K' }); + + expect( + validateRequiredQueryParams(req, res, ['gene', 'mutation']) + ).toBe(true); + expect(res.send).not.toHaveBeenCalled(); + }); +}); + +describe('validateRequestedProperties', () => { + const dbTypes = { admin1: 'VARCHAR', median: 'DOUBLE' }; + + it('requires at least one property', () => { + const { res } = mockReqRes({}); + + expect( + validateRequestedProperties({ properties: '' }, ['admin1', 'median'], dbTypes, res) + ).toBe(false); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('rejects a property outside the endpoint allow-list', () => { + const { res, } = mockReqRes({}); + + expect( + validateRequestedProperties({ properties: 'admin1,median' }, ['median'], dbTypes, res) + ).toBe(false); + expect(res.send).toHaveBeenCalledWith({ error: 'Invalid property requested: admin1' }); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('rejects an allowed property absent from the parquet file', () => { + const { res, } = mockReqRes({}); + + expect( + validateRequestedProperties({ properties: 'median' }, ['median'], {}, res) + ).toBe(false); + expect(res.send).toHaveBeenCalledWith({ error: 'Invalid property requested: median' }); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns true if the requested property list is valid', () => { + const { res } = mockReqRes({}); + + expect( + validateRequestedProperties({ properties: 'admin1,median' }, ['admin1', 'median'], dbTypes, res) + ).toBe(true); + }); +}); + +describe('release validators', () => { + it('accepts a known model release', () => { + const { res } = mockReqRes({}); + expect(validateModelRelease(fixtureConfig.modelRelease, res)).toBe(true); + }); + + it('rejects an unknown model release', () => { + const { res } = mockReqRes({ model_release: '../private' }); + + expect(validateModelRelease("../private", res)).toBe(false); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('accepts a known data release', () => { + const { req, res } = mockReqRes({ data_release: fixtureConfig.dataRelease }); + expect(validateDataRelease(req, res)).toBe(true); + }); + + it('rejects an unknown data release', () => { + const { req, res } = mockReqRes({ data_release: '../private' }); + + expect(validateDataRelease(req, res)).toBe(false); + expect(res.status).toHaveBeenCalledWith(400); + }); +}); + +describe('validateDateParams', () => { + it.each(['date', 'date_from', 'date_to'])('rejects an invalid %s format', (parameter) => { + const { req, res } = mockReqRes({ [parameter]: '01-05-2024' }); + + expect(validateDateParams(req, res)).toBe(false); + expect(res.send).toHaveBeenCalledWith({ + error: `Invalid date format for parameter '${parameter}'. Expected YYYY-MM-DD.`, + }); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it.each(['date_from', 'date_to'])('requires %s with the other date range parameter', (parameter) => { + const { req, res } = mockReqRes({ [parameter]: '2024-01-01' }); + + expect(validateDateParams(req, res)).toBe(false); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('rejects when date_from is later than date_to', () => { + const { req, res } = mockReqRes({ + date_from: '2025-01-01', + date_to: '2024-01-01', + }); + + expect(validateDateParams(req, res)).toBe(false); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('accepts a valid date range', () => { + const { req, res } = mockReqRes({ + date_from: '2024-01-01', + date_to: '2025-01-01', + }); + expect(validateDateParams(req, res)).toBe(true); + }); +}); + +describe('validateDateIsFirstOfMonth', () => { + it('accepts the first day of a month', () => { + const { req, res } = mockReqRes({ date: '2024-05-01' }); + expect(validateDateIsFirstOfMonth(req, res)).toBe(true); + }); + + it('rejects any other day', () => { + const { req, res } = mockReqRes({ date: '2024-05-02' }); + + expect(validateDateIsFirstOfMonth(req, res)).toBe(false); + expect(res.status).toHaveBeenCalledWith(400); + }); +}); + +describe('validateAdminLevel', () => { + it('requires admin_level to be present', () => { + const { req, res } = mockReqRes({}); + + expect(validateAdminLevel(req, res)).toBe(false); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('requires admin_level to be non-empty', () => { + const { req, res } = mockReqRes({ admin_level: '' }); + + expect(validateAdminLevel(req, res)).toBe(false); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('rejects an unsupported admin level', () => { + const { req, res } = mockReqRes({ admin_level: '3' }); + + expect(validateAdminLevel(req, res)).toBe(false); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('rejects a containing region more granular than the results', () => { + const { req, res } = mockReqRes({ + admin_level: '1', + admin2: 'MLI.1.1_1', + }); + + expect(validateAdminLevel(req, res)).toBe(false); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('accepts a valid level and containing region', () => { + const { req, res } = mockReqRes({ admin_level: '2', admin1: 'MLI.1_1' }); + expect(validateAdminLevel(req, res)).toBe(true); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..01cc49d --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/tsconfig.json b/tsconfig.json index d5b6785..e71e518 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,14 +2,15 @@ "compilerOptions": { "target": "esnext", "module": "nodenext", - "rootDir": "src", - "outDir": "dist", + "rootDir": ".", + "noEmit": true, "rewriteRelativeImportExtensions": true, "erasableSyntaxOnly": true, "verbatimModuleSyntax": true, "strict": true, "skipLibCheck": true }, - "include": ["src/**/*"], - "exclude": ["node_modules"] + "include": ["src/**/*", "tests/**/*", "scripts/**/*"], + "exclude": ["node_modules"], + "types": ["node"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2b49ecb --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + restoreMocks: true, + }, +});