diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..528270e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + workflow_dispatch: # Allow manual triggering + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [22, 24] + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - run: npm ci + - run: npm run build + - run: npm run lint + - run: npm run typecheck + - run: npm test + + integration: + # Only run on push or manual dispatch *and* only on main/develop. + # The branch check is load-bearing for `workflow_dispatch`: the + # Actions UI lets you pick any ref, so without it a collaborator + # could push a modified workflow to a feature branch and fire it + # with `TEST_API_KEY` attached, bypassing PR review. `push` events + # are already gated by `on.push.branches`, but checking github.ref + # here keeps both event types under one rule. + if: | + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npm run build + - name: Run e2e tests against live API + env: + OMOPHUB_API_KEY: ${{ secrets.TEST_API_KEY }} + run: npm run test:e2e diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4b20e96 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,27 @@ +name: Publish to npm + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + cache: npm + + - run: npm ci + - run: npm run build + - run: npm test + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca5742c --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +coverage/ +.env +.env.local +.env.*.local +*.log +npm-debug.log* +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4e38742 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +All notable changes to this project will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] - 2026-05-31 + +### Added + +- Initial scaffolding: `OMOPHub` client skeleton with API key + base URL resolution, env-var precedence, and constructor-time validation. +- TypeScript build (ES2022, NodeNext, strict mode). +- Biome v2 lint + format. +- Vitest test runner with v8 coverage and a 90% statements threshold. +- CI workflow on Node 22 and 24 (`build`, `lint`, `typecheck`, `test`). +- npm publish workflow triggered by GitHub release, with provenance attestation. +- HTTP layer: `get` / `post` / `patch` / `put` / `delete` methods on `OMOPHub` with retry on 429 + 502/503/504 + network errors. Full-jitter exponential backoff (500ms → 8s), `Retry-After` honoured up to 60s. +- Discriminated `Response = { data, error, meta, headers }` return type - errors never throw from network paths. +- 16-code `OMOPHUB_ERROR_CODE_KEY` union with stable codes mapped from HTTP status + server-provided error codes. +- `OMOPHubError` (thrown only on constructor misuse) and `OMOPHubIteratorError` (thrown from future async iterators). +- Common request-option interfaces: `PerCallOptions`, `GetOptions`, `PostOptions` (with `idempotencyKey`), `PatchOptions`, `PutOptions`, `DeleteOptions`. +- Pagination types: `PaginationOptions`, `PaginationMeta`, `PaginatedData`. +- Vocab-release mixin + utility types (`RequireAtLeastOne`, `RequireExactlyOne`). +- Query builder: camelCase → snake_case, array → comma-join, null/undefined dropped. +- Envelope unwrap: tolerates both `{ success, data, meta }` and raw payload bodies. +- AbortSignal composition: client timeout + caller signal merged via `AbortSignal.any`; caller aborts propagate as thrown `AbortError`, timeouts return `timeout_error`. +- `X-Vocab-Version` header injection when `vocabVersion` option is set. +- First resource: `client.vocabularies.list()` with snake_case query serialisation, pagination, and error mapping. +- Test fixtures (`DIABETES_CONCEPT_ID` etc.) and mock-fetch helpers for `vi.fn`-based testing without external mock libraries. + +### Added (resources) + +- `client.concepts` - 7 methods: `get`, `getByCode`, `batch`, `suggest`, `related`, `relationships`, `recommended`. `concepts.get(0)` accepts the OMOP unmapped sentinel (R-SDK bug fix). `batch` validates 1–100 IDs synthetically; `recommended` validates `conceptIds` ≤ 100, `relationshipTypes` ≤ 20, `vocabularyIds`/`domainIds` ≤ 50. +- `client.vocabularies` extended with 6 methods: `get`, `stats`, `domainStats`, `domains` (vocab-scoped), `conceptClasses`, `concepts`. +- `client.domains` - 2 methods: `list`, `concepts`. +- `client.search` - 11 methods: `basic`, `basicIter`, `basicAll`, `advanced`, `autocomplete`, `semantic`, `semanticIter`, `semanticAll`, `bulkBasic`, `bulkSemantic`, `similar`. `bulkBasic` validates 1–50 searches; `bulkSemantic` validates 1–25; `similar` enforces XOR of `conceptId`/`conceptName`/`query` both at the TS type level (discriminated union) and at runtime. +- `client.hierarchy` - 3 methods: `get` (flat or graph format), `ancestors`, `descendants`. Server caps `maxLevels` at 20. +- `client.relationships` - 2 methods: `get` (shares wire endpoint with `concepts.relationships` - kept as parallel discoverable surface), `types`. +- `client.mappings` - 2 methods: `get` and `map`. `map` enforces XOR of `sourceConcepts` vs `sourceCodes` at both type and runtime levels. `vocabRelease` is routed to the `?vocab_release=` query parameter rather than the JSON body (matches Python SDK convention). JSDoc documents the Procedure-domain vocabulary priority chain (SNOMED → LOINC → CPT4 → HCPCS → ICD10PCS → ICD9Proc → OPCS4 → OMOP Extension). +- `ConceptHierarchyNode` extended with `domain_id`, `concept_class_id`, `standard_concept` optional fields - now matches Python's `HierarchyConcept` and is re-exported as `HierarchyConcept`/`Ancestor`/`Descendant` from the hierarchy module. +- `ConceptRelationship` re-exported as `Relationship` from the relationships module - kept in sync via type alias. +- `client.fhir` - 3 methods: `resolve` (accepts both flat `{ system, code }` and nested `{ coding: {...} }` forms, mirroring the Python SDK's `_extract_coding`), `resolveBatch` (1–100 codings), `resolveCodeableConcept` (1–20 codings). All three validate synthetically before issuing requests. +- Standalone helpers (no client required): + - `omophubFhirUrl(version)` - returns the URL of OMOPHub's hosted FHIR Terminology Service (`'r4'` default, also `'r4b'`, `'r5'`, `'r6'`). + - `getApiKey()`, `setApiKey(key)`, `hasApiKey()` - env-backed helpers reading `OMOPHUB_API_KEY` from `process.env`. `setApiKey` throws `OMOPHubError` on edge runtimes that lack a writable `process.env`. +- FHIR `Coding` type uses camelCase (`userSelected`, `vocabularyId`) to match the FHIR JSON spec - converted to snake_case at the wire via `toSnakeCaseKeys`. +- README polish: install + config table + per-resource usage examples for all 8 resources + error-handling guide + async-iterator guide + Python/R migration table. + +### SDK surface at v1.0.0 - 37 methods across 8 resources + +``` +client.concepts - get, getByCode, batch, suggest, related, relationships, recommended (7) +client.search - basic, basicIter, basicAll, advanced, autocomplete, semantic, + semanticIter, semanticAll, bulkBasic, bulkSemantic, similar (11) +client.vocabularies - list, get, stats, domainStats, domains, conceptClasses, concepts (7) +client.domains - list, concepts (2) +client.hierarchy - get, ancestors, descendants (3) +client.relationships - get, types (2) +client.mappings - get, map (2) +client.fhir - resolve, resolveBatch, resolveCodeableConcept (3) + Σ = 37 + +Standalone: omophubFhirUrl, getApiKey, setApiKey, hasApiKey +``` + + +[Unreleased]: https://github.com/OMOPHub/omophub-node/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/OMOPHub/omophub-node/releases/tag/v1.0.0 + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..18834a1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,230 @@ +# Contributing to OMOPHub Node.js SDK + +First off, thank you for considering contributing to OMOPHub! + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check the [existing issues](https://github.com/OMOPHub/omophub-node/issues) to avoid duplicates. + +When creating a bug report, please include: + +- **Node version** (`node --version`) +- **Package manager + version** (`npm --version` / `pnpm --version` / `bun --version`) +- **SDK version** (`npm ls @omophub/omophub-node`) +- **Runtime** (Node, Deno, Bun, Cloudflare Workers, Vercel Edge, browser) +- **Operating system** +- **Minimal code example** that reproduces the issue +- **Full error output** - `error.name`, `error.statusCode`, `error.message`, and `error.requestId` if available +- **Expected vs actual behavior** + +If you're seeing a transport-level failure, include the response headers - especially `x-request-id` - so we can correlate against server logs. + +### Suggesting Features + +Feature requests are welcome! Please open an issue with: + +- Clear description of the feature +- Use case: why would this be useful? +- Proposed TypeScript surface (option keys, return shape) +- Possible implementation approach (optional) + +### Pull Requests + +1. **Fork the repository** and create your branch from `main` +2. **Install dependencies:** + ```bash + git clone https://github.com/YOUR_USERNAME/omophub-node.git + cd omophub-node + npm install + ``` +3. **Make your changes** with clear, descriptive commits +4. **Add tests** for new functionality - both unit tests (`test/`) and, when the change touches the wire, integration tests (`e2e/`) +5. **Run the full validation suite:** + ```bash + npm run typecheck + npm run lint + npm test + npm run build + ``` +6. **Run integration tests** if your change affects request/response shapes: + ```bash + OMOPHUB_API_KEY=oh_xxx npm run test:e2e + ``` +7. **Update documentation** - `README.md`, `CHANGELOG.md` under `## [Unreleased]`, and any relevant JSDoc +8. **Submit a pull request** with a clear description + +## Development Setup + +### Prerequisites + +- **Node.js 22+** (see `.nvmrc`) +- **npm 10+** (ships with Node 22) + +### Installation + +```bash +# Clone your fork +git clone https://github.com/YOUR_USERNAME/omophub-node.git +cd omophub-node + +# Use the pinned Node version +nvm use # or `fnm use` + +# Install dependencies +npm install +``` + +For integration tests against the live API, create a `.env` file at the repo root: + +```bash +echo "export OMOPHUB_API_KEY=oh_xxx" > .env +``` + +`.env` is gitignored - never commit your key. + +### Running Tests + +```bash +# Unit tests (fast, fully mocked - no network) +npm test + +# Watch mode for active development +npm run test:watch + +# With coverage report +npm run test:coverage + +# Integration tests against the live API (requires OMOPHUB_API_KEY) +set -a && source .env && set +a +npm run test:e2e + +# Run a single test file +npx vitest run test/concepts/concepts.test.ts + +# Run tests matching a pattern +npx vitest run -t "bulk" +``` + +Unit tests live in `test/` and use `vitest` with `vi.fn()`-based mock fetches - no external mock libraries. Integration tests live in `e2e/` and exercise the SDK against `api.omophub.com/v1`; they tolerate rate limits and known slow paths via the helpers in `e2e/_helpers.ts`. + +### Code Style + +We use: + +- **Biome v2** for linting and formatting +- **TypeScript strict mode** (no `any`, no implicit returns, no unused locals) + +```bash +# Check lint + format +npm run lint + +# Auto-fix lint + format +npm run lint:fix + +# Format only +npm run format + +# Type-check (src + e2e) +npm run typecheck +``` + +Run `npm run lint:fix && npm run typecheck` before pushing. CI runs the same checks. + +### Running Examples + +The `examples/` directory contains runnable scripts you can use to smoke-test your changes end-to-end: + +```bash +set -a && source .env && set +a +npx tsx examples/basic-usage.ts +npx tsx examples/fhir-resolver.ts +``` + +If your change affects a public method, add or update an example. + +## Project Structure + +``` +omophub-node/ +├── src/ +│ ├── index.ts # Public API exports +│ ├── client.ts # OMOPHub client + HTTP dispatch + retry +│ ├── errors.ts # OMOPHubError, OMOPHubIteratorError, error codes +│ ├── version.ts # SDK version (auto-bumped) +│ ├── interfaces.ts # Shared request/response/option types +│ ├── auth/ # API key env helpers +│ ├── common/ # syntheticError, pagination, toSnakeCaseKeys, … +│ ├── concepts/ +│ │ ├── concepts.ts # Concepts resource methods +│ │ └── interfaces/ # Per-method option + response types +│ ├── search/ +│ ├── hierarchy/ +│ ├── relationships/ +│ ├── mappings/ +│ ├── vocabularies/ +│ ├── domains/ +│ └── fhir/ +├── test/ # Unit tests (mock-fetch, no network) +├── e2e/ # Integration tests (live api.omophub.com) +├── examples/ # Runnable example scripts +├── package.json +├── tsconfig.json # src build config +├── tsconfig.e2e.json # e2e typecheck config +├── tsconfig.examples.json # examples typecheck config +├── biome.json # Lint + format rules +├── vitest.config.ts # Unit test runner +└── vitest.e2e.config.ts # Integration test runner (fileParallelism: false) +``` + +## SDK Conventions + +A few patterns are load-bearing - please follow them when adding new methods: + +- **Discriminated returns, not exceptions.** Every method returns `Promise<{ data, error, meta, headers }>`. The only exceptions the SDK throws are `OMOPHubError` (constructor misuse) and `OMOPHubIteratorError` (`*Iter` async-generator page failures). Network errors, 404s, 429s, validation failures - all returned as `error` values. +- **camelCase TypeScript surface, snake_case wire.** Request options take camelCase keys (`vocabularyIds`, `pageSize`); they're converted to snake_case at the request boundary via `toSnakeCaseKeys`. Response types use snake_case to match the wire - never client-translated. +- **Synthetic validation when feasible.** Reject obviously-bad inputs (empty arrays, out-of-range counts, missing required fields) with `syntheticError(...)` before making a network call. Match the wire-shape of a real server error so callers' error-handling code doesn't have to branch. +- **Positional path args + merged options.** Prefer `client.concepts.get(201826, { includeRelationships: true })` over `({ conceptId: 201826, ... })`. Path params are positional; everything else goes in a single options object. +- **Idempotency-key retries.** GET/HEAD/OPTIONS/PUT/DELETE retry automatically on transient failures. POST/PATCH only retry when the caller sets `{ idempotencyKey: '...' }` - never silently retry a non-idempotent write. +- **Pagination metadata on the outer envelope.** `meta.pagination` lives on the response, never inside `data`. Add `*Iter` (async generator) and `*All` (eager collector with error accumulation) variants for any new paginated resource. + +When the live API returns a shape that doesn't match the existing types, **fix the type to match the wire** - don't transform on the response path. Log the discovery in `CHANGELOG.md` so the wire-drift history stays traceable. + +## Commit Messages + +We follow [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` New feature +- `fix:` Bug fix +- `docs:` Documentation changes +- `test:` Adding or updating tests +- `refactor:` Code refactoring +- `chore:` Maintenance tasks + +Examples: +``` +feat: add semantic search endpoint +fix: handle rate limit errors correctly +docs: update README with new examples +test: add integration tests for batch concept lookup +``` + +## Release Process + +Releases are cut by maintainers via GitHub Releases - **do not bump the version in `package.json` in your PR**. The publish workflow (`.github/workflows/publish.yml`) runs on release-published, publishes to npm with provenance attestation, and tags the registry version. + +For each PR, add a bullet under `## [Unreleased]` in `CHANGELOG.md` describing the user-visible change. + +## Questions? + +- Open a [GitHub Discussion](https://github.com/OMOPHub/omophub-node/discussions) +- Email: support@omophub.com + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. + +--- + +Thank you for helping make OMOPHub better! diff --git a/README.md b/README.md new file mode 100644 index 0000000..a558bbb --- /dev/null +++ b/README.md @@ -0,0 +1,504 @@ +# OMOPHub Node.js SDK + +**Query millions of standardized medical concepts from TypeScript with full type safety** + +Access SNOMED CT, ICD-10, RxNorm, LOINC, and 100+ OHDSI ATHENA vocabularies without downloading, installing, or maintaining local databases. + +[![npm version](https://img.shields.io/npm/v/@omophub/omophub-node.svg)](https://www.npmjs.com/package/@omophub/omophub-node) +[![Node Version](https://img.shields.io/node/v/@omophub/omophub-node.svg)](https://www.npmjs.com/package/@omophub/omophub-node) +[![Codecov](https://codecov.io/gh/OMOPHub/omophub-node/branch/main/graph/badge.svg)](https://app.codecov.io/gh/OMOPHub/omophub-node?branch=main) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) +![Downloads](https://img.shields.io/npm/dm/@omophub/omophub-node) + +**[Documentation](https://docs.omophub.com/sdks/node/overview)** · +**[API Reference](https://docs.omophub.com/api-reference)** · +**[Examples](https://github.com/OMOPHub/omophub-node/tree/main/examples)** + +--- + +## Why OMOPHub? + +Working with OHDSI ATHENA vocabularies traditionally requires downloading multi-gigabyte files, setting up a database instance, and writing complex SQL queries. **OMOPHub eliminates this friction.** + +| Traditional Approach | With OMOPHub | +|---------------------|--------------| +| Download 5GB+ ATHENA vocabulary files | `npm install @omophub/omophub-node` | +| Set up and maintain database | One API call | +| Write complex SQL with multiple JOINs | Simple TypeScript methods | +| Manually update vocabularies quarterly | Always current data | +| Local infrastructure required | Runs in Node, Deno, Bun, or any edge runtime | + +## Installation + +```bash +npm install @omophub/omophub-node +# or pnpm add / yarn add / bun add @omophub/omophub-node +``` + +Requires **Node ≥ 22**. Also runs in Deno, Bun, Cloudflare Workers, Vercel Edge, and modern browsers (CORS permitting). Pure ESM, zero runtime dependencies. + +## Quick Start + +```ts +import { OMOPHub } from '@omophub/omophub-node'; + +// Initialize client (uses OMOPHUB_API_KEY env var, or pass apiKey explicitly) +const client = new OMOPHub(); + +// Get a concept by ID +const { data, error } = await client.concepts.get(201826); +if (error) throw new Error(error.message); +console.log(data.concept_name); // "Type 2 diabetes mellitus" + +// Search for concepts across vocabularies +const search = await client.search.basic('metformin', { + vocabularyIds: ['RxNorm'], + domainIds: ['Drug'], +}); +for (const c of search.data?.concepts ?? []) { + console.log(`${c.concept_id}: ${c.concept_name}`); +} + +// Map ICD-10 code to SNOMED +const mapping = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceCodes: [{ vocabulary_id: 'ICD10CM', concept_code: 'E11.9' }], +}); + +// Navigate concept hierarchy +const ancestors = await client.hierarchy.ancestors(201826, { maxLevels: 3 }); +``` + +**Errors are values, not exceptions.** Every method returns a discriminated `{ data, error, meta, headers }` union - narrow with `if (error) ...` and TypeScript types `data` correctly in the success branch. No try/catch boilerplate, no surprise throws on 404s or rate limits. + +## FHIR-to-OMOP Resolution + +Resolve FHIR coded values to OMOP standard concepts in one call: + +```ts +// Single FHIR Coding → OMOP concept + CDM target table +const { data } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + resourceType: 'Condition', +}); +console.log(data.resolution.target_table); // "condition_occurrence" +console.log(data.resolution.mapping_type); // "direct" + +// ICD-10-CM → traverses "Maps to" automatically +const icd = await client.fhir.resolve({ + system: 'http://hl7.org/fhir/sid/icd-10-cm', + code: 'E11.9', +}); +console.log(icd.data?.resolution.standard_concept.vocabulary_id); // "SNOMED" + +// Batch resolve up to 100 codings +const batch = await client.fhir.resolveBatch( + [ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://loinc.org', code: '2339-0' }, + { system: 'http://www.nlm.nih.gov/research/umls/rxnorm', code: '197696' }, + ], +); +console.log(`Resolved ${batch.data?.summary?.resolved}/${batch.data?.summary?.total}`); + +// CodeableConcept with vocabulary preference (SNOMED wins over ICD-10) +const codeable = await client.fhir.resolveCodeableConcept( + [ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, + ], + { resourceType: 'Condition' }, +); +console.log(codeable.data?.best_match?.resolution.source_concept.vocabulary_id); // "SNOMED" +``` + +The resolver follows the [HL7 FHIR-to-OMOP IG](https://hl7.org/fhir/uv/omop/INFORMATIVE1/en/): it resolves FHIR administrative codes via the IG ConceptMaps, decomposes composite concepts (`Maps to value`), honors `Coding.userSelected`, and can return a `concept_id` 0 sentinel instead of a 404. + +```ts +// Administrative gender → person.gender_concept_id (via IG ConceptMap) +await client.fhir.resolve({ + system: 'http://hl7.org/fhir/administrative-gender', + code: 'male', +}); + +// A user-selected coding wins over vocabulary preference +await client.fhir.resolveCodeableConcept([ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9', userSelected: true }, +]); + +// onUnmapped: 'sentinel' → concept_id 0 record instead of a 404 (one row per input for ETL) +await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '00000000', + onUnmapped: 'sentinel', +}); + +// Display-only semantic fallback - no code/system needed +await client.fhir.resolve({ display: 'blood glucose measurement' }); +``` + +Composite concepts (e.g. "Allergy to penicillin") additionally surface `resolution.value_as_concept` (the IG Value-as-Concept pattern). `onUnmapped` is accepted by `resolve()`, `resolveBatch()`, and `resolveCodeableConcept()`. + +### FHIR Type Interoperability + +The resolver accepts any Coding-like input via TypeScript structural typing - a plain object, the SDK's `Coding` interface, or any object with `system` and `code` properties (e.g. `fhir-kit-client` codings, generated FHIR resource types). + +```ts +import type { Coding } from '@omophub/omophub-node'; + +// SDK's typed interface - IDE autocomplete, no extra deps +const coding: Coding = { system: 'http://snomed.info/sct', code: '44054006' }; +const { data } = await client.fhir.resolve({ coding }); + +// Mixed shapes in a single batch call +await client.fhir.resolveBatch([ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://loinc.org', code: '2339-0' }, +]); +``` + +### FHIR Server URL Helper + +Point external FHIR client libraries at OMOPHub's hosted FHIR Terminology Service directly - useful when you need raw FHIR `Parameters` / `Bundle` responses instead of the Concept Resolver envelope. + +```ts +import { omophubFhirUrl } from '@omophub/omophub-node'; + +omophubFhirUrl(); // → "https://fhir.omophub.com/fhir/r4" +omophubFhirUrl('r5'); // → "https://fhir.omophub.com/fhir/r5" +omophubFhirUrl('r4b'); // → "https://fhir.omophub.com/fhir/r4b" +``` + +**When to use which**: the Concept Resolver (`client.fhir.resolve`) gives you OMOP-enriched answers - standard concept ID, CDM target table, mapping quality. Use the FHIR server URL directly with a FHIR client when you need raw FHIR responses for FHIR-native tooling. + +## Semantic Search + +Use natural language queries to find concepts using neural embeddings: + +```ts +// Natural language search - understands clinical intent +const { data } = await client.search.semantic('high blood sugar levels'); +for (const r of data?.results ?? []) { + console.log(`${r.concept_name} (similarity: ${r.similarity_score.toFixed(2)})`); +} + +// Filter by vocabulary and set minimum similarity threshold +await client.search.semantic('heart attack', { + vocabularyIds: ['SNOMED'], + domainIds: ['Condition'], + threshold: 0.5, +}); + +// Iterate through all results with auto-pagination +for await (const r of client.search.semanticIter('chronic kidney disease', { pageSize: 50 })) { + console.log(`${r.concept_id}: ${r.concept_name}`); +} +``` + +### Bulk Search + +Search for multiple terms in a single API call - much faster than individual requests: + +```ts +// Bulk lexical search (up to 50 queries) - `bulkBasic` returns a bare array +const bulk = await client.search.bulkBasic( + [ + { search_id: 'q1', query: 'diabetes mellitus' }, + { search_id: 'q2', query: 'hypertension' }, + { search_id: 'q3', query: 'aspirin' }, + ], + { defaults: { vocabulary_ids: ['SNOMED'], page_size: 5 } }, +); +for (const item of bulk.data ?? []) { + console.log(`${item.search_id}: ${item.results.length} results`); +} + +// Bulk semantic search (up to 25 queries) - returns a wrapper with aggregate counts +await client.search.bulkSemantic( + [ + { search_id: 's1', query: 'heart failure treatment options' }, + { search_id: 's2', query: 'type 2 diabetes medication' }, + ], + { defaults: { threshold: 0.5, page_size: 10 } }, +); +``` + +### Similarity Search + +Find concepts similar to a known concept or natural language query: + +```ts +// Find concepts similar to a known concept +const sim = await client.search.similar({ conceptId: 201826, algorithm: 'hybrid' }); +for (const r of sim.data?.similar_concepts ?? []) { + console.log(`${r.concept_name} (score: ${r.similarity_score.toFixed(2)})`); +} + +// Find similar concepts using a natural language query +await client.search.similar({ + query: 'medications for high blood pressure', + algorithm: 'semantic', + similarityThreshold: 0.6, + vocabularyIds: ['RxNorm'], + includeScores: true, +}); +``` + +## Async Iteration & Eager Collection + +Every paginated resource exposes `*Iter` (lazy async generator) and `*All` (eager collector) variants: + +```ts +// Async iterator - walks every page, throws OMOPHubIteratorError on page failure +for await (const c of client.search.basicIter('diabetes', { pageSize: 100 })) { + await process(c); +} + +// Eager collect - accumulates errors as values instead of throwing +const { data, errors, pagesFetched } = await client.search.basicAll('diabetes', { + maxPages: 10, +}); +console.log(`Got ${data.length} concepts across ${pagesFetched} pages, ${errors.length} errors`); +``` + +## Use Cases + +### ETL & Data Pipelines + +Validate and map clinical codes during OMOP CDM transformations: + +```ts +async function validateAndMap(sourceVocab: string, sourceCode: string): Promise { + const lookup = await client.concepts.getByCode(sourceVocab, sourceCode); + if (lookup.error || !lookup.data) return null; + + if (lookup.data.standard_concept === 'S') { + return lookup.data.concept_id; + } + + const mappings = await client.mappings.get(lookup.data.concept_id, { + targetVocabulary: 'SNOMED', + }); + return mappings.data?.mappings[0]?.target_concept_id ?? null; +} +``` + +### Data Quality Checks + +Verify codes exist and are valid standard concepts: + +```ts +const conditionCodes = ['E11.9', 'I10', 'J44.9']; // ICD-10 codes +for (const code of conditionCodes) { + const { data, error } = await client.concepts.getByCode('ICD10CM', code); + if (error?.name === 'not_found') { + console.log(`✗ ${code}: Invalid code!`); + } else if (data) { + console.log(`✓ ${code}: ${data.concept_name}`); + } +} +``` + +### Phenotype Development + +Explore hierarchies to build comprehensive concept sets: + +```ts +// Get all descendants of "Type 2 diabetes mellitus" for phenotype +const { data } = await client.hierarchy.descendants(201826, { maxLevels: 5 }); +const conceptSet = data?.descendants.map((d) => d.concept_id) ?? []; +console.log(`Found ${conceptSet.length} concepts for T2DM phenotype`); +``` + +### Clinical Applications + +Build terminology lookups into healthcare applications - works in Next.js server components, Express, Fastify, Hono, and edge functions: + +```ts +// Autocomplete endpoint (Next.js route handler) +export async function GET(req: Request) { + const url = new URL(req.url); + const q = url.searchParams.get('q') ?? ''; + + const { data } = await client.search.autocomplete(q, { + vocabularyIds: ['SNOMED'], + pageSize: 10, + }); + return Response.json(data?.suggestions ?? []); +} +``` + +## API Resources + +| Resource | Description | Key Methods | +|----------|-------------|-------------| +| `concepts` | Concept lookup, batch ops, suggestions | `get()`, `getByCode()`, `batch()`, `suggest()`, `related()`, `relationships()`, `recommended()` | +| `search` | Full-text and semantic search | `basic()`, `advanced()`, `semantic()`, `similar()`, `bulkBasic()`, `bulkSemantic()`, plus `*Iter` / `*All` | +| `hierarchy` | Navigate concept relationships | `get()`, `ancestors()`, `descendants()` | +| `relationships` | Concept relationship lookup | `get()`, `types()` | +| `mappings` | Cross-vocabulary mappings | `get()`, `map()` | +| `vocabularies` | Vocabulary metadata | `list()`, `get()`, `stats()`, `domainStats()`, `domains()`, `conceptClasses()`, `concepts()` | +| `domains` | Domain catalog | `list()`, `concepts()` | +| `fhir` | FHIR-to-OMOP resolution | `resolve()`, `resolveBatch()`, `resolveCodeableConcept()` | + +## Configuration + +```ts +const client = new OMOPHub('oh_xxx', { + baseUrl: 'https://api.omophub.com/v1', // API endpoint + timeoutMs: 30_000, // Request timeout (0 disables) + maxRetries: 3, // Retry attempts (0 disables) + vocabVersion: '2025.2', // Pin a specific vocabulary release + userAgent: 'my-app/1.0', // Override default UA + fetch: customFetch, // BYO fetch (e.g. for testing / proxies) +}); +``` + +| Option | Env var | Default | +|---|---|---| +| `apiKey` (1st constructor arg) | `OMOPHUB_API_KEY` | - (required) | +| `baseUrl` | `OMOPHUB_API_URL` | `https://api.omophub.com/v1` | +| `timeoutMs` | - | `30000` | +| `maxRetries` | - | `3` | +| `vocabVersion` | - | unset (server picks latest) | + +The client retries `429`, `502`, `503`, `504`, and transient network errors automatically (jittered exponential backoff, `Retry-After` honoured up to 60s). **POST and PATCH only retry when an `Idempotency-Key` header is set** - pass it via `{ idempotencyKey: '...' }`. + +## Error Handling + +```ts +const { data, error, headers } = await client.concepts.get(999_999_999); +if (error) { + switch (error.name) { + case 'not_found': + console.log(`Concept not found: ${error.message}`); + break; + case 'invalid_api_key': + case 'missing_api_key': + case 'restricted_api_key': + console.log('Check your API key'); + break; + case 'rate_limit_exceeded': + case 'tier_limit_exceeded': + console.log(`Rate limited. Retry after ${error.retryAfter}s`); + break; + case 'validation_error': + case 'missing_required_field': + case 'invalid_argument': + console.log(`Bad request: ${error.message}`); + break; + case 'timeout_error': + case 'connection_error': + console.log('Transport error - try again'); + break; + default: + console.log(`API error ${error.statusCode}: ${error.message}`); + } + return; +} +console.log(data.concept_name); // TypeScript narrows to the success type +``` + +Async iterators are the **only** API surface that throws - page failures during `for await` raise `OMOPHubIteratorError` (since generators can't yield discriminated errors gracefully): + +```ts +import { OMOPHubIteratorError } from '@omophub/omophub-node'; + +try { + for await (const c of client.search.basicIter('diabetes')) { + /* ... */ + } +} catch (e) { + if (e instanceof OMOPHubIteratorError) { + console.error(e.code, e.statusCode, e.message); + } +} +``` + +Prefer the `*All` variants (`basicAll`, `semanticAll`, etc.) when you want errors as values instead. + +## Type Safety + +The SDK ships hand-written TypeScript types for every request option and response shape - full IDE autocomplete, no codegen, no `any`. + +```ts +import { OMOPHub, type Concept } from '@omophub/omophub-node'; + +const client = new OMOPHub(); +const { data, error } = await client.concepts.get(201826); +if (error) throw new Error(error.message); + +// `data` is narrowed to `Concept` +const c: Concept = data; +c.concept_id; // number +c.concept_name; // string +c.vocabulary_id; // string +c.domain_id; // string +c.concept_class_id; // string +c.standard_concept; // 'S' | 'C' | 'N' | null +``` + +Request options are camelCase TypeScript; wire fields are snake_case. The SDK converts between them at the request boundary - you write `{ vocabularyIds: ['SNOMED'] }` and the server sees `vocabulary_ids=SNOMED`. + +## Runtime Support + +| Runtime | Status | Notes | +|---|---|---| +| Node.js ≥ 22 | First-class | Primary target | +| Deno ≥ 1.40 | First-class | Native `fetch`, ESM-only | +| Bun ≥ 1.0 | First-class | Native `fetch`, ESM-only | +| Cloudflare Workers | Supported | Pure ESM, no Node built-ins required | +| Vercel Edge | Supported | Same as Workers | +| Browser | Supported | CORS configured on `api.omophub.com` | + +## Compared to Alternatives + +| Feature | OMOPHub SDK | ATHENA Download | OHDSI WebAPI | +|---------|-------------|-----------------|--------------| +| Setup time | 1 minute | Hours | Hours | +| Infrastructure | None | Database required | Full OHDSI stack | +| Updates | Automatic | Manual download | Manual | +| TypeScript types | First-class | None | Generated from OpenAPI | +| Edge runtime support | Yes | N/A | No | + +**Best for:** Teams who need quick, type-safe access to OMOP vocabularies from JavaScript/TypeScript without infrastructure overhead. + +## Documentation + +- [Full Documentation](https://docs.omophub.com/sdks/node/overview) +- [API Reference](https://docs.omophub.com/api-reference) +- [Examples](https://github.com/OMOPHub/omophub-node/tree/main/examples) +- [Get API Key](https://dashboard.omophub.com/api-keys) + +## Contributing + +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. + +```bash +# Clone and install for development +git clone https://github.com/OMOPHub/omophub-node.git +cd omophub-node +npm install + +# Run tests +npm test # unit tests +npm run test:e2e # integration tests (requires OMOPHUB_API_KEY) +npm run typecheck && npm run lint && npm run build +``` + +## Support + +- [GitHub Issues](https://github.com/OMOPHub/omophub-node/issues) +- [GitHub Discussions](https://github.com/OMOPHub/omophub-node/discussions) +- Email: support@omophub.com +- Website: [omophub.com](https://omophub.com) + +## License + +MIT License - see [LICENSE](./LICENSE) for details. + +--- + +*Built for the OHDSI community* diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..1ac106e --- /dev/null +++ b/biome.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "warn" + }, + "correctness": { + "noUnusedVariables": "error", + "noUnusedImports": "error" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all", + "semicolons": "always" + } + }, + "files": { + "includes": ["src/**", "test/**", "e2e/**"] + }, + "overrides": [ + { + "includes": ["test/**", "e2e/**"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + } + ] +} diff --git a/e2e/_helpers.ts b/e2e/_helpers.ts new file mode 100644 index 0000000..3adf546 --- /dev/null +++ b/e2e/_helpers.ts @@ -0,0 +1,84 @@ +import { OMOPHub } from '../src/index.js'; + +/** + * Well-known concept IDs used across the e2e smoke suite. Same set the + * Python and R SDK test suites use for cross-SDK parity. + */ +export const E2E_CONCEPT_IDS = { + diabetes: 201826, + aspirin: 1112807, + hypertension: 316866, + covid: 37311061, +} as const; + +const API_KEY = process.env.OMOPHUB_API_KEY; + +/** + * Skip-or-run guard. If `OMOPHUB_API_KEY` isn't present in the + * environment (CI without the secret, contributor without `.env`), the + * suite no-ops with a clear message instead of failing. + */ +export const e2eEnabled = typeof API_KEY === 'string' && API_KEY.length > 0; + +/** + * Throws if you try to construct a client without the key — `e2eEnabled` + * gates this so it should never fire inside an e2e test. + */ +export function e2eClient(): OMOPHub { + if (!e2eEnabled) { + throw new Error('e2eClient() called without OMOPHUB_API_KEY — guard with e2eEnabled.'); + } + return new OMOPHub(API_KEY, { + // Generous timeout: some live queries (semantic search, exactMatch) + // can take 15–25 s before responding. + timeoutMs: 45_000, + maxRetries: 2, + }); +} + +/** + * Client variant for tests that EXPECT an error response (404, 400, etc.) + * — disables retries so a known-bad call fails fast and stays inside the + * 60 s test timeout. Used by `server-errors.test.ts` and similar. + */ +export function e2eClientNoRetry(): OMOPHub { + if (!e2eEnabled) { + throw new Error('e2eClientNoRetry() called without OMOPHUB_API_KEY.'); + } + return new OMOPHub(API_KEY, { + timeoutMs: 20_000, + maxRetries: 0, + }); +} + +/** + * Brief per-suite throttle to avoid hot-spotting the API rate limiter + * when the full e2e suite runs back-to-back. ~2 req/sec global ceiling. + * + * Two implementation notes: + * + * 1. **Cross-file state via `globalThis`** — vitest loads each test file + * in its own module context, so a plain `let lastRequestAt = 0` would + * reset at every file boundary, letting the first call in each of the + * 11 e2e files fire with zero delay. We pin the timestamp on + * `globalThis` (a single shared object across module contexts) so the + * throttle is genuinely suite-wide. + * + * 2. **Reservation pattern** — each call advances `nextAllowedAt` to its + * target time *before* awaiting, so even concurrent calls (which the + * suite avoids today via `fileParallelism: false`, but might allow in + * the future) get queued slots ~500 ms apart rather than racing on a + * single shared timestamp. + */ +const THROTTLE_MS = 500; +const THROTTLE_KEY = Symbol.for('omophub-node:e2e:softThrottle:nextAllowedAt'); +type ThrottleHost = { [THROTTLE_KEY]?: number }; + +export async function softThrottle(): Promise { + const host = globalThis as ThrottleHost; + const now = Date.now(); + const targetTime = Math.max(now, (host[THROTTLE_KEY] ?? 0) + THROTTLE_MS); + host[THROTTLE_KEY] = targetTime; + const wait = targetTime - now; + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); +} diff --git a/e2e/abort-timeout.test.ts b/e2e/abort-timeout.test.ts new file mode 100644 index 0000000..5b8b673 --- /dev/null +++ b/e2e/abort-timeout.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../src/index.js'; +import { e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +const API_KEY = process.env.OMOPHUB_API_KEY ?? ''; + +describe('e2e: AbortSignal + timeout', () => { + runOrSkip('pre-aborted signal re-throws AbortError without hitting the network', async () => { + const controller = new AbortController(); + controller.abort(new DOMException('caller-aborted', 'AbortError')); + + const client = new OMOPHub(API_KEY, { maxRetries: 0 }); + let caught: unknown; + try { + await client.vocabularies.list({ signal: controller.signal }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).name).toBe('AbortError'); + }); + + runOrSkip('signal aborted mid-flight re-throws AbortError', async () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(new DOMException('mid-flight', 'AbortError')), 5); + + const client = new OMOPHub(API_KEY, { maxRetries: 0 }); + let caught: unknown; + try { + await client.search.basic('diabetes', { + pageSize: 100, + signal: controller.signal, + }); + } catch (e) { + caught = e; + } + // Either the request was aborted (AbortError thrown) or it completed + // before the abort fired — both are valid race outcomes + if (caught) { + expect((caught as Error).name).toBe('AbortError'); + } + }); + + runOrSkip('very short timeoutMs returns timeout_error, does not throw', async () => { + await softThrottle(); + const client = new OMOPHub(API_KEY, { + maxRetries: 0, + timeoutMs: 1, // 1ms — the live API can't respond that fast + }); + const { data, error } = await client.search.basic('diabetes', { pageSize: 100 }); + expect(data).toBeNull(); + // Either timeout fired before the request resolved, or it completed + // anyway (network/process latency variance). If it errored, it must be + // a timeout_error or connection_error. + if (error) { + expect(['timeout_error', 'connection_error']).toContain(error.name); + } + }); + + runOrSkip('caller signal aborts during retry sleep also re-throws AbortError', async () => { + // Trigger by hitting a 503-ish endpoint that retries, but abort during + // the backoff. We approximate this with a short timeoutMs + max retries. + const controller = new AbortController(); + setTimeout(() => controller.abort(new DOMException('during-retry', 'AbortError')), 20); + + const client = new OMOPHub(API_KEY, { maxRetries: 3 }); + let caught: unknown; + try { + await client.vocabularies.list({ signal: controller.signal }); + } catch (e) { + caught = e; + } + if (caught) { + expect((caught as Error).name).toBe('AbortError'); + } + }); +}); diff --git a/e2e/auth.test.ts b/e2e/auth.test.ts new file mode 100644 index 0000000..653fb15 --- /dev/null +++ b/e2e/auth.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub, OMOPHubError } from '../src/index.js'; +import { e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: auth', () => { + test('constructor throws OMOPHubError when no key is supplied or in env', () => { + const original = process.env.OMOPHUB_API_KEY; + delete process.env.OMOPHUB_API_KEY; + try { + expect(() => new OMOPHub()).toThrowError(OMOPHubError); + } finally { + if (original !== undefined) process.env.OMOPHUB_API_KEY = original; + } + }); + + runOrSkip('an obviously bad API key gets invalid_api_key from the live API', async () => { + await softThrottle(); + const client = new OMOPHub('oh_invalid_definitely_not_a_real_key_xxx', { + maxRetries: 0, + }); + const { data, error } = await client.vocabularies.list({ pageSize: 1 }); + expect(data).toBeNull(); + expect(error).not.toBeNull(); + // Server may use any of these depending on tier / gating + expect([ + 'invalid_api_key', + 'restricted_api_key', + 'missing_api_key', + 'application_error', + ]).toContain(error?.name); + expect([401, 403]).toContain(error?.statusCode); + }); + + runOrSkip('empty API key string is rejected (treated as missing)', () => { + expect(() => new OMOPHub('')).toThrowError(OMOPHubError); + }); + + runOrSkip('whitespace-only key passes the constructor but server rejects it', async () => { + await softThrottle(); + const client = new OMOPHub(' ', { maxRetries: 0 }); + const { error } = await client.vocabularies.list({ pageSize: 1 }); + expect(error).not.toBeNull(); + expect([401, 403]).toContain(error?.statusCode); + }); +}); diff --git a/e2e/concepts.test.ts b/e2e/concepts.test.ts new file mode 100644 index 0000000..457a91c --- /dev/null +++ b/e2e/concepts.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, test } from 'vitest'; +import { + E2E_CONCEPT_IDS, + e2eClient, + e2eClientNoRetry, + e2eEnabled, + softThrottle, +} from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: client.concepts.get', () => { + runOrSkip('returns the Type 2 diabetes mellitus row by ID', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.get(E2E_CONCEPT_IDS.diabetes); + expect(error).toBeNull(); + expect(data?.concept_id).toBe(E2E_CONCEPT_IDS.diabetes); + expect(data?.vocabulary_id).toBe('SNOMED'); + expect(data?.domain_id).toBe('Condition'); + expect(data?.standard_concept).toBe('S'); + expect(typeof data?.valid_start_date).toBe('string'); + expect(typeof data?.valid_end_date).toBe('string'); + }); + + runOrSkip('includeSynonyms returns synonyms array', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.get(E2E_CONCEPT_IDS.diabetes, { + includeSynonyms: true, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.synonyms)).toBe(true); + }); + + runOrSkip('includeRelationships returns a { parents, children } object', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.get(E2E_CONCEPT_IDS.diabetes, { + includeRelationships: true, + }); + expect(error).toBeNull(); + expect(data?.relationships).toBeTruthy(); + expect(typeof data?.relationships).toBe('object'); + expect(Array.isArray(data?.relationships?.parents)).toBe(true); + expect(Array.isArray(data?.relationships?.children)).toBe(true); + }); + + runOrSkip('all four known fixture concepts resolve and stay typed', async () => { + await softThrottle(); + const client = e2eClient(); + for (const conceptId of Object.values(E2E_CONCEPT_IDS)) { + const { data, error } = await client.concepts.get(conceptId); + await softThrottle(); + expect(error).toBeNull(); + expect(data?.concept_id).toBe(conceptId); + expect(typeof data?.concept_name).toBe('string'); + } + }); + + runOrSkip('vocabRelease pin sends a `vocab_release` query param', async () => { + // We can't easily verify the URL hit the live API; instead confirm + // either a successful resolution OR a structured error (NOT a + // network exception). + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.get(E2E_CONCEPT_IDS.diabetes, { + vocabRelease: '2025.1', + }); + if (error) { + expect(['not_found', 'validation_error', 'application_error']).toContain(error.name); + return; + } + expect(data?.concept_id).toBe(E2E_CONCEPT_IDS.diabetes); + expect(typeof data?.concept_name).toBe('string'); + expect((data?.concept_name ?? '').toLowerCase()).toContain('diabetes'); + }); +}); + +describe('e2e: client.concepts.getByCode', () => { + runOrSkip('SNOMED 44054006 → Type 2 diabetes', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.getByCode('SNOMED', '44054006'); + expect(error).toBeNull(); + expect(data?.concept_id).toBe(E2E_CONCEPT_IDS.diabetes); + }); + + runOrSkip('round-trip consistency: get(id) and getByCode(vocab, code) match', async () => { + await softThrottle(); + const client = e2eClient(); + const byId = await client.concepts.get(E2E_CONCEPT_IDS.diabetes); + await softThrottle(); + const byCode = await client.concepts.getByCode( + byId.data?.vocabulary_id ?? 'SNOMED', + byId.data?.concept_code ?? '44054006', + ); + expect(byId.error).toBeNull(); + expect(byCode.error).toBeNull(); + expect(byCode.data?.concept_id).toBe(byId.data?.concept_id); + expect(byCode.data?.concept_name).toBe(byId.data?.concept_name); + }); + + runOrSkip( + 'includeRelationships flag flows through and returns { parents, children }', + async () => { + await softThrottle(); + // Slow server path: getByCode + includeRelationships can take >25s. + // No-retry client to stay inside the 60s test timeout; tolerate + // structured timeout as a valid outcome. + const client = e2eClientNoRetry(); + const { data, error } = await client.concepts.getByCode('SNOMED', '44054006', { + includeRelationships: true, + }); + if (error) { + expect(['timeout_error', 'connection_error', 'rate_limit_exceeded']).toContain(error.name); + return; + } + expect(data?.relationships).toBeTruthy(); + expect(typeof data?.relationships).toBe('object'); + }, + ); +}); + +describe('e2e: client.concepts.batch', () => { + runOrSkip('returns concepts for multiple IDs', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.batch({ + conceptIds: [E2E_CONCEPT_IDS.diabetes, E2E_CONCEPT_IDS.hypertension], + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.concepts)).toBe(true); + expect(data?.concepts.length).toBeGreaterThanOrEqual(2); + }); + + runOrSkip('single-id batch is a valid request (length=1, the minimum)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.batch({ + conceptIds: [E2E_CONCEPT_IDS.diabetes], + }); + expect(error).toBeNull(); + expect(data?.concepts.length).toBeGreaterThanOrEqual(1); + }); + + runOrSkip('all four known fixtures returned as a batch', async () => { + await softThrottle(); + const client = e2eClient(); + const ids = Object.values(E2E_CONCEPT_IDS); + const { data, error } = await client.concepts.batch({ conceptIds: ids }); + // Tolerate rate-limit errors when run as part of the full e2e + // sweep — the suite stays within healthcare-tier quotas but spike + // bursts can still occur. + if (error?.name === 'rate_limit_exceeded') return; + expect(error).toBeNull(); + const returnedIds = new Set((data?.concepts ?? []).map((c) => c.concept_id)); + for (const id of ids) { + const inResults = returnedIds.has(id); + const inFailures = data?.failed_concepts?.some((f) => f.concept_id === id) ?? false; + expect(inResults || inFailures).toBe(true); + } + }); + + runOrSkip('batch with one unknown ID either omits or reports it in failed_concepts', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.batch({ + conceptIds: [E2E_CONCEPT_IDS.diabetes, 999_999_999], + }); + if (error?.name === 'rate_limit_exceeded') return; + expect(error).toBeNull(); + const returnedIds = (data?.concepts ?? []).map((c) => c.concept_id); + expect(returnedIds).toContain(E2E_CONCEPT_IDS.diabetes); + }); + + runOrSkip('synthetic validation: empty array does not hit network', async () => { + const client = e2eClient(); + const { error } = await client.concepts.batch({ conceptIds: [] }); + expect(error?.name).toBe('validation_error'); + expect(error?.statusCode).toBeNull(); + }); + + runOrSkip('synthetic validation: >100 ids does not hit network', async () => { + const client = e2eClient(); + const tooMany = Array.from({ length: 101 }, (_, i) => 200_000 + i); + const { error } = await client.concepts.batch({ conceptIds: tooMany }); + expect(error?.name).toBe('validation_error'); + expect(error?.statusCode).toBeNull(); + }); +}); + +describe('e2e: client.concepts.suggest', () => { + runOrSkip('returns concept-summary entries for a query prefix', async () => { + await softThrottle(); + // Slow on first-cache-miss; no-retry client to stay inside test budget. + const client = e2eClientNoRetry(); + const { data, error } = await client.concepts.suggest('diab', { + pageSize: 5, + vocabularyIds: ['SNOMED'], + }); + if (error) { + expect(['timeout_error', 'connection_error', 'rate_limit_exceeded']).toContain(error.name); + return; + } + expect(data).toBeTruthy(); + }); +}); + +describe('e2e: client.concepts.related + cross-method consistency', () => { + runOrSkip('related returns a bare array of related concepts', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.related(E2E_CONCEPT_IDS.diabetes, { + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data)).toBe(true); + if ((data?.length ?? 0) > 0) { + const first = data?.[0]; + expect(typeof first?.concept_name).toBe('string'); + expect(typeof first?.relationship_id).toBe('string'); + } + }); + + runOrSkip( + 'concepts.relationships and relationships.get produce identical wire results', + async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const a = await client.concepts.relationships(E2E_CONCEPT_IDS.diabetes, { + pageSize: 5, + vocabularyIds: ['SNOMED'], + }); + const transient = ['timeout_error', 'connection_error', 'rate_limit_exceeded']; + if (a.error && transient.includes(a.error.name)) return; + await softThrottle(); + const b = await client.relationships.get(E2E_CONCEPT_IDS.diabetes, { + pageSize: 5, + vocabularyIds: ['SNOMED'], + }); + if (b.error && transient.includes(b.error.name)) return; + expect(a.error).toBeNull(); + expect(b.error).toBeNull(); + expect(a.data?.relationships.length).toBe(b.data?.relationships.length); + }, + ); +}); + +describe('e2e: client.concepts.recommended', () => { + runOrSkip('returns recommendations keyed by source concept ID (as string)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.recommended({ + conceptIds: [E2E_CONCEPT_IDS.diabetes], + pageSize: 10, + }); + expect(error).toBeNull(); + expect(data).toBeTruthy(); + expect(typeof data).toBe('object'); + // Keyed by source concept ID as a string + const key = String(E2E_CONCEPT_IDS.diabetes); + const entries = data?.[key]; + expect(Array.isArray(entries)).toBe(true); + if (entries && entries.length > 0) { + expect(typeof entries[0]?.concept_name).toBe('string'); + } + }); +}); diff --git a/e2e/domains.test.ts b/e2e/domains.test.ts new file mode 100644 index 0000000..d577c55 --- /dev/null +++ b/e2e/domains.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'vitest'; +import { e2eClient, e2eClientNoRetry, e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: client.domains.list', () => { + runOrSkip('returns the OMOP domain catalog wrapped under .domains', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.domains.list(); + expect(error).toBeNull(); + expect(Array.isArray(data?.domains)).toBe(true); + const ids = (data?.domains ?? []).map((d) => d.domain_id); + expect(ids).toContain('Condition'); + expect(ids).toContain('Drug'); + }); + + runOrSkip('includeStats returns per-domain stats when requested', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.domains.list({ includeStats: true }); + expect(error).toBeNull(); + expect(Array.isArray(data?.domains)).toBe(true); + }); +}); + +describe('e2e: client.domains.concepts', () => { + runOrSkip('Condition returns paginated standard concepts', async () => { + await softThrottle(); + // standardOnly+Condition is a heavy server query; no-retry client + // keeps us inside the test timeout. Tolerate structured timeout. + const client = e2eClientNoRetry(); + const { data, error, meta } = await client.domains.concepts('Condition', { + pageSize: 5, + standardOnly: true, + }); + if (error) { + expect(['timeout_error', 'connection_error', 'rate_limit_exceeded']).toContain(error.name); + return; + } + expect(Array.isArray(data?.concepts)).toBe(true); + expect(data?.concepts.length).toBeGreaterThan(0); + expect(typeof meta?.pagination?.total_items).toBe('number'); + for (const c of data?.concepts ?? []) { + expect(c.vocabulary_id).toBeTruthy(); + } + }); + + runOrSkip('Drug + vocabularyIds=["RxNorm"] narrows to RxNorm only', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.domains.concepts('Drug', { + vocabularyIds: ['RxNorm'], + pageSize: 10, + }); + if (error) { + expect(['timeout_error', 'connection_error', 'rate_limit_exceeded']).toContain(error.name); + return; + } + for (const c of data?.concepts ?? []) { + expect(c.vocabulary_id).toBe('RxNorm'); + } + }); + + runOrSkip('Measurement domain returns concepts', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.domains.concepts('Measurement', { pageSize: 5 }); + if (error) { + expect(['timeout_error', 'connection_error', 'rate_limit_exceeded']).toContain(error.name); + return; + } + expect(Array.isArray(data?.concepts)).toBe(true); + }); + + runOrSkip('cross-method: domains.concepts and search by domain agree on schema', async () => { + await softThrottle(); + const client = e2eClient(); + const direct = await client.domains.concepts('Condition', { + vocabularyIds: ['SNOMED'], + pageSize: 5, + standardOnly: true, + }); + await softThrottle(); + const viaSearch = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED'], + domainIds: ['Condition'], + standardConcept: 'S', + pageSize: 5, + }); + expect(direct.error).toBeNull(); + expect(viaSearch.error).toBeNull(); + for (const c of direct.data?.concepts ?? []) { + expect(c.vocabulary_id).toBe('SNOMED'); + } + for (const c of viaSearch.data?.concepts ?? []) { + if (c.domain_id) expect(c.domain_id).toBe('Condition'); + } + }); +}); diff --git a/e2e/fhir.test.ts b/e2e/fhir.test.ts new file mode 100644 index 0000000..cb7743c --- /dev/null +++ b/e2e/fhir.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from 'vitest'; +import { omophubFhirUrl } from '../src/index.js'; +import { E2E_CONCEPT_IDS, e2eClient, e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: client.fhir.resolve', () => { + runOrSkip('SNOMED 44054006 → Type 2 diabetes (direct mapping)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + resourceType: 'Condition', + }); + expect(error).toBeNull(); + expect(data?.resolution.standard_concept.concept_id).toBe(E2E_CONCEPT_IDS.diabetes); + expect(data?.resolution.target_table).toBe('condition_occurrence'); + expect(typeof data?.resolution.mapping_type).toBe('string'); + }); + + runOrSkip('ICD10CM E11.9 → SNOMED (maps-to traversal)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolve({ + system: 'http://hl7.org/fhir/sid/icd-10-cm', + code: 'E11.9', + }); + expect(error).toBeNull(); + expect(data?.resolution.source_concept.vocabulary_id).toBe('ICD10CM'); + expect(data?.resolution.standard_concept.vocabulary_id).toBe('SNOMED'); + }); + + runOrSkip('nested coding-object form is equivalent to flat form', async () => { + await softThrottle(); + const client = e2eClient(); + const flat = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + }); + await softThrottle(); + const nested = await client.fhir.resolve({ + coding: { system: 'http://snomed.info/sct', code: '44054006' }, + }); + expect(flat.error).toBeNull(); + expect(nested.error).toBeNull(); + expect(nested.data?.resolution.standard_concept.concept_id).toBe( + flat.data?.resolution.standard_concept.concept_id, + ); + }); + + runOrSkip('vocabularyId shortcut bypasses URI lookup', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolve({ + vocabularyId: 'ICD10CM', + code: 'E11.9', + }); + expect(error).toBeNull(); + expect(data?.resolution.source_concept.vocabulary_id).toBe('ICD10CM'); + }); + + runOrSkip('includeRecommendations populates the recommendations array', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + includeRecommendations: true, + recommendationsLimit: 3, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.resolution.recommendations)).toBe(true); + }); + + runOrSkip('includeQuality populates mapping_quality (string bucket)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + includeQuality: true, + }); + expect(error).toBeNull(); + expect(typeof data?.resolution.mapping_quality).toBe('string'); + // Common buckets per the docs — server may add more + expect(['high', 'medium', 'low', 'manual_review']).toContain(data?.resolution.mapping_quality); + }); + + runOrSkip('synthetic: empty code is rejected without network call', async () => { + const client = e2eClient(); + const { error } = await client.fhir.resolve({ system: 'http://snomed.info/sct', code: '' }); + expect(error?.name).toBe('missing_required_field'); + expect(error?.statusCode).toBeNull(); + }); + + runOrSkip('synthetic: coding without code is rejected without network call', async () => { + const client = e2eClient(); + const { error } = await client.fhir.resolve({ + coding: { system: 'http://snomed.info/sct' }, + }); + expect(error?.name).toBe('missing_required_field'); + expect(error?.statusCode).toBeNull(); + }); +}); + +describe('e2e: client.fhir.resolveBatch', () => { + runOrSkip('2-coding batch reports total/resolved/failed', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolveBatch( + [ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, + ], + { resourceType: 'Condition' }, + ); + expect(error).toBeNull(); + expect(data?.summary.total).toBe(2); + expect(typeof data?.summary.resolved).toBe('number'); + expect(typeof data?.summary.failed).toBe('number'); + expect((data?.summary.resolved ?? 0) + (data?.summary.failed ?? 0)).toBe(2); + }); + + runOrSkip('single-coding batch (minimum) is valid', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolveBatch([ + { system: 'http://snomed.info/sct', code: '44054006' }, + ]); + expect(error).toBeNull(); + expect(data?.summary.total).toBe(1); + }); + + runOrSkip('synthetic: empty array is rejected without network call', async () => { + const client = e2eClient(); + const { error } = await client.fhir.resolveBatch([]); + expect(error?.name).toBe('validation_error'); + expect(error?.statusCode).toBeNull(); + }); + + runOrSkip('synthetic: 101 codings is rejected without network call', async () => { + const client = e2eClient(); + const tooMany = Array.from({ length: 101 }, () => ({ + system: 'http://snomed.info/sct', + code: '44054006', + })); + const { error } = await client.fhir.resolveBatch(tooMany); + expect(error?.name).toBe('validation_error'); + expect(error?.statusCode).toBeNull(); + }); +}); + +describe('e2e: client.fhir.resolveCodeableConcept', () => { + runOrSkip('picks the best match across multiple codings', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.fhir.resolveCodeableConcept( + [ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, + ], + { resourceType: 'Condition' }, + ); + expect(error).toBeNull(); + expect(data?.best_match).toBeTruthy(); + expect(Array.isArray(data?.alternatives)).toBe(true); + expect(Array.isArray(data?.unresolved)).toBe(true); + }); + + runOrSkip('synthetic: >20 codings is rejected without network call', async () => { + const client = e2eClient(); + const tooMany = Array.from({ length: 21 }, () => ({ + system: 'http://snomed.info/sct', + code: '44054006', + })); + const { error } = await client.fhir.resolveCodeableConcept(tooMany); + expect(error?.name).toBe('validation_error'); + expect(error?.statusCode).toBeNull(); + }); +}); + +describe('e2e: standalone helpers', () => { + test('omophubFhirUrl returns the documented base for each FHIR version', () => { + expect(omophubFhirUrl()).toBe('https://fhir.omophub.com/fhir/r4'); + expect(omophubFhirUrl('r4')).toBe('https://fhir.omophub.com/fhir/r4'); + expect(omophubFhirUrl('r4b')).toBe('https://fhir.omophub.com/fhir/r4b'); + expect(omophubFhirUrl('r5')).toBe('https://fhir.omophub.com/fhir/r5'); + expect(omophubFhirUrl('r6')).toBe('https://fhir.omophub.com/fhir/r6'); + }); +}); diff --git a/e2e/hierarchy-relationships-mappings.test.ts b/e2e/hierarchy-relationships-mappings.test.ts new file mode 100644 index 0000000..e2eefda --- /dev/null +++ b/e2e/hierarchy-relationships-mappings.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, test } from 'vitest'; +import { + E2E_CONCEPT_IDS, + e2eClient, + e2eClientNoRetry, + e2eEnabled, + softThrottle, +} from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: client.hierarchy.get', () => { + runOrSkip('format="flat" returns concepts + paths arrays', async () => { + await softThrottle(); + // Hierarchy traversal is one of the heaviest server queries; use the + // no-retry client to stay inside the test timeout. + const client = e2eClientNoRetry(); + const { data, error } = await client.hierarchy.get(E2E_CONCEPT_IDS.diabetes, { + format: 'flat', + maxLevels: 2, + vocabularyIds: ['SNOMED'], + }); + if (error) { + expect(['timeout_error', 'connection_error', 'rate_limit_exceeded']).toContain(error.name); + return; + } + expect(data).toBeTruthy(); + }); + + runOrSkip('format="graph" returns nodes + edges arrays', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.hierarchy.get(E2E_CONCEPT_IDS.diabetes, { + format: 'graph', + maxLevels: 2, + }); + expect(error).toBeNull(); + expect(data).toBeTruthy(); + }); + + runOrSkip('maxLevels above the server cap returns a structured validation_error', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.hierarchy.get(E2E_CONCEPT_IDS.diabetes, { + maxLevels: 50, // exceeds the server cap of 20 + }); + // Server rejects rather than silently capping — both are valid + // contracts; the SDK just needs to surface the error structurally. + if (error) { + expect(error.name).toBe('validation_error'); + expect(error.statusCode).toBe(400); + expect(data).toBeNull(); + } else { + expect(data).toBeTruthy(); + } + }); +}); + +describe('e2e: client.hierarchy.ancestors', () => { + runOrSkip('returns ancestors of diabetes filtered by vocabulary', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.hierarchy.ancestors(E2E_CONCEPT_IDS.diabetes, { + vocabularyIds: ['SNOMED'], + pageSize: 10, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.ancestors)).toBe(true); + }); + + runOrSkip('includeDistance flag is accepted and ancestors are typed correctly', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.hierarchy.ancestors(E2E_CONCEPT_IDS.diabetes, { + vocabularyIds: ['SNOMED'], + includeDistance: true, + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.ancestors)).toBe(true); + // Distance fields (`level`, `min_levels_of_separation`, + // `max_levels_of_separation`) are server-populated and may not be + // present on every row, so we don't assert their presence — only the + // base ancestor concept shape. + for (const a of data?.ancestors ?? []) { + expect(typeof a.concept_id).toBe('number'); + expect(typeof a.concept_name).toBe('string'); + } + }); +}); + +describe('e2e: client.hierarchy.descendants', () => { + runOrSkip('returns descendants with maxLevels filter', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.hierarchy.descendants(E2E_CONCEPT_IDS.diabetes, { + maxLevels: 2, + pageSize: 10, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.descendants)).toBe(true); + }); + + runOrSkip('domainIds filter restricts descendants to a domain', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.hierarchy.descendants(E2E_CONCEPT_IDS.diabetes, { + maxLevels: 2, + domainIds: ['Condition'], + pageSize: 5, + }); + expect(error).toBeNull(); + for (const d of data?.descendants ?? []) { + if (d.domain_id) expect(d.domain_id).toBe('Condition'); + } + }); +}); + +describe('e2e: client.relationships', () => { + runOrSkip('types() returns the relationship-type catalog', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.relationships.types({ pageSize: 50 }); + expect(error).toBeNull(); + expect(Array.isArray(data?.relationship_types)).toBe(true); + expect(data?.relationship_types.length).toBeGreaterThan(0); + }); + + runOrSkip( + 'get(diabetes) and concepts.relationships(diabetes) hit the same endpoint with matching counts', + async () => { + await softThrottle(); + const client = e2eClient(); + const a = await client.relationships.get(E2E_CONCEPT_IDS.diabetes, { + pageSize: 10, + vocabularyIds: ['SNOMED'], + }); + await softThrottle(); + const b = await client.concepts.relationships(E2E_CONCEPT_IDS.diabetes, { + pageSize: 10, + vocabularyIds: ['SNOMED'], + }); + expect(a.error).toBeNull(); + expect(b.error).toBeNull(); + expect(a.data?.relationships.length).toBe(b.data?.relationships.length); + }, + ); + + runOrSkip('includeReverse adds reverse relationships', async () => { + await softThrottle(); + const client = e2eClient(); + const without = await client.relationships.get(E2E_CONCEPT_IDS.diabetes, { + pageSize: 5, + vocabularyIds: ['SNOMED'], + includeReverse: false, + }); + await softThrottle(); + const withReverse = await client.relationships.get(E2E_CONCEPT_IDS.diabetes, { + pageSize: 5, + vocabularyIds: ['SNOMED'], + includeReverse: true, + }); + expect(without.error).toBeNull(); + expect(withReverse.error).toBeNull(); + // Reverse-inclusive set is a superset + const a = without.meta?.pagination?.total_items ?? 0; + const b = withReverse.meta?.pagination?.total_items ?? 0; + expect(b).toBeGreaterThanOrEqual(a); + }); +}); + +describe('e2e: client.mappings.get', () => { + runOrSkip('returns mappings for diabetes', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.mappings.get(E2E_CONCEPT_IDS.diabetes); + expect(error).toBeNull(); + expect(Array.isArray(data?.mappings)).toBe(true); + }); + + runOrSkip('targetVocabulary filter narrows results', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.mappings.get(E2E_CONCEPT_IDS.diabetes, { + targetVocabulary: 'ICD10CM', + }); + expect(error).toBeNull(); + for (const m of data?.mappings ?? []) { + if (m.target_vocabulary_id) expect(m.target_vocabulary_id).toBe('ICD10CM'); + } + }); +}); + +describe('e2e: client.mappings.map', () => { + runOrSkip('SNOMED ← ICD10CM E11.9 (sourceCodes variant)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceCodes: [{ vocabulary_id: 'ICD10CM', concept_code: 'E11.9' }], + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.mappings)).toBe(true); + }); + + runOrSkip('ICD10CM ← OMOP concept IDs (sourceConcepts variant)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.mappings.map({ + targetVocabulary: 'ICD10CM', + sourceConcepts: [E2E_CONCEPT_IDS.diabetes], + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.mappings)).toBe(true); + }); + + runOrSkip('mappingType filter is accepted', async () => { + await softThrottle(); + const client = e2eClient(); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [E2E_CONCEPT_IDS.diabetes], + mappingType: 'direct', + }); + expect(error).toBeNull(); + }); + + runOrSkip('synthetic XOR: empty sourceConcepts does not hit network', async () => { + const client = e2eClient(); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [], + }); + expect(error?.name).toBe('missing_required_field'); + expect(error?.statusCode).toBeNull(); + }); + + runOrSkip('synthetic XOR: empty sourceCodes does not hit network', async () => { + const client = e2eClient(); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceCodes: [], + }); + expect(error?.name).toBe('missing_required_field'); + expect(error?.statusCode).toBeNull(); + }); +}); diff --git a/e2e/pagination.test.ts b/e2e/pagination.test.ts new file mode 100644 index 0000000..290f6f9 --- /dev/null +++ b/e2e/pagination.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHubIteratorError } from '../src/index.js'; +import { e2eClient, e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: pagination behaviour', () => { + runOrSkip('basicAll respects maxPages and reports pagesFetched accurately', async () => { + await softThrottle(); + const client = e2eClient(); + const result = await client.search.basicAll('diabetes', { + vocabularyIds: ['SNOMED'], + pageSize: 5, + maxPages: 3, + }); + expect(result.errors).toEqual([]); + expect(result.pagesFetched).toBeGreaterThanOrEqual(1); + expect(result.pagesFetched).toBeLessThanOrEqual(3); + // Total items can't exceed maxPages * pageSize + expect(result.data.length).toBeLessThanOrEqual(15); + }); + + runOrSkip('basicIter stops cleanly when caller breaks out of the loop', async () => { + await softThrottle(); + const client = e2eClient(); + let count = 0; + for await (const _ of client.search.basicIter('diabetes', { pageSize: 10 })) { + count++; + if (count >= 7) break; + } + expect(count).toBe(7); + }); + + runOrSkip('basicIter terminates naturally on a query with no results', async () => { + await softThrottle(); + const client = e2eClient(); + let count = 0; + for await (const _ of client.search.basicIter('zzz-no-such-thing-xyz-abc', { + pageSize: 10, + maxPages: 3, + })) { + count++; + } + expect(count).toBe(0); + }); + + runOrSkip('paginated endpoint last page has has_next: false', async () => { + await softThrottle(); + const client = e2eClient(); + // List vocabularies — small enough catalog to reach the last page + const { data, meta, error } = await client.vocabularies.list({ pageSize: 200 }); + expect(error).toBeNull(); + const pagination = meta?.pagination; + expect(pagination).toBeTruthy(); + if (pagination) { + if (pagination.total_items <= pagination.page_size) { + expect(pagination.has_next).toBe(false); + expect(pagination.has_previous).toBe(false); + } + expect(pagination.total_pages).toBe( + Math.max(1, Math.ceil(pagination.total_items / pagination.page_size)), + ); + } + expect(data?.vocabularies.length).toBeLessThanOrEqual(pagination?.page_size ?? 200); + }); + + runOrSkip('pageSize=1 returns exactly one row when results exist', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED'], + pageSize: 1, + }); + expect(error).toBeNull(); + expect(data?.concepts.length).toBeLessThanOrEqual(1); + }); + + runOrSkip('basicIter throws OMOPHubIteratorError when a page fails mid-stream', async () => { + // We can't reliably trigger a mid-stream failure on the live API, so + // we verify the error-class wiring via a deliberately bad client (no + // retries + invalid key) and confirm the iterator throws on FIRST page + await softThrottle(); + const { OMOPHub } = await import('../src/index.js'); + const badClient = new OMOPHub('oh_definitely_bad_key_xxx', { maxRetries: 0 }); + let caught: unknown; + try { + for await (const _ of badClient.search.basicIter('diabetes', { + pageSize: 5, + maxPages: 1, + })) { + // unreachable + } + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(OMOPHubIteratorError); + }); + + runOrSkip('basicAll surfaces partial result + errors when a page fails', async () => { + await softThrottle(); + const { OMOPHub } = await import('../src/index.js'); + const badClient = new OMOPHub('oh_definitely_bad_key_xxx', { maxRetries: 0 }); + const result = await badClient.search.basicAll('diabetes', { + pageSize: 5, + maxPages: 2, + }); + expect(result.errors.length).toBeGreaterThanOrEqual(1); + // pagesFetched counts the attempt that failed + expect(result.pagesFetched).toBeGreaterThanOrEqual(1); + }); + + runOrSkip('navigating to page 2 explicitly works when has_next is true', async () => { + await softThrottle(); + const client = e2eClient(); + const first = await client.vocabularies.list({ pageSize: 5, page: 1 }); + if (first.meta?.pagination?.has_next) { + await softThrottle(); + const second = await client.vocabularies.list({ pageSize: 5, page: 2 }); + expect(second.error).toBeNull(); + expect(second.meta?.pagination?.page).toBe(2); + expect(second.meta?.pagination?.has_previous).toBe(true); + } + }); +}); diff --git a/e2e/search.test.ts b/e2e/search.test.ts new file mode 100644 index 0000000..0810d8d --- /dev/null +++ b/e2e/search.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, test } from 'vitest'; +import { + E2E_CONCEPT_IDS, + e2eClient, + e2eClientNoRetry, + e2eEnabled, + softThrottle, +} from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: client.search.basic', () => { + runOrSkip('returns a normalised SearchResult with concepts array', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED'], + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.concepts)).toBe(true); + expect(data?.concepts.length).toBeGreaterThan(0); + expect(data?.concepts[0]?.concept_name?.toLowerCase()).toContain('diab'); + }); + + runOrSkip('standardConcept: "S" returns only standard rows', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED'], + standardConcept: 'S', + pageSize: 10, + }); + expect(error).toBeNull(); + for (const c of data?.concepts ?? []) { + if (c.standard_concept !== undefined && c.standard_concept !== null) { + expect(c.standard_concept).toBe('S'); + } + } + }); + + runOrSkip('exactMatch flag flows through to the wire', async () => { + await softThrottle(); + // Use a no-retry client — exactMatch=true triggers a slower server path + // and full retries would push us past the test timeout. Either we get + // an empty/non-empty success or a structured timeout — both are fine. + const client = e2eClientNoRetry(); + const { data, error } = await client.search.basic('Type 2 diabetes mellitus', { + exactMatch: true, + pageSize: 3, + }); + if (error) { + expect(['timeout_error', 'connection_error', 'validation_error']).toContain(error.name); + } else { + expect(Array.isArray(data?.concepts)).toBe(true); + } + }); + + runOrSkip('basicIter walks at least one page', async () => { + await softThrottle(); + const client = e2eClient(); + const collected: number[] = []; + for await (const c of client.search.basicIter('aspirin', { + vocabularyIds: ['RxNorm'], + pageSize: 5, + maxPages: 1, + })) { + collected.push(c.concept_id); + } + expect(collected.length).toBeGreaterThan(0); + }); + + runOrSkip('basicAll aggregates with maxPages cap', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, errors, pagesFetched } = await client.search.basicAll('diabetes', { + vocabularyIds: ['SNOMED'], + pageSize: 10, + maxPages: 2, + }); + expect(errors).toEqual([]); + expect(pagesFetched).toBeGreaterThanOrEqual(1); + expect(pagesFetched).toBeLessThanOrEqual(2); + expect(data.length).toBeGreaterThan(0); + }); +}); + +describe('e2e: client.search.advanced', () => { + runOrSkip('hits POST /search/advanced and returns normalised concepts', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.advanced('diabetes type 2', { + vocabularyIds: ['SNOMED'], + domainIds: ['Condition'], + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.concepts)).toBe(true); + }); + + runOrSkip('relationshipFilters use camelCase keys (server sees snake_case)', async () => { + await softThrottle(); + const client = e2eClient(); + const { error } = await client.search.advanced('diabetes', { + relationshipFilters: [{ relationshipId: 'Is a', targetConceptId: E2E_CONCEPT_IDS.diabetes }], + pageSize: 3, + }); + if (error) { + // A 400 here would indicate the camelCase → snake_case translation is broken + expect(error.statusCode).not.toBe(400); + } + }); +}); + +describe('e2e: client.search.autocomplete', () => { + runOrSkip('returns suggestions wrapped under .suggestions', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.autocomplete('diab', { pageSize: 5 }); + expect(error).toBeNull(); + expect(data?.query).toBe('diab'); + expect(Array.isArray(data?.suggestions)).toBe(true); + expect(data?.suggestions.length).toBeGreaterThan(0); + const first = data?.suggestions[0]; + expect(typeof first?.suggestion.concept_id).toBe('number'); + expect(typeof first?.suggestion.concept_name).toBe('string'); + }); + + runOrSkip('echoes the query field with vocabulary filter applied', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.autocomplete('aspirin', { + vocabularyIds: ['RxNorm'], + pageSize: 3, + }); + expect(error).toBeNull(); + expect(data?.query).toBe('aspirin'); + }); +}); + +describe('e2e: client.search.semantic', () => { + runOrSkip('returns normalised results array regardless of server shape', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.semantic('high blood sugar', { + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.results)).toBe(true); + }); + + runOrSkip('similarity scores fall within [0, 1]', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.semantic('blood pressure', { + threshold: 0.5, + pageSize: 5, + }); + expect(error).toBeNull(); + for (const r of data?.results ?? []) { + expect(r.similarity_score).toBeGreaterThanOrEqual(0); + expect(r.similarity_score).toBeLessThanOrEqual(1); + } + }); + + runOrSkip('semanticIter terminates cleanly and yields well-shaped items', async () => { + await softThrottle(); + const client = e2eClient(); + // The test asserts (1) iteration doesn't throw and (2) each yielded + // item carries a numeric concept_id. Server may return zero results + // for some queries, so we don't assert a count. + for await (const r of client.search.semanticIter('chest pain', { + pageSize: 5, + maxPages: 1, + })) { + expect(typeof r.concept_id).toBe('number'); + } + }); +}); + +describe('e2e: client.search.bulkBasic', () => { + runOrSkip('returns a bare array, one entry per search_id', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.bulkBasic( + [ + { search_id: 'q1', query: 'diabetes' }, + { search_id: 'q2', query: 'hypertension' }, + { search_id: 'q3', query: 'aspirin' }, + ], + { defaults: { vocabulary_ids: ['SNOMED', 'RxNorm'], page_size: 3 } }, + ); + expect(error).toBeNull(); + expect(Array.isArray(data)).toBe(true); + expect(data?.length).toBe(3); + const ids = (data ?? []).map((r) => r.search_id); + expect(new Set(ids)).toEqual(new Set(['q1', 'q2', 'q3'])); + for (const item of data ?? []) { + expect(['completed', 'failed']).toContain(item.status); + expect(Array.isArray(item.results)).toBe(true); + } + }); +}); + +describe('e2e: client.search.bulkSemantic', () => { + runOrSkip('returns a wrapper with results + aggregate counts', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.bulkSemantic( + [ + { search_id: 's1', query: 'heart attack' }, + { search_id: 's2', query: 'high blood pressure' }, + ], + { defaults: { threshold: 0.4, page_size: 3 } }, + ); + expect(error).toBeNull(); + expect(typeof data).toBe('object'); + expect(Array.isArray(data?.results)).toBe(true); + expect(data?.total_searches).toBe(2); + expect(typeof data?.completed_count).toBe('number'); + expect(typeof data?.failed_count).toBe('number'); + }); +}); + +describe('e2e: client.search.similar (XOR variants)', () => { + runOrSkip('similar by conceptId returns ranked similar concepts', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.similar({ + conceptId: E2E_CONCEPT_IDS.diabetes, + algorithm: 'hybrid', + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.similar_concepts)).toBe(true); + expect(typeof data?.search_metadata?.algorithm_used).toBe('string'); + }); + + runOrSkip('similar by conceptName works for known names', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.similar({ + conceptName: 'Type 2 diabetes mellitus', + algorithm: 'semantic', + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.similar_concepts)).toBe(true); + }); + + runOrSkip('similar by free-text query (no collision with PerCallOptions.query)', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.similar({ + query: 'elevated blood sugar', + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.similar_concepts)).toBe(true); + }); + + runOrSkip('algorithm: "lexical" returns text-based matches', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.similar({ + conceptName: 'diabetes', + algorithm: 'lexical', + similarityThreshold: 0.3, + pageSize: 5, + }); + expect(error).toBeNull(); + if (data?.search_metadata?.algorithm_used) { + expect(['lexical', 'hybrid', 'semantic']).toContain(data.search_metadata.algorithm_used); + } + }); +}); diff --git a/e2e/server-errors.test.ts b/e2e/server-errors.test.ts new file mode 100644 index 0000000..0b3e14b --- /dev/null +++ b/e2e/server-errors.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from 'vitest'; +import { e2eClientNoRetry, e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +/** + * Helper: the server-errors suite uses the no-retry client so each test + * fails fast on a known-bad call. That also means a transient 429 from + * the shared rate limiter shows up directly as `rate_limit_exceeded` + * instead of being retried away. We treat that as an inconclusive run + * for the specific error-contract being tested (not a failure of the + * SDK's contract under test). + */ +function isTransient(name?: string): boolean { + return name === 'rate_limit_exceeded' || name === 'timeout_error' || name === 'connection_error'; +} + +describe('e2e: server-side errors', () => { + runOrSkip('concepts.get with a non-existent ID returns not_found', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.concepts.get(999_999_999); + if (isTransient(error?.name)) return; + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + expect(error?.statusCode).toBe(404); + }); + + runOrSkip('concepts.getByCode with an unknown code returns not_found', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.concepts.getByCode('SNOMED', 'DEFINITELY-NOT-A-CODE'); + if (isTransient(error?.name)) return; + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + }); + + runOrSkip('vocabularies.get with an unknown vocabulary returns an error', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.vocabularies.get('THIS_VOCAB_DOES_NOT_EXIST'); + if (isTransient(error?.name)) return; + expect(data).toBeNull(); + expect(error).not.toBeNull(); + expect([400, 404]).toContain(error?.statusCode); + }); + + runOrSkip('domains.concepts with an unknown domain returns an empty concepts array', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.domains.concepts('NotARealDomain'); + if (isTransient(error?.name)) return; + // Server returns 200 OK with `{ concepts: [] }` for unknown domains + // rather than a 404. Treat empty as the "no matches" signal. + expect(error).toBeNull(); + expect(Array.isArray(data?.concepts)).toBe(true); + expect(data?.concepts.length).toBe(0); + }); + + runOrSkip('hierarchy.ancestors for non-existent concept returns not_found', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { error } = await client.hierarchy.ancestors(999_999_999); + if (isTransient(error?.name)) return; + expect(error).not.toBeNull(); + expect([400, 404]).toContain(error?.statusCode); + }); + + runOrSkip('mappings.get for non-existent concept returns not_found or empty', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.mappings.get(999_999_999); + if (isTransient(error?.name)) return; + // Either: 404, OR a 200 with empty mappings array — both are acceptable + if (error) { + expect([400, 404]).toContain(error.statusCode); + } else { + expect(Array.isArray(data?.mappings)).toBe(true); + } + }); + + runOrSkip('fhir.resolve with unknown system URI surfaces a structured error', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.fhir.resolve({ + system: 'http://example.invalid/code-system', + code: '12345', + }); + if (isTransient(error?.name)) return; + expect(data).toBeNull(); + expect(error).not.toBeNull(); + expect(typeof error?.name).toBe('string'); + expect(typeof error?.message).toBe('string'); + }); + + runOrSkip('error responses preserve x-request-id for support triage', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { error, headers } = await client.concepts.get(999_999_999); + if (isTransient(error?.name)) return; + expect(error).not.toBeNull(); + // requestId must surface on BOTH the error object AND the raw + // headers, and the two must match — that's the support-triage contract. + expect(typeof error?.requestId).toBe('string'); + expect((error?.requestId ?? '').length).toBeGreaterThan(0); + const headerRequestId = headers?.['x-request-id']; + expect(typeof headerRequestId).toBe('string'); + expect((headerRequestId ?? '').length).toBeGreaterThan(0); + expect(error?.requestId).toBe(headerRequestId); + }); + + runOrSkip('200-OK error responses (empty matches) return success, not error', async () => { + await softThrottle(); + const client = e2eClientNoRetry(); + const { data, error } = await client.search.basic('zzz-no-such-concept-aaa-xyz', { + pageSize: 5, + }); + if (isTransient(error?.name)) return; + expect(error).toBeNull(); + expect(Array.isArray(data?.concepts)).toBe(true); + // Empty matches is a successful query with zero results + expect(data?.concepts.length).toBe(0); + }); +}); diff --git a/e2e/url-encoding.test.ts b/e2e/url-encoding.test.ts new file mode 100644 index 0000000..9eae71d --- /dev/null +++ b/e2e/url-encoding.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'vitest'; +import { e2eClient, e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: URL safety + encoding', () => { + runOrSkip('getByCode with a code containing a dot round-trips correctly', async () => { + await softThrottle(); + const client = e2eClient(); + // ICD10CM codes contain dots — verify the path segment encodes them + // without truncation or fragment-stripping. + const { data, error } = await client.concepts.getByCode('ICD10CM', 'E11.9'); + expect(error).toBeNull(); + expect(data?.concept_code).toBe('E11.9'); + expect(data?.vocabulary_id).toBe('ICD10CM'); + }); + + runOrSkip('queries with spaces are correctly encoded', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.basic('type 2 diabetes mellitus', { + pageSize: 3, + vocabularyIds: ['SNOMED'], + }); + expect(error).toBeNull(); + expect(data?.concepts.length ?? 0).toBeGreaterThan(0); + }); + + runOrSkip( + 'queries with commas inside vocabularyIds are joined with comma not duplicated', + async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED', 'ICD10CM'], + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data?.concepts)).toBe(true); + // Results from multiple vocabularies indicate the array was joined, not + // dropped, and not URL-mangled + if (data && data.concepts.length > 0) { + const vocabs = new Set(data.concepts.map((c) => c.vocabulary_id)); + // We requested two vocabs; allow either to be present + const intersection = ['SNOMED', 'ICD10CM'].filter((v) => vocabs.has(v)); + expect(intersection.length).toBeGreaterThan(0); + } + }, + ); + + runOrSkip('queries with ampersand do not bleed into URL params', async () => { + await softThrottle(); + const client = e2eClient(); + // If `&` isn't encoded, the server would treat the rest as a separate param + const { error } = await client.search.basic('headache & nausea', { pageSize: 1 }); + expect(error).toBeNull(); + }); + + runOrSkip('Unicode queries (Cyrillic) round-trip without corruption', async () => { + await softThrottle(); + const client = e2eClient(); + // The query may have zero matches — the important thing is no encoding error + const { error } = await client.search.basic('диабет', { pageSize: 1 }); + expect(error).toBeNull(); + }); + + runOrSkip('Unicode queries (Japanese) round-trip without corruption', async () => { + await softThrottle(); + const client = e2eClient(); + const { error } = await client.search.basic('糖尿病', { pageSize: 1 }); + expect(error).toBeNull(); + }); + + runOrSkip('queries with + character are encoded (not treated as space)', async () => { + await softThrottle(); + const client = e2eClient(); + // `vitamin B+12` shouldn't become `vitamin B 12` + const { error } = await client.search.basic('vitamin B+12', { pageSize: 1 }); + expect(error).toBeNull(); + }); + + runOrSkip('queries with question mark do not bleed into URL params', async () => { + await softThrottle(); + const client = e2eClient(); + const { error } = await client.search.basic('what is diabetes?', { pageSize: 1 }); + expect(error).toBeNull(); + }); + + runOrSkip('hash characters do not get stripped as fragment', async () => { + await softThrottle(); + const client = e2eClient(); + const { error } = await client.search.basic('insulin #1', { pageSize: 1 }); + expect(error).toBeNull(); + }); + + runOrSkip('autocomplete query echo matches input including special chars', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.search.autocomplete('diab', { pageSize: 3 }); + expect(error).toBeNull(); + expect(data?.query).toBe('diab'); + }); +}); diff --git a/e2e/vocabularies.test.ts b/e2e/vocabularies.test.ts new file mode 100644 index 0000000..a1ae8a4 --- /dev/null +++ b/e2e/vocabularies.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'vitest'; +import { E2E_CONCEPT_IDS, e2eClient, e2eEnabled, softThrottle } from './_helpers.js'; + +const runOrSkip = e2eEnabled ? test : test.skip; + +describe('e2e: client.vocabularies', () => { + runOrSkip('list() returns at least SNOMED', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error, headers, meta } = await client.vocabularies.list({ pageSize: 200 }); + // First test in the suite — server cold cache can spike latency. + if (error?.name === 'timeout_error' || error?.name === 'rate_limit_exceeded') return; + expect(error).toBeNull(); + expect(headers).toBeTruthy(); + expect(Array.isArray(data?.vocabularies)).toBe(true); + const ids = (data?.vocabularies ?? []).map((v) => v.vocabulary_id); + expect(ids).toContain('SNOMED'); + expect(typeof meta?.pagination?.total_items).toBe('number'); + }); + + runOrSkip('list() exposes total_items, total_pages, has_next correctly', async () => { + await softThrottle(); + const client = e2eClient(); + const { meta, error } = await client.vocabularies.list({ pageSize: 10 }); + expect(error).toBeNull(); + const p = meta?.pagination; + expect(p).toBeTruthy(); + if (p) { + expect(p.page).toBe(1); + expect(p.page_size).toBe(10); + expect(p.total_items).toBeGreaterThan(0); + expect(p.total_pages).toBe(Math.max(1, Math.ceil(p.total_items / p.page_size))); + if (p.total_items > p.page_size) expect(p.has_next).toBe(true); + expect(p.has_previous).toBe(false); + } + }); + + runOrSkip('list() accepts includeStats: true and rows carry audit timestamps', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.list({ + pageSize: 5, + includeStats: true, + }); + // The live API currently ignores `includeStats` on the list endpoint + // (stats are populated via `vocabularies.stats(id)` instead). The test + // verifies (a) the flag is accepted without server-side rejection and + // (b) returned rows carry `created_at` / `updated_at` audit timestamps. + expect(error).toBeNull(); + expect(Array.isArray(data?.vocabularies)).toBe(true); + const someHaveTimestamps = (data?.vocabularies ?? []).some( + (v) => typeof v.created_at === 'string' && typeof v.updated_at === 'string', + ); + expect(someHaveTimestamps).toBe(true); + }); + + runOrSkip('get("SNOMED") returns vocabulary metadata', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.get('SNOMED'); + expect(error).toBeNull(); + expect(data?.vocabulary_id).toBe('SNOMED'); + expect(typeof data?.vocabulary_name).toBe('string'); + }); + + runOrSkip('get("RxNorm") works for drug vocabulary', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.get('RxNorm'); + expect(error).toBeNull(); + expect(data?.vocabulary_id).toBe('RxNorm'); + }); + + runOrSkip('stats("SNOMED") returns concept counts', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.stats('SNOMED'); + expect(error).toBeNull(); + expect(data?.vocabulary_id).toBe('SNOMED'); + expect(typeof data?.total_concepts).toBe('number'); + expect(data?.total_concepts ?? 0).toBeGreaterThan(0); + }); + + runOrSkip('domainStats("SNOMED", "Condition") returns a structured payload', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.domainStats('SNOMED', 'Condition'); + expect(error).toBeNull(); + expect(data).toBeTruthy(); + }); + + runOrSkip('domains() returns the vocab-scoped domain catalog', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.domains(); + expect(error).toBeNull(); + expect(Array.isArray(data?.domains)).toBe(true); + const ids = (data?.domains ?? []).map((d) => d.domain_id); + expect(ids).toContain('Condition'); + }); + + runOrSkip('conceptClasses() returns a bare array of concept-class rows', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.conceptClasses(); + expect(error).toBeNull(); + expect(Array.isArray(data)).toBe(true); + expect(data?.length ?? 0).toBeGreaterThan(0); + expect(typeof data?.[0]?.concept_class_id).toBe('string'); + }); + + runOrSkip('concepts(SNOMED) returns a bare array of concept rows', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error, meta } = await client.vocabularies.concepts('SNOMED', { + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data)).toBe(true); + expect(data?.length ?? 0).toBeGreaterThan(0); + expect(typeof meta?.pagination?.total_items).toBe('number'); + }); + + runOrSkip('concepts(SNOMED, search="diabetes") narrows results', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.vocabularies.concepts('SNOMED', { + search: 'diabetes', + pageSize: 5, + }); + expect(error).toBeNull(); + expect(Array.isArray(data)).toBe(true); + expect(data?.length ?? 0).toBeGreaterThan(0); + }); +}); + +describe('e2e: cross-SDK parity', () => { + runOrSkip('known concept IDs resolve to expected names', async () => { + await softThrottle(); + const client = e2eClient(); + const { data, error } = await client.concepts.get(E2E_CONCEPT_IDS.diabetes); + expect(error).toBeNull(); + expect(data?.concept_id).toBe(E2E_CONCEPT_IDS.diabetes); + expect(data?.concept_name?.toLowerCase()).toContain('diabetes'); + }); +}); diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..69a680d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,76 @@ +# OMOPHub Node SDK — Examples + +Runnable TypeScript examples covering the full SDK surface. Each file is self-contained: import the SDK, call a few methods, print results. + +## Setup + +```bash +# 1. From the repo root, install dev deps if you haven't +npm install + +# 2. Either set the env var inline or copy the loader from vitest.e2e.config.ts +export OMOPHUB_API_KEY=oh_your_api_key +``` + +## Running an example + +Examples run directly via `tsx` (already a dev-dep — no build step): + +```bash +npx tsx examples/basic-usage.ts +npx tsx examples/error-handling.ts +npx tsx examples/search-concepts.ts +npx tsx examples/navigate-hierarchy.ts +npx tsx examples/map-between-vocabularies.ts +npx tsx examples/fhir-resolver.ts +``` + +## Files + +| File | What it covers | +|---|---| +| [`basic-usage.ts`](./basic-usage.ts) | `concepts.get`, `search.basic`, `vocabularies.list` — quickstart | +| [`error-handling.ts`](./error-handling.ts) | Discriminated `{ data, error }` pattern, every error code, iterator vs. eager error modes, `OMOPHubError` on misuse | +| [`search-concepts.ts`](./search-concepts.ts) | basic / filtered / autocomplete / semantic / bulk / similar + async iterator pagination | +| [`navigate-hierarchy.ts`](./navigate-hierarchy.ts) | `hierarchy.ancestors`, `hierarchy.descendants`, `hierarchy.get` (graph format), `relationships.get` | +| [`map-between-vocabularies.ts`](./map-between-vocabularies.ts) | `mappings.get`, `mappings.map` (concepts + native codes), code-lookup-then-map | +| [`fhir-resolver.ts`](./fhir-resolver.ts) | SNOMED/LOINC/RxNorm/ICD-10-CM resolution, recommendations, quality, batch, CodeableConcept, coding-object form, `omophubFhirUrl` | + +## SDK conventions used throughout + +**Returns over throws.** Every method returns `{ data, error, meta, headers }` — narrow with `if (error) ...` and TypeScript types `data` correctly: + +```ts +const { data, error } = await client.concepts.get(201826); +if (error) { + console.error(error.name, error.message); + return; +} +console.log(data.concept_name); // narrowed to Concept +``` + +**camelCase in, snake_case on the wire.** The SDK accepts camelCase option keys and converts to snake_case at the HTTP boundary. Response field names stay snake_case (matches the wire). + +**Async iterators throw.** `*Iter` variants throw `OMOPHubIteratorError` on page failure (generators can't gracefully yield discriminated errors). Use `*All` if you prefer error accumulation: + +```ts +// Throws on first failure: +for await (const c of client.search.basicIter('diabetes')) { ... } + +// Accumulates errors: +const { data, errors, pagesFetched } = await client.search.basicAll('diabetes', { maxPages: 5 }); +``` + +**The only thing that throws.** `OMOPHubError` from the `OMOPHub` constructor when no API key is supplied. Network / API / validation errors are all return values. + +## Testing examples + +Examples run against the live API at `https://api.omophub.com/v1`. To verify they execute end-to-end on your key: + +```bash +export OMOPHUB_API_KEY=oh_... +for f in examples/*.ts; do + echo "--- $f ---" + npx tsx "$f" || echo "FAILED: $f" +done +``` diff --git a/examples/basic-usage.ts b/examples/basic-usage.ts new file mode 100644 index 0000000..3dc1db4 --- /dev/null +++ b/examples/basic-usage.ts @@ -0,0 +1,49 @@ +/** + * Basic usage example for the OMOPHub Node SDK. + * + * Run with: + * OMOPHUB_API_KEY=oh_... npx tsx examples/basic-usage.ts + */ +import { OMOPHub } from '../src/index.js'; + +async function main(): Promise { + // Reads OMOPHUB_API_KEY from the environment. To pass it explicitly: + // const client = new OMOPHub('oh_your_api_key'); + const client = new OMOPHub(); + + // Get a concept by ID + const { data: concept, error } = await client.concepts.get(201826); + if (error) throw new Error(error.message); + console.log(`Concept: ${concept.concept_name}`); + console.log(` Vocabulary: ${concept.vocabulary_id}`); + console.log(` Code: ${concept.concept_code}`); + console.log(` Domain: ${concept.domain_id}`); + console.log(); + + // Search for concepts — `data.concepts` is always a `Concept[]` after the + // SDK normalises the wire shape. + const results = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED'], + pageSize: 5, + }); + if (results.error) throw new Error(results.error.message); + console.log("Search results for 'diabetes':"); + for (const c of results.data.concepts) { + console.log(` ${c.concept_id}: ${c.concept_name}`); + } + console.log(); + + // List vocabularies — `data.vocabularies` is the array; pagination meta + // sits on the outer `result.meta.pagination`. + const vocabs = await client.vocabularies.list({ pageSize: 5 }); + if (vocabs.error) throw new Error(vocabs.error.message); + console.log('Available vocabularies:'); + for (const v of vocabs.data.vocabularies) { + console.log(` ${v.vocabulary_id}: ${v.vocabulary_name}`); + } +} + +main().catch((err: unknown) => { + console.error('Failed:', err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/examples/error-handling.ts b/examples/error-handling.ts new file mode 100644 index 0000000..9ac905e --- /dev/null +++ b/examples/error-handling.ts @@ -0,0 +1,179 @@ +/** + * Error handling examples for the OMOPHub Node SDK. + * + * Unlike SDKs that throw on API errors, every method here returns a + * discriminated `{ data, error, meta, headers }` union. Narrow with + * `if (error) ...` and TypeScript will type `data` correctly in the + * else branch. + * + * Run with: + * OMOPHUB_API_KEY=oh_... npx tsx examples/error-handling.ts + */ +import { OMOPHub, OMOPHubError, OMOPHubIteratorError } from '../src/index.js'; + +async function handleNotFound(): Promise { + console.log('=== Handling Not Found ==='); + const client = new OMOPHub(); + const { data, error } = await client.concepts.get(999_999_999); + if (error) { + console.log(`Concept not found: ${error.message}`); + console.log(` Code: ${error.name}`); + console.log(` Status: ${error.statusCode}`); + if (error.requestId) console.log(` Request ID: ${error.requestId}`); + return; + } + console.log(`Found: ${data.concept_name}`); +} + +async function handleAuthentication(): Promise { + console.log('\n=== Handling Authentication Errors ==='); + // Bypass env by passing an explicit (bad) key + const client = new OMOPHub('oh_invalid_key', { maxRetries: 0 }); + const { error } = await client.concepts.get(201826); + if (error) { + console.log(`Authentication failed: ${error.message}`); + console.log(` Code: ${error.name}`); // invalid_api_key | restricted_api_key + console.log(` Status: ${error.statusCode}`); + } +} + +async function handleRateLimit(): Promise { + console.log('\n=== Handling Rate Limits ==='); + // The SDK retries 429 automatically up to `maxRetries` with backoff. + // For demo, disable retries to surface the error so we can inspect it. + const client = new OMOPHub(undefined, { maxRetries: 0 }); + for (let i = 0; i < 3; i++) { + const { error } = await client.search.basic('diabetes', { pageSize: 1 }); + if (error?.name === 'rate_limit_exceeded') { + console.log(`Rate limited! Retry after ${error.retryAfter ?? '?'}s`); + await new Promise((r) => setTimeout(r, (error.retryAfter ?? 1) * 1000)); + continue; + } + if (error) { + console.log(` Request ${i + 1}: ${error.name}: ${error.message}`); + return; + } + console.log(` Request ${i + 1} succeeded`); + } +} + +async function handleValidation(): Promise { + console.log('\n=== Handling Validation Errors ==='); + const client = new OMOPHub(); + // Trigger synthetic client-side validation — no network call. + const { error } = await client.concepts.batch({ conceptIds: [] }); + if (error) { + console.log(`Validation error: ${error.message}`); + console.log(` Code: ${error.name}`); // validation_error + console.log(` Status: ${error.statusCode}`); // null — synthetic + } +} + +/** + * The full set of error codes you can switch on. See + * `OMOPHUB_ERROR_CODE_KEY` in the SDK for the canonical list. + */ +async function comprehensiveErrorHandling(): Promise { + console.log('\n=== Comprehensive Error Handling ==='); + const client = new OMOPHub(); + const { data, error } = await client.concepts.get(201826); + + if (error) { + switch (error.name) { + case 'invalid_api_key': + case 'missing_api_key': + case 'restricted_api_key': + console.log(`Auth error: ${error.message}`); + break; + case 'not_found': + console.log(`Not found: ${error.message}`); + break; + case 'rate_limit_exceeded': + case 'tier_limit_exceeded': + console.log(`Rate / tier limited, retry after: ${error.retryAfter ?? '?'}s`); + break; + case 'validation_error': + case 'missing_required_field': + case 'invalid_argument': + console.log(`Invalid request: ${error.message}`); + break; + case 'service_unavailable': + case 'internal_server_error': + console.log(`Server error: ${error.message}`); + break; + case 'connection_error': + console.log(`Network error: ${error.message}`); + break; + case 'timeout_error': + console.log(`Request timed out: ${error.message}`); + break; + default: + console.log(`SDK error: ${error.name}: ${error.message}`); + } + return; + } + + console.log(`Success: ${data.concept_name}`); +} + +/** + * Async iterators throw `OMOPHubIteratorError` on page failure (they can't + * gracefully yield discriminated errors). Catch and inspect like any + * other Error subclass. + */ +async function handleIteratorErrors(): Promise { + console.log('\n=== Iterator Error Handling ==='); + const client = new OMOPHub('oh_definitely_bad_key', { maxRetries: 0 }); + try { + for await (const c of client.search.basicIter('diabetes', { pageSize: 5 })) { + console.log(` ${c.concept_name}`); // will not reach + } + } catch (e) { + if (e instanceof OMOPHubIteratorError) { + console.log(`Iterator failed: ${e.code} (${e.statusCode}): ${e.message}`); + } else { + throw e; + } + } +} + +/** + * The eager `*All` variant accumulates errors as values rather than throwing. + */ +async function handleEagerErrors(): Promise { + console.log('\n=== Eager Collect with Error Accumulation ==='); + const client = new OMOPHub('oh_definitely_bad_key', { maxRetries: 0 }); + const { data, errors, pagesFetched } = await client.search.basicAll('diabetes', { + pageSize: 5, + maxPages: 2, + }); + console.log(` Collected ${data.length} items across ${pagesFetched} page(s)`); + console.log(` Errors: ${errors.length}`); + for (const e of errors) console.log(` ${e.name}: ${e.message}`); +} + +async function main(): Promise { + await handleNotFound(); + await handleAuthentication(); + await handleValidation(); + await comprehensiveErrorHandling(); + await handleIteratorErrors(); + await handleEagerErrors(); + await handleRateLimit(); + + // Demonstrate that constructor misuse THROWS OMOPHubError (the only + // exception in the SDK — everything else is a return value). + try { + delete process.env.OMOPHUB_API_KEY; + new OMOPHub(); + } catch (e) { + if (e instanceof OMOPHubError) { + console.log(`\nConstructor threw OMOPHubError: ${e.message}`); + } + } +} + +main().catch((err: unknown) => { + console.error('Failed:', err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/examples/fhir-resolver.ts b/examples/fhir-resolver.ts new file mode 100644 index 0000000..18b6713 --- /dev/null +++ b/examples/fhir-resolver.ts @@ -0,0 +1,288 @@ +/** + * FHIR Resolver examples for the OMOPHub Node SDK. + * + * Translates FHIR coded values (system URI + code) into OMOP standard + * concepts, CDM target tables, and optional Phoebe recommendations — all + * in a single API call. + * + * Covers: + * 1. Direct SNOMED resolution + * 2. ICD-10-CM → SNOMED via "Maps to" + * 3. LOINC → measurement table + * 4. RxNorm → drug_exposure table + * 5. Text-only semantic search fallback + * 6. vocabularyId bypass (skip URI resolution) + * 7. Phoebe recommendations + * 8. Mapping quality signal + * 9. Batch resolution + * 10. CodeableConcept resolution + * 11. Coding-object input form (vs. flat fields) + * 12. FHIR Terminology Service URL helper + * + * Run with: + * OMOPHUB_API_KEY=oh_... npx tsx examples/fhir-resolver.ts + */ +import { OMOPHub, omophubFhirUrl } from '../src/index.js'; + +// ─── 1. Direct SNOMED resolution ───────────────────────────────────── + +async function resolveSnomed(client: OMOPHub): Promise { + console.log('=== 1. SNOMED Direct Resolution ==='); + const { data, error } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + resourceType: 'Condition', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + const r = data.resolution; + console.log(` Source: ${r.source_concept.concept_name}`); + console.log(` Standard: ${r.standard_concept.concept_name}`); + console.log(` Mapping type: ${r.mapping_type}`); // "direct" + console.log(` Target table: ${r.target_table}`); // "condition_occurrence" +} + +// ─── 2. ICD-10-CM → SNOMED via "Maps to" ───────────────────────────── + +async function resolveIcd10Mapped(client: OMOPHub): Promise { + console.log('\n=== 2. ICD-10-CM → SNOMED Mapping ==='); + const { data, error } = await client.fhir.resolve({ + system: 'http://hl7.org/fhir/sid/icd-10-cm', + code: 'E11.9', + resourceType: 'Condition', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + const r = data.resolution; + console.log(` Source: [${r.source_concept.vocabulary_id}] ${r.source_concept.concept_name}`); + console.log( + ` Standard: [${r.standard_concept.vocabulary_id}] ${r.standard_concept.concept_name}`, + ); + console.log(` Mapping type: ${r.mapping_type}`); // "mapped" or similar + console.log(` Target table: ${r.target_table}`); +} + +// ─── 3. LOINC → measurement table ──────────────────────────────────── + +async function resolveLoinc(client: OMOPHub): Promise { + console.log('\n=== 3. LOINC → Measurement ==='); + const { data, error } = await client.fhir.resolve({ + system: 'http://loinc.org', + code: '2339-0', // Glucose [Mass/volume] in Blood + resourceType: 'Observation', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + console.log(` Concept: ${data.resolution.standard_concept.concept_name}`); + console.log(` Target table: ${data.resolution.target_table}`); // "measurement" +} + +// ─── 4. RxNorm → drug_exposure ────────────────────────────────────── + +async function resolveRxNorm(client: OMOPHub): Promise { + console.log('\n=== 4. RxNorm → Drug Exposure ==='); + const { data, error } = await client.fhir.resolve({ + system: 'http://www.nlm.nih.gov/research/umls/rxnorm', + code: '197696', // Metformin 500 MG Oral Tablet + resourceType: 'MedicationStatement', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + console.log(` Concept: ${data.resolution.standard_concept.concept_name}`); + console.log(` Target table: ${data.resolution.target_table}`); // "drug_exposure" +} + +// ─── 5. Text-only semantic fallback ────────────────────────────────── + +async function resolveTextOnly(client: OMOPHub): Promise { + console.log('\n=== 5. Text-Only (Semantic Fallback) ==='); + const { data, error } = await client.fhir.resolve({ + display: 'Blood Sugar', + resourceType: 'Observation', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + const r = data.resolution; + console.log(` Standard: ${r.standard_concept.concept_name}`); + console.log(` Mapping type: ${r.mapping_type}`); // "semantic_match" or similar + if (r.similarity_score !== undefined) { + console.log(` Similarity: ${r.similarity_score.toFixed(2)}`); + } +} + +// ─── 6. vocabularyId bypass (skip URI resolution) ──────────────────── + +async function resolveVocabularyIdBypass(client: OMOPHub): Promise { + console.log('\n=== 6. vocabularyId Bypass ==='); + // If you already know the OMOP vocabulary, skip the system-URI lookup. + const { data, error } = await client.fhir.resolve({ + vocabularyId: 'ICD10CM', + code: 'E11.9', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + console.log(` Resolved: ${data.resolution.standard_concept.concept_name}`); +} + +// ─── 7. Phoebe recommendations ─────────────────────────────────────── + +async function resolveWithRecommendations(client: OMOPHub): Promise { + console.log('\n=== 7. With Phoebe Recommendations ==='); + const { data, error } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + includeRecommendations: true, + recommendationsLimit: 5, + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + const recs = data.resolution.recommendations ?? []; + console.log(` Got ${recs.length} recommendations:`); + for (const r of recs.slice(0, 5)) { + console.log(` ${r.concept_name} (${r.domain_id ?? '?'})`); + } +} + +// ─── 8. Mapping quality signal ─────────────────────────────────────── + +async function resolveWithQuality(client: OMOPHub): Promise { + console.log('\n=== 8. With Quality Signal ==='); + const { data, error } = await client.fhir.resolve({ + system: 'http://snomed.info/sct', + code: '44054006', + includeQuality: true, + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + console.log(` Mapping quality: ${data.resolution.mapping_quality ?? '?'}`); + // Common buckets: "high", "medium", "low", "manual_review" +} + +// ─── 9. Batch resolution (up to 100 codings) ───────────────────────── + +async function resolveBatchExample(client: OMOPHub): Promise { + console.log('\n=== 9. Batch Resolution ==='); + const { data, error } = await client.fhir.resolveBatch( + [ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://loinc.org', code: '2339-0' }, + { system: 'http://www.nlm.nih.gov/research/umls/rxnorm', code: '197696' }, + ], + { includeQuality: true }, + ); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + console.log(` Summary: ${data.summary.resolved}/${data.summary.total} resolved`); + for (const item of data.results) { + const r = item.resolution; + console.log( + ` ${r.source_concept.concept_code} → ${r.standard_concept.concept_name} → ${r.target_table}`, + ); + } +} + +// ─── 10. CodeableConcept resolution (up to 20 codings) ─────────────── + +async function resolveCodeableConceptExample(client: OMOPHub): Promise { + console.log('\n=== 10. CodeableConcept Resolution ==='); + // The resolver picks the best match per OHDSI vocabulary preference + // (SNOMED > RxNorm > LOINC > CVX > ICD10). + const { data, error } = await client.fhir.resolveCodeableConcept( + [ + { system: 'http://snomed.info/sct', code: '44054006' }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, + ], + { resourceType: 'Condition' }, + ); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + if (data.best_match) { + // best_match is wrapped as { input, resolution } — same shape + // `fhir.resolve()` returns for a single coding. + const r = data.best_match.resolution; + console.log( + ` Best match: ${r.standard_concept.concept_name} (${r.source_concept.vocabulary_id})`, + ); + } + console.log(` Alternatives: ${data.alternatives.length}`); + console.log(` Unresolved: ${data.unresolved.length}`); +} + +// ─── 11. Coding-object input form ──────────────────────────────────── + +async function resolveWithCodingObject(client: OMOPHub): Promise { + console.log('\n=== 11. Coding-Object Input ==='); + // Equivalent to the flat form — useful when you have a FHIR `Coding` + // already serialized as an object. + const { data, error } = await client.fhir.resolve({ + coding: { + system: 'http://snomed.info/sct', + code: '44054006', + display: 'Type 2 diabetes mellitus', + userSelected: true, + }, + resourceType: 'Condition', + }); + if (error) { + console.log(` Error: ${error.message}`); + return; + } + console.log(` Resolved: ${data.resolution.standard_concept.concept_name}`); + // Flat fields take precedence when both are supplied — handy for + // patching a single field without rebuilding the coding object. +} + +// ─── 12. FHIR Terminology Service URL helper ───────────────────────── + +function fhirServiceUrls(): void { + console.log('\n=== 12. FHIR Terminology Service URLs ==='); + console.log(` R4 : ${omophubFhirUrl()}`); // default + console.log(` R4B: ${omophubFhirUrl('r4b')}`); + console.log(` R5 : ${omophubFhirUrl('r5')}`); + console.log(` R6 : ${omophubFhirUrl('r6')}`); + // Point your favourite FHIR client at these to hit the raw FHIR endpoints + // (CodeSystem/$lookup, ValueSet/$expand, etc.) outside the OMOP envelope. +} + +async function main(): Promise { + // Construct inside main() so OMOPHubError on missing API key is caught + // by the .catch() handler below instead of crashing at module load. + const client = new OMOPHub(); + await resolveSnomed(client); + await resolveIcd10Mapped(client); + await resolveLoinc(client); + await resolveRxNorm(client); + await resolveTextOnly(client); + await resolveVocabularyIdBypass(client); + await resolveWithRecommendations(client); + await resolveWithQuality(client); + await resolveBatchExample(client); + await resolveCodeableConceptExample(client); + await resolveWithCodingObject(client); + fhirServiceUrls(); +} + +main().catch((err: unknown) => { + console.error('Failed:', err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/examples/map-between-vocabularies.ts b/examples/map-between-vocabularies.ts new file mode 100644 index 0000000..79f89c7 --- /dev/null +++ b/examples/map-between-vocabularies.ts @@ -0,0 +1,123 @@ +/** + * Map concepts between vocabularies using the OMOPHub Node SDK. + * + * Run with: + * OMOPHUB_API_KEY=oh_... npx tsx examples/map-between-vocabularies.ts + */ +import { OMOPHub } from '../src/index.js'; + +async function getMappings(): Promise { + console.log('=== Concept Mappings ==='); + const client = new OMOPHub(); + + // Type 2 diabetes mellitus (SNOMED) + const conceptId = 201826; + const { data, error } = await client.mappings.get(conceptId, { + targetVocabulary: 'ICD10CM', + }); + if (error) { + console.log(`API error: ${error.message}`); + return; + } + + console.log(`Mappings for concept ${conceptId} → ICD10CM:`); + console.log(` Total mappings: ${data.summary?.total_mappings ?? data.mappings.length}`); + + for (const m of data.mappings.slice(0, 10)) { + console.log(`\n [${m.target_vocabulary_id}] ${m.target_concept_code}`); + console.log(` Name: ${m.target_concept_name}`); + console.log(` Type: ${m.mapping_type}`); + } +} + +async function mapConcepts(): Promise { + console.log('\n=== Batch Concept Mapping ==='); + const client = new OMOPHub(); + + // Map SNOMED concepts to ICD-10-CM + const { data, error } = await client.mappings.map({ + targetVocabulary: 'ICD10CM', + sourceConcepts: [201826, 4329847], // Type 2 diabetes, Myocardial infarction + }); + if (error) { + console.log(`API error: ${error.message}`); + return; + } + + console.log(`Mapped ${data.mappings.length} concepts to ICD-10-CM`); + if (data.summary) { + console.log( + ` ${data.summary.mapped_concepts ?? '?'}/${data.summary.total_source_concepts ?? '?'} source concepts mapped`, + ); + } + + for (const m of data.mappings) { + console.log(`\n ${m.source_concept_name}`); + console.log(` → [${m.target_concept_code}] ${m.target_concept_name}`); + } +} + +async function mapByNativeCode(): Promise { + console.log('\n=== Map by Native Vocabulary Codes ==='); + const client = new OMOPHub(); + + // Map ICD-10-CM codes directly (no need to resolve to OMOP IDs first) + const { data, error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceCodes: [ + { vocabulary_id: 'ICD10CM', concept_code: 'E11.9' }, // Type 2 diabetes w/o complications + { vocabulary_id: 'ICD10CM', concept_code: 'I10' }, // Essential hypertension + ], + }); + if (error) { + console.log(`API error: ${error.message}`); + return; + } + + for (const m of data.mappings) { + console.log(` ${m.source_vocabulary_id} ${m.source_concept_code}`); + console.log(` → ${m.target_concept_name} (${m.target_vocabulary_id})`); + } +} + +async function lookupByCode(): Promise { + console.log('\n=== Code Lookup and Mapping ==='); + const client = new OMOPHub(); + + // Look up ICD-10-CM code E11 (Type 2 diabetes mellitus) + const { data: concept, error: lookupErr } = await client.concepts.getByCode('ICD10CM', 'E11'); + if (lookupErr) { + console.log(`Lookup failed: ${lookupErr.message}`); + return; + } + + console.log(`Found: ${concept.concept_name}`); + console.log(` Vocabulary: ${concept.vocabulary_id}`); + console.log(` Standard: ${concept.standard_concept ?? 'N/A'}`); + + // If it's not a standard concept, find its mappings + if (concept.standard_concept !== 'S') { + const { data: mappings, error: mapErr } = await client.mappings.get(concept.concept_id); + if (mapErr) { + console.log(` Mappings failed: ${mapErr.message}`); + return; + } + console.log('\n Mappings to other vocabularies:'); + for (const m of mappings.mappings.slice(0, 5)) { + const vocab = m.target_vocabulary_id ?? '?'; + console.log(` → ${m.target_concept_name} (${vocab})`); + } + } +} + +async function main(): Promise { + await getMappings(); + await mapConcepts(); + await mapByNativeCode(); + await lookupByCode(); +} + +main().catch((err: unknown) => { + console.error('Failed:', err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/examples/navigate-hierarchy.ts b/examples/navigate-hierarchy.ts new file mode 100644 index 0000000..804a159 --- /dev/null +++ b/examples/navigate-hierarchy.ts @@ -0,0 +1,116 @@ +/** + * Navigate concept hierarchies using the OMOPHub Node SDK. + * + * Demonstrates: ancestors, descendants, and relationship grouping. + * + * Run with: + * OMOPHUB_API_KEY=oh_... npx tsx examples/navigate-hierarchy.ts + */ +import { OMOPHub } from '../src/index.js'; + +async function getAncestors(client: OMOPHub): Promise { + console.log('=== Concept Ancestors ==='); + // Type 2 diabetes mellitus (SNOMED) + const conceptId = 201826; + + const { data, error } = await client.hierarchy.ancestors(conceptId, { + maxLevels: 5, + includeDistance: true, + }); + if (error) throw new Error(error.message); + + console.log(`Ancestors of concept ${conceptId}:`); + for (const a of data.ancestors.slice(0, 10)) { + const level = a.min_levels_of_separation ?? a.level ?? '?'; + console.log(` Level ${level}: ${a.concept_name}`); + } +} + +async function getDescendants(client: OMOPHub): Promise { + console.log('\n=== Concept Descendants ==='); + // Diabetes mellitus (SNOMED — broader concept) + const conceptId = 201820; + + const { data, error } = await client.hierarchy.descendants(conceptId, { + maxLevels: 2, + includeInvalid: false, + }); + if (error) throw new Error(error.message); + + console.log(`Descendants of concept ${conceptId}:`); + for (const d of data.descendants.slice(0, 10)) { + const level = d.min_levels_of_separation ?? d.level ?? '?'; + console.log(` Level ${level}: ${d.concept_name}`); + } +} + +async function exploreRelationships(client: OMOPHub): Promise { + console.log('\n=== Concept Relationships ==='); + // Aspirin + const conceptId = 1112807; + + const { data, error, meta } = await client.relationships.get(conceptId, { + pageSize: 100, + }); + if (error) throw new Error(error.message); + + console.log(`Relationships for concept ${conceptId}:`); + console.log(` Total: ${meta?.pagination?.total_items ?? data.relationships.length}`); + + // Group by relationship type + const byType = new Map(); + for (const r of data.relationships) { + const list = byType.get(r.relationship_id) ?? []; + list.push(r); + byType.set(r.relationship_id, list); + } + + let shown = 0; + for (const [relType, rels] of byType) { + if (shown >= 5) break; + console.log(`\n ${relType}:`); + for (const r of rels.slice(0, 3)) { + // The wire embeds the related concept under `concept_2` (the queried + // concept itself is `concept_1`). + console.log(` → ${r.concept_2?.concept_name ?? '?'}`); + } + shown++; + } +} + +/** + * Bonus: get the full hierarchy graph in one call (flat or graph format). + */ +async function getFullHierarchy(client: OMOPHub): Promise { + console.log('\n=== Full Hierarchy (graph format) ==='); + const { data, error } = await client.hierarchy.get(201826, { + format: 'graph', + maxLevels: 2, + vocabularyIds: ['SNOMED'], + }); + if (error) { + // Server may validate maxLevels strictly; tolerate non-network errors. + console.log(` Hierarchy returned ${error.name}: ${error.message}`); + return; + } + console.log(` Nodes: ${data.nodes?.length ?? 0}, Edges: ${data.edges?.length ?? 0}`); + if (data.summary) { + console.log(` Max depth: ${data.summary.max_hierarchy_depth}`); + console.log(` Unique vocabularies: ${data.summary.unique_vocabularies}`); + } +} + +async function main(): Promise { + // Construct inside main() so OMOPHubError on missing API key is caught + // by the .catch() handler below instead of crashing at module load. + const client = new OMOPHub(); + await getAncestors(client); + await getDescendants(client); + await exploreRelationships(client); + await getFullHierarchy(client); +} + +main().catch((err: unknown) => { + console.error('Failed:', err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/examples/search-concepts.ts b/examples/search-concepts.ts new file mode 100644 index 0000000..5f107d4 --- /dev/null +++ b/examples/search-concepts.ts @@ -0,0 +1,206 @@ +/** + * Search examples for the OMOPHub Node SDK. + * + * Demonstrates: basic search, filtered search, autocomplete, pagination + * via async iterator, semantic search, similarity search, bulk lexical + * search, and bulk semantic search. + * + * Run with: + * OMOPHUB_API_KEY=oh_... npx tsx examples/search-concepts.ts + */ +import { OMOPHub } from '../src/index.js'; + +async function basicSearch(client: OMOPHub): Promise { + console.log('=== Basic Search ==='); + // `search.basic` normalises three known wire shapes into a stable + // `{ concepts, facets?, search_metadata? }` object. + const { data, error } = await client.search.basic('heart attack'); + if (error) throw new Error(error.message); + console.log(`Found ${data.concepts.length} concepts for 'heart attack'`); + for (const c of data.concepts.slice(0, 3)) { + console.log(` ${c.concept_id}: ${c.concept_name} (${c.vocabulary_id})`); + } +} + +async function filteredSearch(client: OMOPHub): Promise { + console.log('\n=== Filtered Search ==='); + const { data, error } = await client.search.basic('myocardial infarction', { + vocabularyIds: ['SNOMED', 'ICD10CM'], + domainIds: ['Condition'], + standardConcept: 'S', // only standard concepts + pageSize: 10, + }); + if (error) throw new Error(error.message); + console.log(`Found ${data.concepts.length} standard condition concepts`); + for (const c of data.concepts.slice(0, 5)) { + console.log(` [${c.vocabulary_id}] ${c.concept_name}`); + } +} + +async function bulkLexicalSearch(client: OMOPHub): Promise { + console.log('\n=== Bulk Lexical Search ==='); + // `bulkBasic` returns a **bare array** of per-search result items. + const { data, error } = await client.search.bulkBasic( + [ + { search_id: 'q1', query: 'diabetes mellitus' }, + { search_id: 'q2', query: 'hypertension' }, + { search_id: 'q3', query: 'aspirin' }, + ], + { defaults: { vocabulary_ids: ['SNOMED'], page_size: 5 } }, + ); + if (error) throw new Error(error.message); + for (const item of data) { + console.log(` ${item.search_id}: ${item.results.length} results (${item.status})`); + } + + // Per-query overrides + const r2 = await client.search.bulkBasic( + [ + { search_id: 'conditions', query: 'diabetes', domain_ids: ['Condition'] }, + { search_id: 'drugs', query: 'metformin', domain_ids: ['Drug'] }, + ], + { defaults: { vocabulary_ids: ['SNOMED', 'RxNorm'], page_size: 3 } }, + ); + if (r2.error) throw new Error(r2.error.message); + console.log('\n Per-query domain overrides:'); + for (const item of r2.data) { + console.log(` ${item.search_id}:`); + for (const c of item.results.slice(0, 3)) { + console.log(` ${c.concept_name} (${c.vocabulary_id}/${c.domain_id})`); + } + } +} + +async function bulkSemanticSearch(client: OMOPHub): Promise { + console.log('\n=== Bulk Semantic Search ==='); + // `bulkSemantic` returns a wrapper object — UNLIKE `bulkBasic`. + const { data, error } = await client.search.bulkSemantic( + [ + { search_id: 's1', query: 'heart failure treatment options' }, + { search_id: 's2', query: 'type 2 diabetes medication' }, + { search_id: 's3', query: 'elevated blood pressure' }, + ], + { defaults: { threshold: 0.5, page_size: 5 } }, + ); + if (error) throw new Error(error.message); + console.log( + ` Completed ${data.completed_count}/${data.total_searches}` + + (data.total_duration !== undefined ? ` in ${data.total_duration}ms` : ''), + ); + for (const item of data.results) { + console.log(` ${item.search_id}: ${item.results.length} results (${item.status})`); + if (item.results.length > 0) { + const top = item.results[0]; + console.log( + ` Top: ${top?.concept_name} (score: ${top?.similarity_score.toFixed(2)})`, + ); + } + } +} + +async function autocompleteExample(client: OMOPHub): Promise { + console.log('\n=== Autocomplete ==='); + // Returns `{ query, suggestions: [{ suggestion: Concept, match_score?, match_type? }] }`. + const { data, error } = await client.search.autocomplete('hypert', { pageSize: 5 }); + if (error) throw new Error(error.message); + console.log(`Suggestions for '${data.query}':`); + for (const s of data.suggestions.slice(0, 5)) { + console.log(` [${s.suggestion.vocabulary_id}] ${s.suggestion.concept_name}`); + } +} + +async function paginationExample(client: OMOPHub): Promise { + console.log('\n=== Pagination (async iterator) ==='); + // `basicIter` walks every page; throws OMOPHubIteratorError on failure. + let count = 0; + for await (const concept of client.search.basicIter('aspirin', { pageSize: 10 })) { + if (count < 3) console.log(` ${concept.concept_name}`); + count++; + if (count >= 25) break; // demo cap + } + if (count > 3) { + console.log(` ... ${count - 3} more shown (demo capped at ${count})`); + } +} + +async function semanticSearch(client: OMOPHub): Promise { + console.log('\n=== Semantic Search ==='); + // Natural-language search — understands clinical intent. + const a = await client.search.semantic('high blood sugar levels'); + if (a.error) throw new Error(a.error.message); + for (const r of a.data.results.slice(0, 3)) { + console.log(` ${r.concept_name} (similarity: ${r.similarity_score.toFixed(2)})`); + } + + // Filtered semantic search with minimum threshold + const b = await client.search.semantic('heart attack', { + vocabularyIds: ['SNOMED'], + domainIds: ['Condition'], + threshold: 0.5, + }); + if (b.error) throw new Error(b.error.message); + console.log(` Found ${b.data.results.length} SNOMED conditions for 'heart attack'`); +} + +async function semanticPagination(client: OMOPHub): Promise { + console.log('\n=== Semantic Pagination ==='); + let count = 0; + for await (const result of client.search.semanticIter('chronic kidney disease', { + pageSize: 20, + })) { + if (count < 3) console.log(` ${result.concept_id}: ${result.concept_name}`); + count++; + if (count >= 50) break; + } + if (count > 3) { + console.log(` ... ${count - 3} more (demo capped at ${count})`); + } +} + +async function similaritySearch(client: OMOPHub): Promise { + console.log('\n=== Similarity Search ==='); + // XOR: provide exactly one of `conceptId`, `conceptName`, or `query`. + // The discriminated union enforces this at compile time. + const a = await client.search.similar({ + conceptId: 201826, // Type 2 diabetes mellitus + algorithm: 'hybrid', + }); + if (a.error) throw new Error(a.error.message); + console.log("Concepts similar to 'Type 2 diabetes mellitus':"); + for (const r of a.data.similar_concepts.slice(0, 5)) { + const score = typeof r.similarity_score === 'number' ? r.similarity_score.toFixed(2) : '?'; + console.log(` ${r.concept_name} (score: ${score})`); + } + + // Free-text query variant (`similar` uses a two-arg signature so its + // `query` field doesn't collide with `PerCallOptions.query`). + const b = await client.search.similar({ + query: 'medications for high blood pressure', + algorithm: 'semantic', + similarityThreshold: 0.6, + vocabularyIds: ['RxNorm'], + includeScores: true, + }); + if (b.error) throw new Error(b.error.message); + console.log(`\n Found ${b.data.similar_concepts.length} similar RxNorm concepts`); +} + +async function main(): Promise { + // Construct inside main() so OMOPHubError on missing API key is caught + // by the .catch() handler below instead of crashing at module load. + const client = new OMOPHub(); + await basicSearch(client); + await filteredSearch(client); + await autocompleteExample(client); + await paginationExample(client); + await semanticSearch(client); + await semanticPagination(client); + await similaritySearch(client); + await bulkLexicalSearch(client); + await bulkSemanticSearch(client); +} + +main().catch((err: unknown) => { + console.error('Failed:', err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8f67a32 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2208 @@ +{ + "name": "@omophub/omophub-node", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@omophub/omophub-node", + "version": "0.0.1", + "license": "MIT", + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/node": "^25.0.3", + "@vitest/coverage-v8": "^4.1.7", + "tsx": "^4.19.0", + "typescript": "^6.0.3", + "vitest": "^4.1.7" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz", + "integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.16", + "@biomejs/cli-darwin-x64": "2.4.16", + "@biomejs/cli-linux-arm64": "2.4.16", + "@biomejs/cli-linux-arm64-musl": "2.4.16", + "@biomejs/cli-linux-x64": "2.4.16", + "@biomejs/cli-linux-x64-musl": "2.4.16", + "@biomejs/cli-win32-arm64": "2.4.16", + "@biomejs/cli-win32-x64": "2.4.16" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz", + "integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz", + "integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz", + "integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz", + "integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz", + "integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz", + "integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz", + "integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz", + "integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "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/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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/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/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "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.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/ast-v8-to-istanbul": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz", + "integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "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/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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.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/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "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.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "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": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/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/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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.2.3", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.3.tgz", + "integrity": "sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==", + "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", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "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.1.18", + "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/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "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.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "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/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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2039b41 --- /dev/null +++ b/package.json @@ -0,0 +1,74 @@ +{ + "name": "@omophub/omophub-node", + "version": "1.0.0", + "description": "Official OMOPHub Node.js / TypeScript SDK for the OHDSI medical vocabularies API - search, lookup, map, and navigate concepts across SNOMED, ICD10, RxNorm, LOINC, and more.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "CHANGELOG.md" + ], + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc", + "dev": "tsx src/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "lint": "biome check src/ test/ e2e/", + "lint:fix": "biome check --write src/ test/ e2e/", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.e2e.json", + "format": "biome format --write src/ test/ e2e/", + "format:check": "biome format --check src/ test/ e2e/", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "omophub", + "ohdsi", + "omop", + "medical-vocabularies", + "snomed", + "icd10", + "rxnorm", + "loinc", + "healthcare", + "clinical-data", + "vocabulary-mapping", + "concept-mapping", + "fhir", + "athena", + "sdk" + ], + "author": "OMOPHub", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/OMOPHub/omophub-node.git" + }, + "bugs": { + "url": "https://github.com/OMOPHub/omophub-node/issues" + }, + "homepage": "https://omophub.com", + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/node": "^25.0.3", + "@vitest/coverage-v8": "^4.1.7", + "tsx": "^4.19.0", + "typescript": "^6.0.3", + "vitest": "^4.1.7" + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..22a9943 --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"] +} diff --git a/src/auth/auth.ts b/src/auth/auth.ts new file mode 100644 index 0000000..4c38761 --- /dev/null +++ b/src/auth/auth.ts @@ -0,0 +1,54 @@ +import { OMOPHubError } from '../errors.js'; + +const ENV_VAR = 'OMOPHUB_API_KEY'; + +/** + * Returns the API key from `process.env.OMOPHUB_API_KEY`, or `undefined` + * if unset or running outside a Node-like runtime (Cloudflare Workers, + * Vercel Edge, browsers without polyfills). + */ +export function getApiKey(): string | undefined { + if (typeof process === 'undefined' || !process.env) return undefined; + return process.env[ENV_VAR]; +} + +/** + * Stores the API key in `process.env.OMOPHUB_API_KEY`. Throws + * `OMOPHubError` in runtimes without a writable `process.env` — that's + * an SDK misuse signal, not a network error, so it deliberately throws + * rather than returning the discriminated error shape. + */ +export function setApiKey(key: string): void { + if (typeof process === 'undefined' || !process.env) { + throw new OMOPHubError('setApiKey requires a Node-like runtime with a writable process.env.'); + } + // Reject empty / whitespace-only keys so `setApiKey` stays consistent + // with `hasApiKey` (which returns false for empty strings). + if (typeof key !== 'string' || key.trim().length === 0) { + throw new OMOPHubError('setApiKey requires a non-empty key string.'); + } + // Trim surrounding whitespace before persisting. We validated with + // `.trim()` above; storing the raw value would leak the whitespace into + // every outbound request's Authorization header and the server would + // reject the key as invalid. + const trimmed = key.trim(); + // Direct env assignment can throw `TypeError` in runtimes where + // process.env is frozen / sealed / read-only-proxied (some edge + // runtimes, restricted Bun modes, etc.). Convert to `OMOPHubError` so + // callers can rely on the documented exception type. + try { + process.env[ENV_VAR] = trimmed; + } catch (err) { + throw new OMOPHubError( + `setApiKey could not write to process.env: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +/** + * Returns `true` iff `OMOPHUB_API_KEY` is set and non-empty. + */ +export function hasApiKey(): boolean { + const k = getApiKey(); + return typeof k === 'string' && k.length > 0; +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..9187115 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,273 @@ +import type { DeleteOptions } from './common/interfaces/delete-options.js'; +import type { GetOptions } from './common/interfaces/get-options.js'; +import type { PatchOptions } from './common/interfaces/patch-options.js'; +import type { HeadersInit, PerCallOptions } from './common/interfaces/per-call-options.js'; +import type { PostOptions } from './common/interfaces/post-options.js'; +import type { PutOptions } from './common/interfaces/put-options.js'; +import { backoffMs, isNoSideEffectStatus, isRetryableStatus } from './common/utils/backoff.js'; +import { appendQuery, buildQuery } from './common/utils/build-query.js'; +import { envOrUndefined } from './common/utils/env.js'; +import { mergeHeaders } from './common/utils/merge-headers.js'; +import { connectionError, parseErrorResponse, timeoutError } from './common/utils/parse-error.js'; +import { sleep } from './common/utils/sleep.js'; +import { unwrapEnvelope } from './common/utils/unwrap-envelope.js'; +import { Concepts } from './concepts/concepts.js'; +import { Domains } from './domains/domains.js'; +import { OMOPHubError } from './errors.js'; +import { Fhir } from './fhir/fhir.js'; +import { Hierarchy } from './hierarchy/hierarchy.js'; +import type { Response as OMOPHubResponse } from './interfaces.js'; +import { Mappings } from './mappings/mappings.js'; +import { Relationships } from './relationships/relationships.js'; +import { Search } from './search/search.js'; +import { __version__ } from './version.js'; +import { Vocabularies } from './vocabularies/vocabularies.js'; + +const DEFAULT_BASE_URL = 'https://api.omophub.com/v1'; +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_USER_AGENT = `omophub-node/${__version__}`; + +export interface OMOPHubOptions { + /** Base URL of the OMOPHub API. Defaults to env `OMOPHUB_API_URL` or the production URL. */ + baseUrl?: string; + /** Per-request timeout in milliseconds. Default 30000. Set to 0 to disable. */ + timeoutMs?: number; + /** + * Retry attempts on retryable failures (429, 502, 503, 504, and transient + * network errors) — see `isRetryableStatus`. Default 3. Set to 0 to disable. + */ + maxRetries?: number; + /** Pin a vocabulary version via the `X-Vocab-Version` header on every request. */ + vocabVersion?: string; + /** Override the SDK user-agent string. */ + userAgent?: string; + /** Inject a custom fetch implementation (proxy, instrumentation). Defaults to `globalThis.fetch`. */ + fetch?: typeof fetch; +} + +/** + * Entry point for the OMOPHub SDK. + * + * ```ts + * import { OMOPHub } from '@omophub/omophub-node'; + * + * const client = new OMOPHub(process.env.OMOPHUB_API_KEY); + * const { data, error } = await client.vocabularies.list({ pageSize: 5 }); + * if (error) throw new Error(error.message); + * console.log(data.data.map((v) => v.vocabulary_id)); + * ``` + */ +export class OMOPHub { + readonly baseUrl: string; + readonly timeoutMs: number; + readonly maxRetries: number; + readonly userAgent: string; + readonly vocabVersion: string | undefined; + readonly #apiKey: string; + readonly #headers: Headers; + readonly #fetch: typeof fetch; + + readonly concepts: Concepts; + readonly domains: Domains; + readonly fhir: Fhir; + readonly hierarchy: Hierarchy; + readonly mappings: Mappings; + readonly relationships: Relationships; + readonly search: Search; + readonly vocabularies: Vocabularies; + + constructor(apiKey?: string, options: OMOPHubOptions = {}) { + const resolvedKey = apiKey ?? envOrUndefined('OMOPHUB_API_KEY'); + if (!resolvedKey) { + throw new OMOPHubError( + 'Missing API key. Pass it to the constructor: new OMOPHub("oh_...") or set OMOPHUB_API_KEY.', + ); + } + this.#apiKey = resolvedKey; + this.baseUrl = options.baseUrl ?? envOrUndefined('OMOPHUB_API_URL') ?? DEFAULT_BASE_URL; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; + this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT; + this.vocabVersion = options.vocabVersion; + + this.#headers = new Headers({ + Authorization: `Bearer ${this.#apiKey}`, + 'User-Agent': this.userAgent, + 'Content-Type': 'application/json', + Accept: 'application/json', + }); + if (this.vocabVersion) this.#headers.set('X-Vocab-Version', this.vocabVersion); + + const fetchImpl = options.fetch ?? globalThis.fetch; + this.#fetch = fetchImpl.bind(globalThis); + + this.concepts = new Concepts(this); + this.domains = new Domains(this); + this.fhir = new Fhir(this); + this.hierarchy = new Hierarchy(this); + this.mappings = new Mappings(this); + this.relationships = new Relationships(this); + this.search = new Search(this); + this.vocabularies = new Vocabularies(this); + } + + async get(path: string, options: GetOptions = {}): Promise> { + return this.#dispatch(path, 'GET', undefined, options); + } + + async post( + path: string, + body?: unknown, + options: PostOptions = {}, + ): Promise> { + const { idempotencyKey, ...rest } = options; + let mergedHeadersInit: HeadersInit | undefined = rest.headers; + if (idempotencyKey) { + const headers = new Headers(rest.headers); + headers.set('Idempotency-Key', idempotencyKey); + mergedHeadersInit = headers; + } + return this.#dispatch(path, 'POST', body, { ...rest, headers: mergedHeadersInit }); + } + + async patch( + path: string, + body?: unknown, + options: PatchOptions = {}, + ): Promise> { + return this.#dispatch(path, 'PATCH', body, options); + } + + async put( + path: string, + body?: unknown, + options: PutOptions = {}, + ): Promise> { + return this.#dispatch(path, 'PUT', body, options); + } + + async delete( + path: string, + body?: unknown, + options: DeleteOptions = {}, + ): Promise> { + return this.#dispatch(path, 'DELETE', body, options); + } + + async #dispatch( + path: string, + method: string, + body: unknown, + options: PerCallOptions, + ): Promise> { + const query = buildQuery(options.query); + const url = `${this.baseUrl}${appendQuery(path, query)}`; + const headers = mergeHeaders(this.#headers, options.headers); + + let attempt = 0; + while (true) { + const init: RequestInit = { method, headers }; + if (body !== undefined && method !== 'GET') { + init.body = JSON.stringify(body); + } + + const timeoutController = this.timeoutMs > 0 ? new AbortController() : null; + const timer = timeoutController + ? setTimeout(() => timeoutController.abort(), this.timeoutMs) + : null; + init.signal = composeSignals(timeoutController?.signal, options.signal); + + try { + const response = await this.#fetch(url, init); + if (timer) clearTimeout(timer); + const responseHeaders = headersToRecord(response.headers); + + if (response.ok) { + let parsed: unknown; + try { + parsed = await response.json(); + } catch { + parsed = null; + } + const { data, meta } = unwrapEnvelope(parsed); + return { data, error: null, meta, headers: responseHeaders }; + } + + if ( + isRetryableStatus(response.status) && + attempt < this.maxRetries && + (isNoSideEffectStatus(response.status) || isRetryableRequest(method, headers)) + ) { + const retryAfter = response.headers.get('retry-after'); + // Drain the body so undici releases the connection back to the pool + // before we sleep and reuse the agent for the next attempt. + await response.body?.cancel(); + await sleep(backoffMs(attempt, retryAfter)); + attempt++; + continue; + } + + const error = await parseErrorResponse(response); + return { data: null, error, meta: null, headers: responseHeaders }; + } catch (err) { + if (timer) clearTimeout(timer); + if (isTimeoutAbort(err, timeoutController)) { + return { data: null, error: timeoutError(), meta: null, headers: null }; + } + if (isCallerAbort(err, options.signal)) { + throw err; + } + if (attempt < this.maxRetries && isRetryableRequest(method, headers)) { + await sleep(backoffMs(attempt, null)); + attempt++; + continue; + } + return { data: null, error: connectionError(err), meta: null, headers: null }; + } + } + } +} + +function composeSignals( + timeoutSignal: AbortSignal | undefined, + callerSignal: AbortSignal | undefined, +): AbortSignal | undefined { + if (timeoutSignal && callerSignal) return AbortSignal.any([timeoutSignal, callerSignal]); + return timeoutSignal ?? callerSignal; +} + +function headersToRecord(headers: Headers): Record { + const out: Record = {}; + headers.forEach((value, key) => { + out[key] = value; + }); + return out; +} + +function isTimeoutAbort(err: unknown, controller: AbortController | null): boolean { + if (!controller) return false; + if (!(err instanceof Error)) return false; + return err.name === 'AbortError' && controller.signal.aborted; +} + +function isCallerAbort(err: unknown, callerSignal: AbortSignal | undefined): boolean { + if (!callerSignal) return false; + if (!(err instanceof Error)) return false; + return err.name === 'AbortError' && callerSignal.aborted; +} + +const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']); + +/** + * A request is safe to retry when its HTTP verb is idempotent (RFC 7231) + * or when the caller has supplied an `Idempotency-Key` header — at which + * point the server can deduplicate on its side. POST and PATCH without a + * key are NOT retried by this gate, since retry could create duplicates. + * (`isNoSideEffectStatus` bypasses this for statuses like 429 where the + * server explicitly rejected without processing.) + */ +function isRetryableRequest(method: string, headers: Headers): boolean { + if (IDEMPOTENT_METHODS.has(method)) return true; + return headers.has('Idempotency-Key'); +} diff --git a/src/common/interfaces/api-envelope.ts b/src/common/interfaces/api-envelope.ts new file mode 100644 index 0000000..02f7e1f --- /dev/null +++ b/src/common/interfaces/api-envelope.ts @@ -0,0 +1,18 @@ +export interface ApiSuccessEnvelope { + success: true; + data: T; + meta?: Record; +} + +export interface ApiErrorEnvelope { + success: false; + error: { + code?: string; + name?: string; + message: string; + details?: Record; + }; + meta?: Record; +} + +export type ApiEnvelope = ApiSuccessEnvelope | ApiErrorEnvelope; diff --git a/src/common/interfaces/delete-options.ts b/src/common/interfaces/delete-options.ts new file mode 100644 index 0000000..506b403 --- /dev/null +++ b/src/common/interfaces/delete-options.ts @@ -0,0 +1,3 @@ +import type { PerCallOptions } from './per-call-options.js'; + +export type DeleteOptions = PerCallOptions; diff --git a/src/common/interfaces/get-options.ts b/src/common/interfaces/get-options.ts new file mode 100644 index 0000000..259e53b --- /dev/null +++ b/src/common/interfaces/get-options.ts @@ -0,0 +1,3 @@ +import type { PerCallOptions } from './per-call-options.js'; + +export type GetOptions = PerCallOptions; diff --git a/src/common/interfaces/index.ts b/src/common/interfaces/index.ts new file mode 100644 index 0000000..58b3c31 --- /dev/null +++ b/src/common/interfaces/index.ts @@ -0,0 +1,15 @@ +export type { + ApiEnvelope, + ApiErrorEnvelope, + ApiSuccessEnvelope, +} from './api-envelope.js'; +export type { DeleteOptions } from './delete-options.js'; +export type { GetOptions } from './get-options.js'; +export type { PaginatedData, PaginationMeta, PaginationOptions } from './pagination.js'; +export type { PatchOptions } from './patch-options.js'; +export type { PerCallOptions, QueryValue } from './per-call-options.js'; +export type { IdempotentRequest, PostOptions } from './post-options.js'; +export type { PutOptions } from './put-options.js'; +export type { RequireAtLeastOne } from './require-at-least-one.js'; +export type { RequireExactlyOne } from './require-exactly-one.js'; +export type { VocabReleaseMixin } from './vocab-release.js'; diff --git a/src/common/interfaces/pagination.ts b/src/common/interfaces/pagination.ts new file mode 100644 index 0000000..7ff5e99 --- /dev/null +++ b/src/common/interfaces/pagination.ts @@ -0,0 +1,28 @@ +export interface PaginationOptions { + /** 1-based page number. Default 1. */ + page?: number; + /** Items per page. Max 1000. Default varies per endpoint (10–100). */ + pageSize?: number; +} + +/** + * Pagination metadata returned by paginated endpoints. Field names are + * snake_case to match the wire format — no translation applied. + */ +export interface PaginationMeta { + page: number; + page_size: number; + total_items: number; + total_pages: number; + has_next: boolean; + has_previous: boolean; +} + +/** + * Response shape for paginated endpoints. The data array sits at `data`; + * pagination metadata is nested under `meta.pagination`. + */ +export interface PaginatedData { + data: T[]; + meta: { pagination: PaginationMeta }; +} diff --git a/src/common/interfaces/patch-options.ts b/src/common/interfaces/patch-options.ts new file mode 100644 index 0000000..c4da60d --- /dev/null +++ b/src/common/interfaces/patch-options.ts @@ -0,0 +1,3 @@ +import type { PerCallOptions } from './per-call-options.js'; + +export type PatchOptions = PerCallOptions; diff --git a/src/common/interfaces/per-call-options.ts b/src/common/interfaces/per-call-options.ts new file mode 100644 index 0000000..f792208 --- /dev/null +++ b/src/common/interfaces/per-call-options.ts @@ -0,0 +1,23 @@ +export type QueryValue = string | number | boolean | string[] | number[] | null | undefined; + +/** + * Local stand-in for the DOM `HeadersInit`. Structurally compatible with + * the global Headers constructor (provided by `@types/node` via + * `undici-types`). Kept narrow on purpose — we surface it on + * `PerCallOptions.headers` so consumers don't need DOM lib types. + */ +export type HeadersInit = Headers | Record | [string, string][]; + +/** + * Common options accepted by every SDK method as its trailing argument. + * Lets callers override per-request headers, add query-string params, or + * pass an AbortSignal without touching the client. + */ +export interface PerCallOptions { + /** Per-call header overrides. Merged onto the client's default headers. */ + headers?: HeadersInit; + /** Extra query-string parameters. Keys are camelCase → snake_case at the wire boundary. */ + query?: Record; + /** AbortSignal to cancel the request. Composed with the client's internal timeout signal. */ + signal?: AbortSignal; +} diff --git a/src/common/interfaces/post-options.ts b/src/common/interfaces/post-options.ts new file mode 100644 index 0000000..4fa6727 --- /dev/null +++ b/src/common/interfaces/post-options.ts @@ -0,0 +1,8 @@ +import type { PerCallOptions } from './per-call-options.js'; + +export interface IdempotentRequest { + /** Optional `Idempotency-Key` header value. POST-only. */ + idempotencyKey?: string; +} + +export type PostOptions = PerCallOptions & IdempotentRequest; diff --git a/src/common/interfaces/put-options.ts b/src/common/interfaces/put-options.ts new file mode 100644 index 0000000..46ed37e --- /dev/null +++ b/src/common/interfaces/put-options.ts @@ -0,0 +1,3 @@ +import type { PerCallOptions } from './per-call-options.js'; + +export type PutOptions = PerCallOptions; diff --git a/src/common/interfaces/require-at-least-one.ts b/src/common/interfaces/require-at-least-one.ts new file mode 100644 index 0000000..ae89da0 --- /dev/null +++ b/src/common/interfaces/require-at-least-one.ts @@ -0,0 +1,10 @@ +/** + * Forces the consumer to set at least one of the given keys. + * + * ```ts + * type Options = RequireAtLeastOne<{ html: string; text: string; react: ReactNode }>; + * ``` + */ +export type RequireAtLeastOne = { + [K in keyof T]-?: Required> & Partial>>; +}[keyof T]; diff --git a/src/common/interfaces/require-exactly-one.ts b/src/common/interfaces/require-exactly-one.ts new file mode 100644 index 0000000..529db51 --- /dev/null +++ b/src/common/interfaces/require-exactly-one.ts @@ -0,0 +1,15 @@ +/** + * Forces the consumer to set exactly one of the given keys. + * + * ```ts + * type SimilarOptions = RequireExactlyOne<{ + * conceptId: number; + * conceptName: string; + * query: string; + * }>; + * ``` + */ +export type RequireExactlyOne = Omit & + { + [P in K]: Required> & Partial, undefined>>; + }[K]; diff --git a/src/common/interfaces/vocab-release.ts b/src/common/interfaces/vocab-release.ts new file mode 100644 index 0000000..e6e1525 --- /dev/null +++ b/src/common/interfaces/vocab-release.ts @@ -0,0 +1,8 @@ +export interface VocabReleaseMixin { + /** + * Vocabulary release to query (e.g. `"2025.1"`). Sent as a `?vocab_release=` + * query-string parameter. If the client was constructed with + * `vocabVersion`, this per-call value takes precedence at the API level. + */ + vocabRelease?: string; +} diff --git a/src/common/utils/backoff.ts b/src/common/utils/backoff.ts new file mode 100644 index 0000000..fc6035b --- /dev/null +++ b/src/common/utils/backoff.ts @@ -0,0 +1,99 @@ +const INITIAL_DELAY_MS = 500; +const MAX_DELAY_MS = 8_000; +const MAX_RETRY_AFTER_SEC = 60; +const MIN_RETRY_AFTER_MS = 100; + +const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]); + +export function isRetryableStatus(status: number): boolean { + return RETRYABLE_STATUSES.has(status); +} + +/** + * Statuses where the server is guaranteed not to have touched state + * before rejecting (RFC 9110 §15.5.29: 429 means the request was + * declined, not processed). Safe to retry regardless of HTTP method, + * since there's nothing to deduplicate. Distinct from 5xx, where the + * upstream may have partially processed the request before failing — + * those still gate on `Idempotency-Key` to avoid duplicate side effects. + */ +const NO_SIDE_EFFECT_STATUSES = new Set([429]); + +export function isNoSideEffectStatus(status: number): boolean { + return NO_SIDE_EFFECT_STATUSES.has(status); +} + +/** + * Computes the delay before the next retry attempt. + * + * - Honours `Retry-After` header (seconds or HTTP-date) — clamped to + * `[100ms, 60s]`. A value of 0 becomes 100ms (no spam-retry) and a + * value over 60s is capped at 60s (the server's signal isn't ignored). + * - When no Retry-After is present, full-jitter exponential backoff: + * `min(500 * 2^attempt, 8000) * (1 - 0.25 * random())`. + */ +export function backoffMs(attempt: number, retryAfter: string | null): number { + if (retryAfter) { + const seconds = parseRetryAfter(retryAfter); + if (seconds !== null) { + const cappedMs = Math.min(seconds, MAX_RETRY_AFTER_SEC) * 1000; + return Math.max(cappedMs, MIN_RETRY_AFTER_MS); + } + } + const exp = Math.min(INITIAL_DELAY_MS * 2 ** attempt, MAX_DELAY_MS); + return exp * (1 - 0.25 * Math.random()); +} + +// Per RFC 9110 §5.6.7. `Date.parse` itself accepts a much broader grammar +// (ISO strings, trailing-junk strict-looking dates, natural language in +// some engines), so we full-string match before trusting it. +const IMF_FIXDATE_RE = + /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/; +const RFC850_DATE_RE = + /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT$/; +const ASCTIME_DATE_RE = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( \d|\d{2}) (\d{2}:\d{2}:\d{2}) (\d{4})$/; + +/** + * Parses a `Retry-After` header value (delta-seconds or HTTP-date) into + * a number of seconds. Returns `null` for unparseable input. Exported so + * `parse-error.ts` can populate `ErrorResponse.retryAfter` consistently. + * + * Strict per RFC 9110 §10.2.3 / §5.6.7: + * - `delta-seconds` is `1*DIGIT` — no signs, decimals, exponents, or + * whitespace. `Number('')` quietly returns 0; `Number('1.5')` returns + * 1.5; both would otherwise be honoured as retry delays. + * - `HTTP-date` is exactly one of `IMF-fixdate` (preferred), + * `rfc850-date`, or `asctime-date`. Each is matched against a full + * regex before handing off to `Date.parse`. `asctime-date` has no + * timezone field; `Date.parse` interprets the wall-clock as local + * time (the original C `asctime()` convention), but RFC 9110 expects + * UTC, so we normalise the matched components into an IMF-fixdate + * ending in `GMT` before parsing. + */ +export function parseRetryAfter(header: string): number | null { + if (/^\d+$/.test(header)) { + return Number(header); + } + let toParse: string | null = null; + if (IMF_FIXDATE_RE.test(header) || RFC850_DATE_RE.test(header)) { + toParse = header; + } else { + const m = ASCTIME_DATE_RE.exec(header); + if (m) { + const [, dayName, month, day, time, year] = m; + // Group 3 is `( 2DIGIT / ( SP 1DIGIT ))` — single-digit days arrive + // as " 6" (space-prefixed); IMF-fixdate requires zero-padding. + const dd = (day ?? '').trim().padStart(2, '0'); + toParse = `${dayName}, ${dd} ${month} ${year} ${time} GMT`; + } + } + if (toParse !== null) { + const date = Date.parse(toParse); + if (Number.isFinite(date)) { + const diffMs = date - Date.now(); + return diffMs > 0 ? Math.ceil(diffMs / 1000) : 0; + } + } + return null; +} diff --git a/src/common/utils/build-query.ts b/src/common/utils/build-query.ts new file mode 100644 index 0000000..c511c08 --- /dev/null +++ b/src/common/utils/build-query.ts @@ -0,0 +1,37 @@ +import type { QueryValue } from '../interfaces/per-call-options.js'; +import { camelToSnakeCase } from './to-snake-case.js'; + +/** + * Serialises a record of options into a URL query string. + * - camelCase keys are converted to snake_case. + * - `null` and `undefined` values are dropped. + * - Arrays are comma-joined (matches the OMOPHub API convention). + * - Empty arrays are dropped. + * - Booleans and numbers are stringified. + * - Duplicates (after snake-casing) are last-write-wins via `set`, so a + * resource that spreads `{ ...flags, ...userQuery }` lets explicit + * user-supplied keys override the SDK's defaults predictably — whether + * the user wrote `pageSize` or `page_size`. + * + * Returns the encoded query body **without** a leading `?`. + */ +export function buildQuery(params: Record | undefined): string { + if (!params) return ''; + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + const snakeKey = camelToSnakeCase(key); + if (Array.isArray(value)) { + if (value.length === 0) continue; + search.set(snakeKey, value.join(',')); + } else { + search.set(snakeKey, String(value)); + } + } + return search.toString(); +} + +export function appendQuery(path: string, query: string): string { + if (!query) return path; + return `${path}${path.includes('?') ? '&' : '?'}${query}`; +} diff --git a/src/common/utils/env.ts b/src/common/utils/env.ts new file mode 100644 index 0000000..9946b1f --- /dev/null +++ b/src/common/utils/env.ts @@ -0,0 +1,12 @@ +/** + * Reads a process.env value, returning undefined if process is not + * available (Cloudflare Workers, Vercel Edge, etc.). Never throws. + */ +export function envOrUndefined(name: string): string | undefined { + if (typeof process === 'undefined' || !process.env) return undefined; + return process.env[name]; +} + +export function envOr(name: string, fallback: string): string { + return envOrUndefined(name) ?? fallback; +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts new file mode 100644 index 0000000..c75157e --- /dev/null +++ b/src/common/utils/index.ts @@ -0,0 +1,19 @@ +export { backoffMs, isRetryableStatus } from './backoff.js'; +export { appendQuery, buildQuery } from './build-query.js'; +export { envOr, envOrUndefined } from './env.js'; +export { mergeHeaders } from './merge-headers.js'; +export { + normaliseBasicSearchData, + normaliseSemanticSearchData, +} from './normalize-search-response.js'; +export { + type PageFetcher, + type PaginateAllResult, + paginate, + paginateAll, +} from './paginate.js'; +export { connectionError, parseErrorResponse, timeoutError } from './parse-error.js'; +export { sleep } from './sleep.js'; +export { syntheticError } from './synthetic-error.js'; +export { camelToSnakeCase, toSnakeCaseKeys } from './to-snake-case.js'; +export { type UnwrappedEnvelope, unwrapEnvelope } from './unwrap-envelope.js'; diff --git a/src/common/utils/merge-headers.ts b/src/common/utils/merge-headers.ts new file mode 100644 index 0000000..a539789 --- /dev/null +++ b/src/common/utils/merge-headers.ts @@ -0,0 +1,15 @@ +import type { HeadersInit } from '../interfaces/per-call-options.js'; + +/** + * Returns a new Headers object with `extra` merged onto `base`. Keys in + * `extra` overwrite keys in `base`. Neither input is mutated. + */ +export function mergeHeaders(base: Headers, extra: HeadersInit | undefined): Headers { + if (!extra) return new Headers(base); + const merged = new Headers(base); + const incoming = new Headers(extra); + incoming.forEach((value, key) => { + merged.set(key, value); + }); + return merged; +} diff --git a/src/common/utils/normalize-search-response.ts b/src/common/utils/normalize-search-response.ts new file mode 100644 index 0000000..5b3667e --- /dev/null +++ b/src/common/utils/normalize-search-response.ts @@ -0,0 +1,58 @@ +import type { Concept } from '../../concepts/interfaces/concept.js'; +import type { + SearchFacets, + SearchMetadata, + SearchResult, +} from '../../search/interfaces/search-result.js'; +import type { SemanticSearchResult } from '../../search/interfaces/semantic-search-result.js'; + +/** + * Normalises the wire shape of `GET /search/concepts` and + * `POST /search/advanced` into a stable `SearchResult` object. + * + * The server historically returned several shapes for the inner `data` + * payload (post-envelope-unwrap): + * 1. `{ concepts: Concept[], facets, search_metadata }` — modern. + * 2. `{ data: Concept[] }` — legacy paginated wrapper. + * 3. `Concept[]` — bare array. + * + * Callers always see shape #1 regardless of what the server sent. + * + * @see project memory `project_search_api_response_shapes.md` + */ +export function normaliseBasicSearchData(raw: unknown): SearchResult { + if (Array.isArray(raw)) { + return { concepts: raw as Concept[] }; + } + if (!raw || typeof raw !== 'object') { + return { concepts: [] }; + } + const obj = raw as Record; + const concepts = pickArray(obj.concepts) ?? pickArray(obj.data) ?? []; + const result: SearchResult = { concepts }; + if (obj.facets && typeof obj.facets === 'object') { + result.facets = obj.facets as SearchFacets; + } + if (obj.search_metadata && typeof obj.search_metadata === 'object') { + result.search_metadata = obj.search_metadata as SearchMetadata; + } + return result; +} + +/** + * Normalises `GET /concepts/semantic-search` into a flat + * `SemanticSearchResult[]` for the iter/all helpers. Handles both + * `{ results: [...] }` and bare-array forms. + */ +export function normaliseSemanticSearchData(raw: unknown): SemanticSearchResult[] { + if (Array.isArray(raw)) return raw as SemanticSearchResult[]; + if (!raw || typeof raw !== 'object') return []; + const obj = raw as Record; + return ( + pickArray(obj.results) ?? pickArray(obj.data) ?? [] + ); +} + +function pickArray(value: unknown): T[] | null { + return Array.isArray(value) ? (value as T[]) : null; +} diff --git a/src/common/utils/paginate.ts b/src/common/utils/paginate.ts new file mode 100644 index 0000000..df651fb --- /dev/null +++ b/src/common/utils/paginate.ts @@ -0,0 +1,115 @@ +import { OMOPHubIteratorError } from '../../errors.js'; +import type { ErrorResponse, Response as OMOPHubResponse } from '../../interfaces.js'; +import type { PaginatedData } from '../interfaces/pagination.js'; + +/** + * A page-fetching closure. Resource methods adapt their own typed + * options into this generic shape so `paginate` / `paginateAll` stay + * resource-agnostic. + * + * The returned envelope may be a `PaginatedData` (preferred — gives us + * `has_next`) OR a bare `T[]` (legacy / non-paginated endpoints — stop + * after one page). + */ +export type PageFetcher = ( + page: number, + pageSize: number, +) => Promise | T[]>>; + +export interface PaginateAllResult { + data: T[]; + errors: ErrorResponse[]; + pagesFetched: number; +} + +/** + * Async generator that yields each item across pages. + * + * Throws `OMOPHubIteratorError` (wrapping the page's `ErrorResponse`) + * the first time a page fetch fails — async generators can't gracefully + * yield a discriminated `{ data, error }` union, so they throw and the + * eager `paginateAll` variant accumulates errors as values for callers + * who prefer that style. + */ +export async function* paginate( + fetchPage: PageFetcher, + options: { startPage?: number; pageSize?: number; maxPages?: number } = {}, +): AsyncGenerator { + const startPage = options.startPage ?? 1; + const pageSize = options.pageSize ?? 100; + const maxPages = options.maxPages ?? Number.POSITIVE_INFINITY; + + let page = startPage; + let pagesYielded = 0; + while (pagesYielded < maxPages) { + const response = await fetchPage(page, pageSize); + if (response.error) { + throw new OMOPHubIteratorError( + response.error.message, + response.error.statusCode, + response.error.name, + ); + } + const items = extractItems(response.data); + if (items.length === 0) return; + for (const item of items) yield item; + pagesYielded++; + + if (!hasNextPage(response)) return; + page++; + } +} + +/** + * Eagerly walks every page, collecting items into an array. Errors are + * accumulated rather than thrown — caller decides what to do with a + * partial result. + */ +export async function paginateAll( + fetchPage: PageFetcher, + options: { startPage?: number; pageSize?: number; maxPages?: number } = {}, +): Promise> { + const startPage = options.startPage ?? 1; + const pageSize = options.pageSize ?? 100; + const maxPages = options.maxPages ?? Number.POSITIVE_INFINITY; + + const collected: T[] = []; + const errors: ErrorResponse[] = []; + let pagesFetched = 0; + let page = startPage; + + while (pagesFetched < maxPages) { + const response = await fetchPage(page, pageSize); + pagesFetched++; + if (response.error) { + errors.push(response.error); + return { data: collected, errors, pagesFetched }; + } + const items = extractItems(response.data); + collected.push(...items); + if (items.length === 0 || !hasNextPage(response)) { + return { data: collected, errors, pagesFetched }; + } + page++; + } + return { data: collected, errors, pagesFetched }; +} + +function extractItems(data: PaginatedData | T[] | null): T[] { + if (data === null) return []; + if (Array.isArray(data)) return data; + return data.data; +} + +function hasNextPage(response: OMOPHubResponse | T[]>): boolean { + // Canonical location per SDK convention — `meta.pagination` always lives + // on the outer envelope. Resource methods that pre-wrap their data into + // a `PaginatedData` shape are handled by the fallback below. + const outerHasNext = response.meta?.pagination?.has_next; + if (outerHasNext === true) return true; + if (outerHasNext === false) return false; + // Fallback: data-internal pagination (legacy / pre-wrapped callers). + const data = response.data; + if (!data || Array.isArray(data)) return false; + return data.meta.pagination.has_next === true; +} diff --git a/src/common/utils/parse-error.ts b/src/common/utils/parse-error.ts new file mode 100644 index 0000000..7cbf568 --- /dev/null +++ b/src/common/utils/parse-error.ts @@ -0,0 +1,135 @@ +import type { ErrorResponse, OMOPHUB_ERROR_CODE_KEY } from '../../interfaces.js'; +import { parseRetryAfter } from './backoff.js'; + +const STATUS_TO_CODE: Record = { + 400: 'validation_error', + 401: 'invalid_api_key', + 403: 'restricted_api_key', + 404: 'not_found', + 405: 'method_not_allowed', + 409: 'conflict', + 429: 'rate_limit_exceeded', +}; + +const KNOWN_CODES = new Set([ + 'missing_api_key', + 'invalid_api_key', + 'restricted_api_key', + 'validation_error', + 'missing_required_field', + 'invalid_argument', + 'not_found', + 'method_not_allowed', + 'conflict', + 'rate_limit_exceeded', + 'tier_limit_exceeded', + 'internal_server_error', + 'service_unavailable', + 'connection_error', + 'timeout_error', + 'application_error', +]); + +/** + * Converts a non-OK fetch Response into an ErrorResponse. Parses the JSON + * body if present, maps HTTP status to a stable error code, and pulls + * request-id + retry-after from headers. + */ +export async function parseErrorResponse(response: Response): Promise { + const status = response.status; + let parsed: unknown = null; + try { + parsed = await response.json(); + } catch { + parsed = null; + } + + const message = extractMessage(parsed) ?? `HTTP ${status}`; + const name = pickErrorCode(status, parsed); + const requestId = + response.headers.get('x-request-id') ?? response.headers.get('X-Request-Id') ?? undefined; + + const error: ErrorResponse = { + name, + message, + statusCode: status, + }; + if (requestId) error.requestId = requestId; + + // Retry-After is most common on 429 but RFC 7231 §7.1.3 also allows + // it on 503 / 502 / 504 — surface whenever the server sends it. + const retryAfterHeader = response.headers.get('retry-after'); + if (retryAfterHeader) { + const seconds = parseRetryAfter(retryAfterHeader); + if (seconds !== null) error.retryAfter = seconds; + } + + const details = extractDetails(parsed); + if (details) error.details = details; + + return error; +} + +export function connectionError(err: unknown): ErrorResponse { + return { + name: 'connection_error', + message: err instanceof Error ? err.message : 'Network error', + statusCode: null, + }; +} + +export function timeoutError(): ErrorResponse { + return { + name: 'timeout_error', + message: 'Request timed out.', + statusCode: null, + }; +} + +function extractMessage(body: unknown): string | null { + if (!body || typeof body !== 'object') return null; + const b = body as Record; + if (typeof b.message === 'string') return b.message; + if (b.error && typeof b.error === 'object') { + const inner = b.error as Record; + if (typeof inner.message === 'string') return inner.message; + } + if (typeof b.error === 'string') return b.error; + return null; +} + +function extractDetails(body: unknown): Record | undefined { + if (!body || typeof body !== 'object') return undefined; + const b = body as Record; + if (b.error && typeof b.error === 'object') { + const inner = b.error as Record; + if (inner.details && typeof inner.details === 'object') { + return inner.details as Record; + } + } + return undefined; +} + +function pickErrorCode(status: number, body: unknown): OMOPHUB_ERROR_CODE_KEY { + // Server-supplied codes are more specific than the generic HTTP-status + // bucket — e.g. a 400 with `error.code: 'missing_required_field'` should + // surface that, not the generic `validation_error`. Check body first. + if (body && typeof body === 'object') { + const b = body as Record; + const inner = + b.error && typeof b.error === 'object' ? (b.error as Record) : null; + const candidate = inner?.code ?? inner?.name; + if (typeof candidate === 'string' && KNOWN_CODES.has(candidate)) { + return candidate as OMOPHUB_ERROR_CODE_KEY; + } + } + + const mapped = STATUS_TO_CODE[status]; + if (mapped) return mapped; + + if (status >= 500 && status < 600) { + return status === 503 ? 'service_unavailable' : 'internal_server_error'; + } + + return 'application_error'; +} diff --git a/src/common/utils/sleep.ts b/src/common/utils/sleep.ts new file mode 100644 index 0000000..c31b43c --- /dev/null +++ b/src/common/utils/sleep.ts @@ -0,0 +1,21 @@ +/** + * Promise-based sleep. Resolves after `ms` milliseconds. If a signal is + * provided and aborted, rejects with the signal's reason and clears the timer. + */ +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + const timer = setTimeout(() => { + if (signal) signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + if (signal) signal.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/src/common/utils/synthetic-error.ts b/src/common/utils/synthetic-error.ts new file mode 100644 index 0000000..bb9cccb --- /dev/null +++ b/src/common/utils/synthetic-error.ts @@ -0,0 +1,21 @@ +import type { ErrorResponse, OMOPHUB_ERROR_CODE_KEY, Response } from '../../interfaces.js'; + +/** + * Builds a `Response` whose error came from the SDK itself rather than + * the wire — used by resources to short-circuit before issuing a request + * (size caps, XOR validation, etc.). The shape matches what a real API + * error would look like so callers have one error path regardless of source. + * + * `statusCode` is null because no HTTP exchange occurred, and `headers` is + * null for the same reason — distinguishing wire errors (have headers) from + * synthetic ones (don't) without forcing callers to care. + */ +export function syntheticError( + name: OMOPHUB_ERROR_CODE_KEY, + message: string, + details?: Record, +): Response { + const error: ErrorResponse = { name, message, statusCode: null }; + if (details) error.details = details; + return { data: null, error, meta: null, headers: null }; +} diff --git a/src/common/utils/to-snake-case.ts b/src/common/utils/to-snake-case.ts new file mode 100644 index 0000000..0f6e32f --- /dev/null +++ b/src/common/utils/to-snake-case.ts @@ -0,0 +1,54 @@ +/** + * Converts a camelCase string to snake_case. Used at the request boundary + * to translate option-object keys into wire-format keys. + * + * Treats consecutive uppercase runs as a single acronym so that + * `FHIRResource` → `fhir_resource` and `XMLHttpRequest` → `xml_http_request` + * rather than splitting between each letter. Trailing uppercase runs are + * also preserved (`userID` → `user_id`). + * + * A single uppercase letter followed by another uppercase is treated as a + * separate word (`OAuthToken` → `o_auth_token`) — matches `lodash.snakeCase`. + */ +export function camelToSnakeCase(input: string): string { + return input + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .toLowerCase(); +} + +/** + * Recursively converts an object's keys from camelCase to snake_case. + * Arrays are mapped element-wise; primitives and non-plain objects + * (Date, Map, Set, class instances, etc.) pass through unchanged so we + * don't silently flatten them to `{}`. Used for request bodies only — + * responses stay snake_case to match the API. + */ +export function toSnakeCaseKeys(input: T): T { + if (Array.isArray(input)) { + return input.map((item) => toSnakeCaseKeys(item)) as unknown as T; + } + if (isPlainObject(input)) { + // Null-prototype accumulator: prevents `__proto__` (and `constructor`, + // `prototype`) keys in the source from mutating the result's prototype. + // Plain `{}` would trigger Object.prototype's `__proto__` setter on + // bracket assignment. + const out: Record = Object.create(null); + for (const [k, v] of Object.entries(input)) { + out[camelToSnakeCase(k)] = toSnakeCaseKeys(v); + } + return out as T; + } + return input; +} + +/** + * Plain object = `{}` literal or `Object.create(null)`. Anything with a + * non-Object prototype (Date, Buffer, URL, Map, Set, user classes) is + * excluded so its data is preserved as-is. + */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') return false; + const proto = Object.getPrototypeOf(value); + return proto === null || proto === Object.prototype; +} diff --git a/src/common/utils/unwrap-envelope.ts b/src/common/utils/unwrap-envelope.ts new file mode 100644 index 0000000..075a567 --- /dev/null +++ b/src/common/utils/unwrap-envelope.ts @@ -0,0 +1,46 @@ +import type { ResponseMeta } from '../../interfaces.js'; + +export interface UnwrappedEnvelope { + data: T; + meta: ResponseMeta | null; +} + +/** + * Tolerantly extracts payload and metadata from a 2xx response body. + * + * The OMOPHub API returns `{ success, data, meta }` for most endpoints + * but a few legacy paths return the data payload directly. To avoid + * mis-classifying a user payload that happens to have a top-level `data` + * field (e.g. a paginated result), the heuristic requires a second + * envelope-specific key — `success` or `meta` — to co-occur with `data`. + * + * - Envelope (`{ data, success }` or `{ data, meta }` or `{ data, success, meta }`): + * unwrap to `body.data` and extract `body.meta`. + * - Anything else: treat the body as the payload (no meta). + * + * Caller-side error envelopes (`success: false`) are not expected here — + * the dispatch loop routes 4xx/5xx through `parseErrorResponse` first. + */ +export function unwrapEnvelope(body: unknown): UnwrappedEnvelope { + if (body && typeof body === 'object' && 'data' in body && ('success' in body || 'meta' in body)) { + const envelope = body as { data: unknown; meta?: unknown }; + return { + data: envelope.data as T, + meta: extractMeta(envelope.meta), + }; + } + return { data: body as T, meta: null }; +} + +function extractMeta(raw: unknown): ResponseMeta | null { + if (!raw || typeof raw !== 'object') return null; + const m = raw as Record; + const meta: ResponseMeta = {}; + if (typeof m.request_id === 'string') meta.request_id = m.request_id; + if (typeof m.timestamp === 'string') meta.timestamp = m.timestamp; + if (typeof m.vocab_release === 'string') meta.vocab_release = m.vocab_release; + if (m.pagination && typeof m.pagination === 'object') { + meta.pagination = m.pagination as ResponseMeta['pagination']; + } + return Object.keys(meta).length > 0 ? meta : null; +} diff --git a/src/concepts/concepts.ts b/src/concepts/concepts.ts new file mode 100644 index 0000000..5209e1b --- /dev/null +++ b/src/concepts/concepts.ts @@ -0,0 +1,208 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { PaginatedData } from '../common/interfaces/pagination.js'; +import type { PostOptions } from '../common/interfaces/post-options.js'; +import { syntheticError } from '../common/utils/synthetic-error.js'; +import { toSnakeCaseKeys } from '../common/utils/to-snake-case.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { BatchConceptsOptions } from './interfaces/batch-concepts-options.js'; +import type { + BatchConceptResult, + Concept, + ConceptRelationshipsResult, + ConceptSuggestion, + RecommendedConceptsResult, + RelatedConceptsResult, +} from './interfaces/concept.js'; +import type { ConceptRelationshipsOptions } from './interfaces/concept-relationships-options.js'; +import type { GetConceptByCodeOptions } from './interfaces/get-concept-by-code-options.js'; +import type { GetConceptOptions } from './interfaces/get-concept-options.js'; +import type { RecommendedConceptsOptions } from './interfaces/recommended-concepts-options.js'; +import type { RelatedConceptsOptions } from './interfaces/related-concepts-options.js'; +import type { SuggestConceptsOptions } from './interfaces/suggest-concepts-options.js'; + +export class Concepts { + constructor(private readonly client: OMOPHub) {} + + /** + * Fetch a single concept by its OMOP `concept_id`. + * + * Accepts `conceptId === 0` (the OMOP "unmapped" sentinel) — the SDK + * does not pre-validate. The server is the source of truth on what's + * a valid ID. + * + * @see https://docs.omophub.com/api-reference/concepts/get + */ + async get( + conceptId: number, + options: GetConceptOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * Fetch a concept by its native vocabulary code (e.g. SNOMED `44054006`). + * + * @see https://docs.omophub.com/api-reference/concepts/get-by-code + */ + async getByCode( + vocabularyId: string, + conceptCode: string, + options: GetConceptByCodeOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get( + `/concepts/by-code/${encodeURIComponent(vocabularyId)}/${encodeURIComponent(conceptCode)}`, + { signal, headers, query: { ...flags, ...query } }, + ); + } + + /** + * Fetch up to 100 concepts in a single request. + * + * Returns a `validation_error` synthetically (no network call) if + * `conceptIds` is missing, not an array, or outside `[1, 100]`. + * The `Array.isArray` check defends against JS callers / `// @ts-expect-error` + * users — TS strict mode catches it at compile time. + */ + async batch( + options: BatchConceptsOptions & PostOptions, + ): Promise> { + const { conceptIds, signal, headers, query, idempotencyKey, ...rest } = options; + if (!Array.isArray(conceptIds) || conceptIds.length < 1 || conceptIds.length > 100) { + return syntheticError( + 'validation_error', + '`conceptIds` must be an array of 1–100 items.', + ); + } + const body = toSnakeCaseKeys({ conceptIds, ...rest }); + return this.client.post('/concepts/batch', body, { + signal, + headers, + query, + idempotencyKey, + }); + } + + /** + * Auto-complete suggestions for a free-text query. + * + * `query` is positional so it doesn't collide with `PerCallOptions.query`. + * The positional value is spread LAST so a stray `query` key inside + * `options.query` (the escape-hatch params map) cannot silently override + * the method's primary argument. + */ + async suggest( + query: string, + options: SuggestConceptsOptions & GetOptions = {}, + ): Promise>> { + const { signal, headers, query: extraQuery, ...flags } = options; + return this.client.get>( + '/concepts/suggest', + { signal, headers, query: { ...flags, ...extraQuery, query } }, + ); + } + + /** + * Phoebe-style related concepts ranked by relatedness score. + */ + async related( + conceptId: number, + options: RelatedConceptsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/related`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * Concept-centric view of `GET /concepts/{id}/relationships`. Shares the + * underlying endpoint with `client.relationships.get(conceptId)` — keep + * the two in sync as new query params are added. + */ + async relationships( + conceptId: number, + options: ConceptRelationshipsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/relationships`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * OHDSI Phoebe-style recommendations for one or more source concepts. + * + * Validates client-side: `conceptIds` 1–100, `relationshipTypes` ≤ 20, + * `vocabularyIds`/`domainIds` ≤ 50 — returns `validation_error` + * synthetically when any cap is exceeded. + */ + async recommended( + options: RecommendedConceptsOptions & PostOptions, + ): Promise> { + const { + conceptIds, + relationshipTypes, + vocabularyIds, + domainIds, + standardOnly, + includeInvalid, + page, + pageSize, + signal, + headers, + query, + idempotencyKey, + } = options; + + if (!Array.isArray(conceptIds) || conceptIds.length < 1 || conceptIds.length > 100) { + return syntheticError( + 'validation_error', + '`conceptIds` must be an array of 1–100 items.', + ); + } + if (relationshipTypes && relationshipTypes.length > 20) { + return syntheticError( + 'validation_error', + '`relationshipTypes` must contain at most 20 entries.', + ); + } + if (vocabularyIds && vocabularyIds.length > 50) { + return syntheticError( + 'validation_error', + '`vocabularyIds` must contain at most 50 entries.', + ); + } + if (domainIds && domainIds.length > 50) { + return syntheticError( + 'validation_error', + '`domainIds` must contain at most 50 entries.', + ); + } + + const body = toSnakeCaseKeys({ + conceptIds, + relationshipTypes, + vocabularyIds, + domainIds, + standardOnly, + includeInvalid, + }); + return this.client.post('/concepts/recommended', body, { + signal, + headers, + query: { page, page_size: pageSize, ...query }, + idempotencyKey, + }); + } +} diff --git a/src/concepts/interfaces/batch-concepts-options.ts b/src/concepts/interfaces/batch-concepts-options.ts new file mode 100644 index 0000000..928b0e6 --- /dev/null +++ b/src/concepts/interfaces/batch-concepts-options.ts @@ -0,0 +1,11 @@ +export interface BatchConceptsOptions { + /** 1–100 concept IDs to look up in a single request. */ + conceptIds: number[]; + includeRelationships?: boolean; + includeSynonyms?: boolean; + includeMappings?: boolean; + /** Restrict returned concepts to a list of vocabulary IDs. */ + vocabularyFilter?: string[]; + /** When true, returns only `standard_concept = 'S'` rows. Defaults true at the API. */ + standardOnly?: boolean; +} diff --git a/src/concepts/interfaces/concept-relationships-options.ts b/src/concepts/interfaces/concept-relationships-options.ts new file mode 100644 index 0000000..d8dc445 --- /dev/null +++ b/src/concepts/interfaces/concept-relationships-options.ts @@ -0,0 +1,22 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; +import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; + +/** + * Options for `concepts.relationships(conceptId, ...)`. + * + * Shares the wire endpoint `GET /concepts/{id}/relationships` with + * `relationships.get(conceptId, ...)`. Kept as a separate method so users + * who think in concept-centric terms have a discoverable entry point. + * + * Field list must stay aligned with `GetRelationshipsOptions` (in + * `relationships/interfaces/`) since both methods hit the same endpoint — + * a parity test in `relationships.test.ts` enforces equivalent wire URLs. + */ +export interface ConceptRelationshipsOptions extends PaginationOptions, VocabReleaseMixin { + relationshipIds?: string[]; + vocabularyIds?: string[]; + domainIds?: string[]; + includeInvalid?: boolean; + standardOnly?: boolean; + includeReverse?: boolean; +} diff --git a/src/concepts/interfaces/concept.ts b/src/concepts/interfaces/concept.ts new file mode 100644 index 0000000..85110bc --- /dev/null +++ b/src/concepts/interfaces/concept.ts @@ -0,0 +1,183 @@ +/** + * OMOP concept payload shapes. Field names are snake_case to match the + * wire format exactly — no client-side translation on response bodies. + * + * Shapes are derived from live-API inspection during the e2e validation + * pass; nested fields like `relationships` and `hierarchy` only appear + * when the matching `include_*` flag is set on the request. + */ + +export interface Synonym { + concept_synonym_name: string; + language_concept_id?: number; +} + +export interface ConceptSummary { + concept_id: number; + concept_name: string; + vocabulary_id: string; + concept_code: string; +} + +/** + * Concept payload nested inside a `ConceptRelationship` row under + * `concept_1` / `concept_2`. Slightly richer than `ConceptSummary` — + * the relationships endpoint embeds the full concept metadata. + */ +export interface RelationshipConcept extends ConceptSummary { + vocabulary_name?: string; + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; + valid_start_date?: string; + valid_end_date?: string; + invalid_reason?: string | null; +} + +/** + * Full relationship row returned by the dedicated relationships + * endpoints (`GET /concepts/{id}/relationships`, `relationships.get(id)`, + * `concepts.relationships(id)`). + * + * **Wire shape uses `concept_1` / `concept_2`** — NOT a flat `target_*` + * projection. For an `aspirin → Halfprin` "Has brand name" row, + * `concept_1` is aspirin (the queried side) and `concept_2` is Halfprin + * (the related side). + */ +export interface ConceptRelationship { + concept_id_1: number; + concept_id_2: number; + relationship_id: string; + relationship_name?: string; + reverse_relationship_id?: string; + concept_1?: RelationshipConcept; + concept_2?: RelationshipConcept; + valid_start_date?: string; + valid_end_date?: string; + invalid_reason?: string | null; +} + +/** + * Compact relationship row attached to `Concept.relationships.parents` + * and `.children` when `concepts.get(id, { includeRelationships: true })` + * is called. Simpler than `ConceptRelationship` — only carries the + * target concept ID, name, and relationship type. + */ +export interface ConceptRelationshipNode { + concept_id: number; + concept_name: string; + relationship_id: string; +} + +export interface ConceptHierarchyNode { + concept_id: number; + concept_name: string; + vocabulary_id: string; + concept_code: string; + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; + level?: number; + min_levels_of_separation?: number; + max_levels_of_separation?: number; +} + +export interface Concept extends ConceptSummary { + domain_id: string; + concept_class_id: string; + standard_concept: 'S' | 'C' | 'N' | null; + valid_start_date: string; + valid_end_date: string; + invalid_reason?: string | null; + /** Server-flagged boolean equivalent to `invalid_reason === null`. */ + is_valid?: boolean; + /** Server-flagged boolean equivalent to `standard_concept === 'S'`. */ + is_standard?: boolean; + /** Server-flagged boolean equivalent to `standard_concept === 'C'`. */ + is_classification?: boolean; + synonyms?: Synonym[]; + /** + * Populated when `includeRelationships: true`. The server returns a + * `{ parents, children }` shape — NOT a flat `ConceptRelationship[]`. + * Use `relationships.get(conceptId)` for the richer paginated view. + */ + relationships?: { + parents?: ConceptRelationshipNode[]; + children?: ConceptRelationshipNode[]; + }; + hierarchy?: { + ancestors?: ConceptHierarchyNode[]; + descendants?: ConceptHierarchyNode[]; + }; +} + +/** + * `concepts.related(id)` result row. Each item is a related concept + * with relationship metadata (`relationship_id`, `relationship_score`, + * `relationship_distance`). + */ +export interface RelatedConcept extends ConceptSummary { + vocabulary_name?: string; + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; + relationship_id: string; + relationship_name?: string; + relationship_score?: number; + relationship_distance?: number; +} + +export interface BatchConceptResult { + concepts: Concept[]; + failed_concepts?: { concept_id: number; reason?: string }[]; + summary?: { + total?: number; + found?: number; + failed?: number; + }; +} + +export interface ConceptSuggestion { + suggestion: string; + type: string; + match_type: string; + match_score: number; + concept_id: number; + vocabulary_id: string; +} + +/** + * `concepts.related` returns a bare array of related concepts. + * Aliased as a type so the resource method's return looks named. + */ +export type RelatedConceptsResult = RelatedConcept[]; + +export interface ConceptRelationshipsResult { + relationships: ConceptRelationship[]; +} + +/** + * Single entry in a `concepts.recommended` result group. + */ +export interface RecommendedConceptEntry extends ConceptSummary { + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; + invalid_reason?: string | null; + valid_start_date?: string; + valid_end_date?: string; + relationship_id?: string; +} + +/** + * `concepts.recommended` groups its output **keyed by source concept ID** + * (as a string, per the JSON shape). Iterate via `Object.entries(data)`. + * + * ```ts + * const { data } = await client.concepts.recommended({ conceptIds: [201826] }); + * for (const [sourceId, entries] of Object.entries(data ?? {})) { + * console.log(`Recommendations for ${sourceId}:`, entries); + * } + * ``` + */ +export type RecommendedConceptsResult = Record; diff --git a/src/concepts/interfaces/get-concept-by-code-options.ts b/src/concepts/interfaces/get-concept-by-code-options.ts new file mode 100644 index 0000000..8897301 --- /dev/null +++ b/src/concepts/interfaces/get-concept-by-code-options.ts @@ -0,0 +1,9 @@ +import type { GetConceptOptions } from './get-concept-options.js'; + +/** + * `get` and `getByCode` accept the same optional flags — the only + * difference is how the concept is identified (numeric ID vs. + * vocabulary+code). Aliased rather than duplicated so any new option + * added to `GetConceptOptions` automatically propagates here. + */ +export type GetConceptByCodeOptions = GetConceptOptions; diff --git a/src/concepts/interfaces/get-concept-options.ts b/src/concepts/interfaces/get-concept-options.ts new file mode 100644 index 0000000..a771d22 --- /dev/null +++ b/src/concepts/interfaces/get-concept-options.ts @@ -0,0 +1,7 @@ +import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; + +export interface GetConceptOptions extends VocabReleaseMixin { + includeRelationships?: boolean; + includeSynonyms?: boolean; + includeHierarchy?: boolean; +} diff --git a/src/concepts/interfaces/index.ts b/src/concepts/interfaces/index.ts new file mode 100644 index 0000000..2a22877 --- /dev/null +++ b/src/concepts/interfaces/index.ts @@ -0,0 +1,23 @@ +export type { BatchConceptsOptions } from './batch-concepts-options.js'; +export type { + BatchConceptResult, + Concept, + ConceptHierarchyNode, + ConceptRelationship, + ConceptRelationshipNode, + ConceptRelationshipsResult, + ConceptSuggestion, + ConceptSummary, + RecommendedConceptEntry, + RecommendedConceptsResult, + RelatedConcept, + RelatedConceptsResult, + RelationshipConcept, + Synonym, +} from './concept.js'; +export type { ConceptRelationshipsOptions } from './concept-relationships-options.js'; +export type { GetConceptByCodeOptions } from './get-concept-by-code-options.js'; +export type { GetConceptOptions } from './get-concept-options.js'; +export type { RecommendedConceptsOptions } from './recommended-concepts-options.js'; +export type { RelatedConceptsOptions } from './related-concepts-options.js'; +export type { SuggestConceptsOptions } from './suggest-concepts-options.js'; diff --git a/src/concepts/interfaces/recommended-concepts-options.ts b/src/concepts/interfaces/recommended-concepts-options.ts new file mode 100644 index 0000000..39e6878 --- /dev/null +++ b/src/concepts/interfaces/recommended-concepts-options.ts @@ -0,0 +1,15 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +/** + * OHDSI Phoebe-style concept recommendations. Server-side caps: + * `conceptIds` 1–100, `relationshipTypes` ≤ 20, `vocabularyIds`/`domainIds` ≤ 50. + */ +export interface RecommendedConceptsOptions extends PaginationOptions { + conceptIds: number[]; + relationshipTypes?: string[]; + vocabularyIds?: string[]; + domainIds?: string[]; + /** Defaults true at the API — restrict candidates to standard concepts. */ + standardOnly?: boolean; + includeInvalid?: boolean; +} diff --git a/src/concepts/interfaces/related-concepts-options.ts b/src/concepts/interfaces/related-concepts-options.ts new file mode 100644 index 0000000..e172fb7 --- /dev/null +++ b/src/concepts/interfaces/related-concepts-options.ts @@ -0,0 +1,9 @@ +import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; + +export interface RelatedConceptsOptions extends VocabReleaseMixin { + relationshipTypes?: string[]; + /** Filter results by minimum relatedness score (0–1). */ + minScore?: number; + /** Default 20 at the API; max 100. */ + pageSize?: number; +} diff --git a/src/concepts/interfaces/suggest-concepts-options.ts b/src/concepts/interfaces/suggest-concepts-options.ts new file mode 100644 index 0000000..e08110f --- /dev/null +++ b/src/concepts/interfaces/suggest-concepts-options.ts @@ -0,0 +1,12 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; +import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; + +/** + * Note: the free-text `query` is the first positional argument to + * `concepts.suggest()` — it would otherwise collide with + * `PerCallOptions.query` (the escape-hatch query-param record). + */ +export interface SuggestConceptsOptions extends PaginationOptions, VocabReleaseMixin { + vocabularyIds?: string[]; + domainIds?: string[]; +} diff --git a/src/domains/domains.ts b/src/domains/domains.ts new file mode 100644 index 0000000..85bd8a1 --- /dev/null +++ b/src/domains/domains.ts @@ -0,0 +1,49 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { DomainConceptsResult, ListDomainsResult } from './interfaces/domain.js'; +import type { DomainConceptsOptions } from './interfaces/domain-concepts-options.js'; +import type { ListDomainsOptions } from './interfaces/list-domains-options.js'; + +export class Domains { + constructor(private readonly client: OMOPHub) {} + + /** + * List all OMOP domains. Distinct from `vocabularies.domains()` — + * this endpoint (`/domains`) returns the canonical domain catalog, + * while the vocabularies version returns domains scoped to vocabulary + * usage. Wrapped under `domains`. + * + * Not paginated server-side (Python and R SDKs both call this endpoint + * without page/page_size). Returns the full catalog in a single call. + * + * @see https://docs.omophub.com/api-reference/domains/list + */ + async list( + options: ListDomainsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get('/domains', { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * List the concepts that belong to a specific domain. Wrapped under + * `concepts`; pagination metadata on outer `Response.meta`. + * + * @see https://docs.omophub.com/api-reference/domains/concepts + */ + async concepts( + domainId: string, + options: DomainConceptsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get( + `/domains/${encodeURIComponent(domainId)}/concepts`, + { signal, headers, query: { ...flags, ...query } }, + ); + } +} diff --git a/src/domains/interfaces/domain-concepts-options.ts b/src/domains/interfaces/domain-concepts-options.ts new file mode 100644 index 0000000..61c3754 --- /dev/null +++ b/src/domains/interfaces/domain-concepts-options.ts @@ -0,0 +1,8 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface DomainConceptsOptions extends PaginationOptions { + vocabularyIds?: string[]; + /** When true, returns only `standard_concept = 'S'` rows. */ + standardOnly?: boolean; + includeInvalid?: boolean; +} diff --git a/src/domains/interfaces/domain.ts b/src/domains/interfaces/domain.ts new file mode 100644 index 0000000..ed25f9b --- /dev/null +++ b/src/domains/interfaces/domain.ts @@ -0,0 +1,38 @@ +export interface DomainStats { + total_concepts: number; + standard_concepts?: number; + classification_concepts?: number; + vocabulary_distribution?: Record; + concept_class_distribution?: Record; + growth_trend?: Record; + usage_frequency?: number; + relationship_density?: number; +} + +export interface DomainSummary { + domain_id: string; + domain_name: string; + domain_concept_id?: number; +} + +export interface Domain extends DomainSummary { + stats?: DomainStats; + category?: string; + description?: string; +} + +/** + * `GET /domains` returns the catalog wrapped under `domains` (not a bare + * array). The endpoint is not paginated server-side. + */ +export interface ListDomainsResult { + domains: Domain[]; +} + +/** + * `GET /domains/{id}/concepts` — concepts wrapped under `concepts`, + * pagination on outer `Response.meta`. + */ +export interface DomainConceptsResult { + concepts: import('../../concepts/interfaces/concept.js').ConceptSummary[]; +} diff --git a/src/domains/interfaces/index.ts b/src/domains/interfaces/index.ts new file mode 100644 index 0000000..0cf207b --- /dev/null +++ b/src/domains/interfaces/index.ts @@ -0,0 +1,9 @@ +export type { + Domain, + DomainConceptsResult, + DomainStats, + DomainSummary, + ListDomainsResult, +} from './domain.js'; +export type { DomainConceptsOptions } from './domain-concepts-options.js'; +export type { ListDomainsOptions } from './list-domains-options.js'; diff --git a/src/domains/interfaces/list-domains-options.ts b/src/domains/interfaces/list-domains-options.ts new file mode 100644 index 0000000..17a1ca3 --- /dev/null +++ b/src/domains/interfaces/list-domains-options.ts @@ -0,0 +1,4 @@ +export interface ListDomainsOptions { + /** Embed per-domain statistics in each result. */ + includeStats?: boolean; +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..3b4c84b --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,30 @@ +import type { OMOPHUB_ERROR_CODE_KEY } from './interfaces.js'; + +/** + * Thrown only on SDK misuse (e.g. missing API key at construction time). + * Network failures and API errors return through the discriminated + * `Response` shape — they are never thrown. + */ +export class OMOPHubError extends Error { + constructor(message: string) { + super(message); + this.name = 'OMOPHubError'; + } +} + +/** + * Thrown from async iterators when a page fetch fails. Async generators + * cannot gracefully yield a discriminated `{ data, error }` union, so + * iterators throw and `*All` variants accumulate. + */ +export class OMOPHubIteratorError extends Error { + readonly statusCode: number | null; + readonly code: OMOPHUB_ERROR_CODE_KEY; + + constructor(message: string, statusCode: number | null, code: OMOPHUB_ERROR_CODE_KEY) { + super(message); + this.name = 'OMOPHubIteratorError'; + this.statusCode = statusCode; + this.code = code; + } +} diff --git a/src/fhir/fhir-url.ts b/src/fhir/fhir-url.ts new file mode 100644 index 0000000..ee67c3f --- /dev/null +++ b/src/fhir/fhir-url.ts @@ -0,0 +1,18 @@ +export type FhirVersion = 'r4' | 'r4b' | 'r5' | 'r6'; + +/** + * Returns the URL of OMOPHub's hosted FHIR Terminology Service for the + * given version. No client / API key required — this is a pointer for + * users who want to hit the raw FHIR endpoints (CodeSystem/$lookup, + * ValueSet/$expand, etc.) with their own FHIR client library. + * + * ```ts + * import { omophubFhirUrl } from '@omophub/omophub-node'; + * + * const base = omophubFhirUrl('r4'); + * // → https://fhir.omophub.com/fhir/r4 + * ``` + */ +export function omophubFhirUrl(version: FhirVersion = 'r4'): string { + return `https://fhir.omophub.com/fhir/${version}`; +} diff --git a/src/fhir/fhir.ts b/src/fhir/fhir.ts new file mode 100644 index 0000000..b849d08 --- /dev/null +++ b/src/fhir/fhir.ts @@ -0,0 +1,143 @@ +import type { OMOPHub } from '../client.js'; +import type { PostOptions } from '../common/interfaces/post-options.js'; +import { syntheticError } from '../common/utils/synthetic-error.js'; +import { toSnakeCaseKeys } from '../common/utils/to-snake-case.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { + Coding, + FhirBatchResult, + FhirCodeableConceptResult, + FhirResolveResult, +} from './interfaces/fhir.js'; +import type { ResolveBatchOptions } from './interfaces/resolve-batch-options.js'; +import type { ResolveCodeableConceptOptions } from './interfaces/resolve-codeable-concept-options.js'; +import type { ResolveOptions } from './interfaces/resolve-options.js'; + +export class Fhir { + constructor(private readonly client: OMOPHub) {} + + /** + * Resolve a single FHIR coding to its OMOP standard concept. + * + * Accepts two equivalent input forms: + * + * ```ts + * // Flat form: + * client.fhir.resolve({ system: 'http://snomed.info/sct', code: '44054006' }); + * + * // Nested-coding form: + * client.fhir.resolve({ coding: { system: 'http://snomed.info/sct', code: '44054006' } }); + * ``` + * + * Flat fields take precedence when both forms are supplied. + * + * @see https://docs.omophub.com/api-reference/fhir/resolve + */ + async resolve( + options: ResolveOptions & PostOptions, + ): Promise> { + const hasCodingObj = + options.coding !== undefined && + options.coding !== null && + typeof options.coding === 'object' && + !Array.isArray(options.coding) && + typeof options.coding.code === 'string' && + options.coding.code.length > 0; + const hasFlatCode = typeof options.code === 'string' && options.code.length > 0; + // The server also accepts text-only input — display text alone triggers + // a semantic-search fallback to the best-matching standard concept. + // Display can sit at the top level (`{ display }`) or nested inside a + // coding object (`{ coding: { display } }`); both are legal FHIR. + const flatDisplay = typeof options.display === 'string' && options.display.length > 0; + const codingDisplay = + options.coding !== undefined && + options.coding !== null && + typeof options.coding === 'object' && + !Array.isArray(options.coding) && + typeof options.coding.display === 'string' && + options.coding.display.length > 0; + const hasDisplay = flatDisplay || codingDisplay; + if (!hasCodingObj && !hasFlatCode && !hasDisplay) { + return syntheticError( + 'missing_required_field', + 'Provide a `coding` object with a `code`, a flat `code`, or `display` text for semantic fallback.', + ); + } + + const { signal, headers, query, idempotencyKey, coding, ...flatFields } = options; + // Merge order: coding first, flat overrides — lets callers spread a + // FHIR object and then patch specific fields. + const merged = coding ? { ...coding, ...stripUndefined(flatFields) } : flatFields; + const body = toSnakeCaseKeys(merged); + return this.client.post('/fhir/resolve', body, { + signal, + headers, + query, + idempotencyKey, + }); + } + + /** + * Resolve a batch of up to 100 FHIR codings in a single request. + * + * @see https://docs.omophub.com/api-reference/fhir/resolve-batch + */ + async resolveBatch( + codings: Coding[], + options: ResolveBatchOptions & PostOptions = {}, + ): Promise> { + if (!Array.isArray(codings) || codings.length < 1 || codings.length > 100) { + return syntheticError( + 'validation_error', + '`codings` must be an array of 1–100 entries.', + ); + } + const { signal, headers, query, idempotencyKey, ...rest } = options; + const body = toSnakeCaseKeys({ codings, ...rest }); + return this.client.post('/fhir/resolve/batch', body, { + signal, + headers, + query, + idempotencyKey, + }); + } + + /** + * Resolve a FHIR `CodeableConcept` (up to 20 codings) by picking the + * best-matching OMOP standard concept across the supplied codings. + * + * @see https://docs.omophub.com/api-reference/fhir/resolve-codeable-concept + */ + async resolveCodeableConcept( + coding: Coding[], + options: ResolveCodeableConceptOptions & PostOptions = {}, + ): Promise> { + if (!Array.isArray(coding) || coding.length < 1 || coding.length > 20) { + return syntheticError( + 'validation_error', + '`coding` must be an array of 1–20 entries.', + ); + } + const { signal, headers, query, idempotencyKey, ...rest } = options; + const body = toSnakeCaseKeys({ coding, ...rest }); + return this.client.post('/fhir/resolve/codeable-concept', body, { + signal, + headers, + query, + idempotencyKey, + }); + } +} + +/** + * Strips keys whose value is `undefined`. Used when merging flat fields + * onto a coding object so an unset `system` doesn't blow away the + * coding's `system`. + */ +function stripUndefined>(obj: T): Partial { + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined) out[k] = v; + } + return out as Partial; +} diff --git a/src/fhir/interfaces/fhir.ts b/src/fhir/interfaces/fhir.ts new file mode 100644 index 0000000..2fd7f0e --- /dev/null +++ b/src/fhir/interfaces/fhir.ts @@ -0,0 +1,86 @@ +/** + * FHIR `Coding` shape, structurally compatible with the FHIR JSON spec + * (camelCase). The SDK converts to snake_case (`user_selected`, + * `vocabulary_id`) at the wire boundary via `toSnakeCaseKeys`. + */ +export interface Coding { + system?: string; + code?: string; + display?: string; + /** Whether this coding was explicitly chosen by the user (FHIR spec). */ + userSelected?: boolean; + /** OMOPHub extension — pin the OMOP vocabulary_id rather than letting + * the API infer it from `system`. */ + vocabularyId?: string; +} + +export interface CodeableConcept { + coding?: Coding[]; + text?: string; +} + +/** + * One row of the OMOP concept table as returned by the FHIR resolver. + * Snake_case to match the wire. + */ +export interface ResolvedConcept { + concept_id: number; + concept_name: string; + vocabulary_id: string; + concept_code: string; + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; +} + +export interface RecommendedConceptOutput extends ResolvedConcept { + similarity_score?: number; + relationship_type?: string; +} + +export interface FhirResolution { + source_concept: ResolvedConcept; + standard_concept: ResolvedConcept; + value_as_concept?: ResolvedConcept; + value_target_field?: string; + mapping_type: string; + target_table: string; + domain_resource_alignment?: string; + similarity_score?: number; + /** Quality bucket: typically `'high'`, `'medium'`, `'low'`, or `'manual_review'`. */ + mapping_quality?: string; + quality_note?: string; + alternative_standard_concepts?: ResolvedConcept[]; + recommendations?: RecommendedConceptOutput[]; + concept_map_id?: string; + mapping_note?: string; +} + +export interface FhirResolveResult { + input: Record; + resolution: FhirResolution; +} + +export interface FhirBatchSummary { + total: number; + resolved: number; + failed: number; +} + +export interface FhirBatchResult { + results: FhirResolveResult[]; + summary: FhirBatchSummary; +} + +export interface FhirCodeableConceptResult { + input: Record; + /** + * The best-matching coding resolved to a standard concept. Wrapped as + * `{ input, resolution }` — the same shape `fhir.resolve()` returns — + * because the server reports both the original coding and the resolution + * details per-pick. + */ + best_match?: FhirResolveResult; + alternatives: FhirResolveResult[]; + unresolved: Record[]; +} diff --git a/src/fhir/interfaces/index.ts b/src/fhir/interfaces/index.ts new file mode 100644 index 0000000..db1ca73 --- /dev/null +++ b/src/fhir/interfaces/index.ts @@ -0,0 +1,15 @@ +export type { + CodeableConcept, + Coding, + FhirBatchResult, + FhirBatchSummary, + FhirCodeableConceptResult, + FhirResolution, + FhirResolveResult, + RecommendedConceptOutput, + ResolvedConcept, +} from './fhir.js'; +export type { ResolveBatchOptions } from './resolve-batch-options.js'; +export type { ResolveCodeableConceptOptions } from './resolve-codeable-concept-options.js'; +export type { ResolveCommonOptions } from './resolve-common-options.js'; +export type { ResolveOptions } from './resolve-options.js'; diff --git a/src/fhir/interfaces/resolve-batch-options.ts b/src/fhir/interfaces/resolve-batch-options.ts new file mode 100644 index 0000000..61efb0f --- /dev/null +++ b/src/fhir/interfaces/resolve-batch-options.ts @@ -0,0 +1,8 @@ +import type { ResolveCommonOptions } from './resolve-common-options.js'; + +/** + * Options for `fhir.resolveBatch(codings, options)`. All fields are + * inherited from `ResolveCommonOptions` — `recommendationsLimit` is + * applied **per coding** in the batch. + */ +export type ResolveBatchOptions = ResolveCommonOptions; diff --git a/src/fhir/interfaces/resolve-codeable-concept-options.ts b/src/fhir/interfaces/resolve-codeable-concept-options.ts new file mode 100644 index 0000000..c1c676c --- /dev/null +++ b/src/fhir/interfaces/resolve-codeable-concept-options.ts @@ -0,0 +1,6 @@ +import type { ResolveCommonOptions } from './resolve-common-options.js'; + +export interface ResolveCodeableConceptOptions extends ResolveCommonOptions { + /** Optional human-readable description of the CodeableConcept. */ + text?: string; +} diff --git a/src/fhir/interfaces/resolve-common-options.ts b/src/fhir/interfaces/resolve-common-options.ts new file mode 100644 index 0000000..0118173 --- /dev/null +++ b/src/fhir/interfaces/resolve-common-options.ts @@ -0,0 +1,21 @@ +/** + * Shared option fields accepted by every FHIR resolver method + * (`resolve`, `resolveBatch`, `resolveCodeableConcept`). Extracted so a + * new field added today reaches all three methods without manual + * replication. + */ +export interface ResolveCommonOptions { + /** FHIR resource type for context-aware mapping (e.g. `'Condition'`, `'Observation'`). */ + resourceType?: string; + /** Include Phoebe-style recommendations alongside the resolution. */ + includeRecommendations?: boolean; + /** Max recommendations (1–20). Default 5 at the API. */ + recommendationsLimit?: number; + /** Surface the server's quality bucket on the resolution. */ + includeQuality?: boolean; + /** + * `'error'` (default) returns a 404 for unmapped codings; `'sentinel'` + * returns a `concept_id: 0` row so batch flows can keep going. + */ + onUnmapped?: 'error' | 'sentinel'; +} diff --git a/src/fhir/interfaces/resolve-options.ts b/src/fhir/interfaces/resolve-options.ts new file mode 100644 index 0000000..4889cf6 --- /dev/null +++ b/src/fhir/interfaces/resolve-options.ts @@ -0,0 +1,26 @@ +import type { Coding } from './fhir.js'; +import type { ResolveCommonOptions } from './resolve-common-options.js'; + +/** + * Options for `fhir.resolve()`. Accepts either flat fields + * (`{ system, code, display, vocabularyId }`) or a nested `coding` object + * — mirrors the Python SDK's `_extract_coding`. The flat form takes + * precedence when both are supplied. + * + * At minimum, `code` (or `coding.code`) is required. + * + * Common knobs (`resourceType`, `includeRecommendations`, + * `recommendationsLimit`, `includeQuality`, `onUnmapped`) are inherited + * from `ResolveCommonOptions` and shared with `resolveBatch` / + * `resolveCodeableConcept`. + */ +export interface ResolveOptions extends ResolveCommonOptions { + // Flat-form fields + system?: string; + code?: string; + display?: string; + vocabularyId?: string; + + // Nested-form + coding?: Coding; +} diff --git a/src/hierarchy/hierarchy.ts b/src/hierarchy/hierarchy.ts new file mode 100644 index 0000000..bd9f41b --- /dev/null +++ b/src/hierarchy/hierarchy.ts @@ -0,0 +1,66 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { AncestorsOptions } from './interfaces/ancestors-options.js'; +import type { DescendantsOptions } from './interfaces/descendants-options.js'; +import type { GetHierarchyOptions } from './interfaces/get-hierarchy-options.js'; +import type { + AncestorsResult, + DescendantsResult, + HierarchyResult, +} from './interfaces/hierarchy.js'; + +export class Hierarchy { + constructor(private readonly client: OMOPHub) {} + + /** + * Fetch the concept hierarchy in either flat or graph form. + * + * The `format` option drives the response shape — `'flat'` returns + * `concepts` + `paths`, `'graph'` returns `nodes` + `edges`. The + * server caps `maxLevels` at 20 regardless of what's sent. + * + * @see https://docs.omophub.com/api-reference/hierarchy/get + */ + async get( + conceptId: number, + options: GetHierarchyOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/hierarchy`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * List a concept's ancestors (concepts higher up in the hierarchy). + */ + async ancestors( + conceptId: number, + options: AncestorsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/ancestors`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * List a concept's descendants (concepts lower in the hierarchy). + */ + async descendants( + conceptId: number, + options: DescendantsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/descendants`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } +} diff --git a/src/hierarchy/interfaces/ancestors-options.ts b/src/hierarchy/interfaces/ancestors-options.ts new file mode 100644 index 0000000..ae9eab6 --- /dev/null +++ b/src/hierarchy/interfaces/ancestors-options.ts @@ -0,0 +1,11 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface AncestorsOptions extends PaginationOptions { + vocabularyIds?: string[]; + /** Max distance from the source concept. Server-side cap applies. */ + maxLevels?: number; + relationshipTypes?: string[]; + includePaths?: boolean; + includeDistance?: boolean; + includeInvalid?: boolean; +} diff --git a/src/hierarchy/interfaces/descendants-options.ts b/src/hierarchy/interfaces/descendants-options.ts new file mode 100644 index 0000000..8c12f60 --- /dev/null +++ b/src/hierarchy/interfaces/descendants-options.ts @@ -0,0 +1,12 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface DescendantsOptions extends PaginationOptions { + vocabularyIds?: string[]; + /** Max descent depth. Capped at 20 by the server. Default 10. */ + maxLevels?: number; + relationshipTypes?: string[]; + includeDistance?: boolean; + includePaths?: boolean; + includeInvalid?: boolean; + domainIds?: string[]; +} diff --git a/src/hierarchy/interfaces/get-hierarchy-options.ts b/src/hierarchy/interfaces/get-hierarchy-options.ts new file mode 100644 index 0000000..595c383 --- /dev/null +++ b/src/hierarchy/interfaces/get-hierarchy-options.ts @@ -0,0 +1,11 @@ +export interface GetHierarchyOptions { + /** `'flat'` (default) returns concepts + paths; `'graph'` returns nodes + edges. */ + format?: 'flat' | 'graph'; + vocabularyIds?: string[]; + domainIds?: string[]; + /** Max traversal depth. Capped at 20 by the server. Default 10. */ + maxLevels?: number; + maxResults?: number; + relationshipTypes?: string[]; + includeInvalid?: boolean; +} diff --git a/src/hierarchy/interfaces/hierarchy.ts b/src/hierarchy/interfaces/hierarchy.ts new file mode 100644 index 0000000..3c789b8 --- /dev/null +++ b/src/hierarchy/interfaces/hierarchy.ts @@ -0,0 +1,55 @@ +import type { ConceptHierarchyNode } from '../../concepts/interfaces/concept.js'; + +/** + * Hierarchy node — same shape as `ConceptHierarchyNode` from the concepts + * module. Aliased so users reading hierarchy code see the canonical name. + */ +export type HierarchyConcept = ConceptHierarchyNode; +export type Ancestor = ConceptHierarchyNode; +export type Descendant = ConceptHierarchyNode; + +export interface HierarchyPath { + path: number[]; + concepts?: ConceptHierarchyNode[]; + length?: number; +} + +export interface HierarchySummary { + total_ancestors: number; + total_descendants: number; + max_hierarchy_depth: number; + unique_vocabularies: number; + relationship_types_used: string[]; + classification_count: number; +} + +export interface HierarchyEdge { + source: number; + target: number; + relationship_id: string; +} + +/** + * Response from `GET /concepts/{id}/hierarchy`. The exact shape depends on + * the `format` request parameter — `'flat'` returns `concepts` + paths, + * `'graph'` returns `nodes` + `edges`. We type both for ergonomics; the + * caller knows which they asked for. + */ +export interface HierarchyResult { + format?: 'flat' | 'graph'; + concepts?: ConceptHierarchyNode[]; + paths?: HierarchyPath[]; + nodes?: ConceptHierarchyNode[]; + edges?: HierarchyEdge[]; + summary?: HierarchySummary; +} + +export interface AncestorsResult { + ancestors: Ancestor[]; + summary?: HierarchySummary; +} + +export interface DescendantsResult { + descendants: Descendant[]; + summary?: HierarchySummary; +} diff --git a/src/hierarchy/interfaces/index.ts b/src/hierarchy/interfaces/index.ts new file mode 100644 index 0000000..e229d3b --- /dev/null +++ b/src/hierarchy/interfaces/index.ts @@ -0,0 +1,14 @@ +export type { AncestorsOptions } from './ancestors-options.js'; +export type { DescendantsOptions } from './descendants-options.js'; +export type { GetHierarchyOptions } from './get-hierarchy-options.js'; +export type { + Ancestor, + AncestorsResult, + Descendant, + DescendantsResult, + HierarchyConcept, + HierarchyEdge, + HierarchyPath, + HierarchyResult, + HierarchySummary, +} from './hierarchy.js'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c8749d4 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,148 @@ +export { getApiKey, hasApiKey, setApiKey } from './auth/auth.js'; +export { OMOPHub, type OMOPHubOptions } from './client.js'; +export type { + ApiEnvelope, + ApiErrorEnvelope, + ApiSuccessEnvelope, + DeleteOptions, + GetOptions, + IdempotentRequest, + PaginatedData, + PaginationMeta, + PaginationOptions, + PatchOptions, + PerCallOptions, + PostOptions, + PutOptions, + QueryValue, + RequireAtLeastOne, + RequireExactlyOne, + VocabReleaseMixin, +} from './common/interfaces/index.js'; +export type { + BatchConceptResult, + BatchConceptsOptions, + Concept, + ConceptHierarchyNode, + ConceptRelationship, + ConceptRelationshipNode, + ConceptRelationshipsOptions, + ConceptRelationshipsResult, + ConceptSuggestion, + ConceptSummary, + GetConceptByCodeOptions, + GetConceptOptions, + RecommendedConceptEntry, + RecommendedConceptsOptions, + RecommendedConceptsResult, + RelatedConcept, + RelatedConceptsOptions, + RelatedConceptsResult, + RelationshipConcept, + SuggestConceptsOptions, + Synonym, +} from './concepts/interfaces/index.js'; +export type { + Domain, + DomainConceptsOptions, + DomainStats, + DomainSummary, + ListDomainsOptions, +} from './domains/interfaces/index.js'; +export { OMOPHubError, OMOPHubIteratorError } from './errors.js'; +export { type FhirVersion, omophubFhirUrl } from './fhir/fhir-url.js'; +export type { + CodeableConcept, + Coding, + FhirBatchResult, + FhirBatchSummary, + FhirCodeableConceptResult, + FhirResolution, + FhirResolveResult, + RecommendedConceptOutput, + ResolveBatchOptions, + ResolveCodeableConceptOptions, + ResolveCommonOptions, + ResolvedConcept, + ResolveOptions, +} from './fhir/interfaces/index.js'; +export type { + Ancestor, + AncestorsOptions, + AncestorsResult, + Descendant, + DescendantsOptions, + DescendantsResult, + GetHierarchyOptions, + HierarchyConcept, + HierarchyEdge, + HierarchyPath, + HierarchyResult, + HierarchySummary, +} from './hierarchy/interfaces/index.js'; +export type { + ErrorResponse, + OMOPHUB_ERROR_CODE_KEY, + Response, + ResponseMeta, +} from './interfaces.js'; +export type { + FailedMapping, + GetMappingsOptions, + MapConceptsOptions, + MapConceptsResult, + Mapping, + MappingContext, + MappingQuality, + MappingsListResult, + MappingsSummary, + SourceCodeRef, +} from './mappings/interfaces/index.js'; +export type { + GetRelationshipsOptions, + ListRelationshipTypesOptions, + Relationship, + RelationshipsResult, + RelationshipType, + RelationshipTypesResult, +} from './relationships/interfaces/index.js'; +export type { + AdvancedSearchOptions, + AutocompleteOptions, + BasicSearchOptions, + BulkBasicOptions, + BulkBasicResultItem, + BulkBasicSearchInput, + BulkBasicSearchResponse, + BulkSearchDefaults, + BulkSearchStatus, + BulkSemanticDefaults, + BulkSemanticOptions, + BulkSemanticResultItem, + BulkSemanticSearchInput, + BulkSemanticSearchResponse, + PaginateOptions, + RelationshipFilter, + SearchFacet, + SearchFacets, + SearchMetadata, + SearchResult, + SemanticSearchMetadata, + SemanticSearchOptions, + SemanticSearchResult, + SemanticSearchResultSet, + SimilarConcept, + SimilarSearchMetadata, + SimilarSearchOptions, + SimilarSearchResult, +} from './search/interfaces/index.js'; +export { __version__ } from './version.js'; +export type { + ConceptClass, + ListVocabulariesOptions, + Vocabulary, + VocabularyConceptsOptions, + VocabularyDomain, + VocabularyStats, + VocabularySummary, +} from './vocabularies/interfaces/index.js'; diff --git a/src/interfaces.ts b/src/interfaces.ts new file mode 100644 index 0000000..c501d6a --- /dev/null +++ b/src/interfaces.ts @@ -0,0 +1,64 @@ +import type { PaginationMeta } from './common/interfaces/pagination.js'; + +/** + * Canonical SDK error codes. Stable across releases — safe to switch on. + */ +export type OMOPHUB_ERROR_CODE_KEY = + // Auth + | 'missing_api_key' + | 'invalid_api_key' + | 'restricted_api_key' + // Request shape + | 'validation_error' + | 'missing_required_field' + | 'invalid_argument' + // Resource + | 'not_found' + | 'method_not_allowed' + | 'conflict' + // Quota + | 'rate_limit_exceeded' + | 'tier_limit_exceeded' + // Server + | 'internal_server_error' + | 'service_unavailable' + // Transport + | 'connection_error' + | 'timeout_error' + // Catch-all + | 'application_error'; + +export interface ErrorResponse { + name: OMOPHUB_ERROR_CODE_KEY; + message: string; + statusCode: number | null; + requestId?: string; + retryAfter?: number; + details?: Record; +} + +export interface ResponseMeta { + request_id?: string; + timestamp?: string; + vocab_release?: string; + pagination?: PaginationMeta; +} + +/** + * Discriminated-union return type for every SDK method. + * + * On success: `{ data: T, error: null, meta, headers }` — `data` is the + * unwrapped payload from the API envelope, `meta` carries pagination / + * request_id / vocab_release when present. + * + * On error: `{ data: null, error: ErrorResponse, meta: null, headers }` — + * `headers` is preserved when the error came from the wire, null when + * the error was synthesised client-side. + * + * Narrow with `if (error) ...` — TypeScript will type `data` as `T` in the + * `else` branch. + */ +export type Response = ( + | { data: T; error: null; meta: ResponseMeta | null } + | { data: null; error: ErrorResponse; meta: null } +) & { headers: Record | null }; diff --git a/src/mappings/interfaces/get-mappings-options.ts b/src/mappings/interfaces/get-mappings-options.ts new file mode 100644 index 0000000..14181f2 --- /dev/null +++ b/src/mappings/interfaces/get-mappings-options.ts @@ -0,0 +1,7 @@ +import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; + +export interface GetMappingsOptions extends VocabReleaseMixin { + /** Restrict mappings to a single target vocabulary (e.g. `'SNOMED'`). */ + targetVocabulary?: string; + includeInvalid?: boolean; +} diff --git a/src/mappings/interfaces/index.ts b/src/mappings/interfaces/index.ts new file mode 100644 index 0000000..f112612 --- /dev/null +++ b/src/mappings/interfaces/index.ts @@ -0,0 +1,12 @@ +export type { GetMappingsOptions } from './get-mappings-options.js'; +export type { MapConceptsOptions } from './map-concepts-options.js'; +export type { + FailedMapping, + MapConceptsResult, + Mapping, + MappingContext, + MappingQuality, + MappingsListResult, + MappingsSummary, + SourceCodeRef, +} from './mapping.js'; diff --git a/src/mappings/interfaces/map-concepts-options.ts b/src/mappings/interfaces/map-concepts-options.ts new file mode 100644 index 0000000..0ec385c --- /dev/null +++ b/src/mappings/interfaces/map-concepts-options.ts @@ -0,0 +1,28 @@ +import type { SourceCodeRef } from './mapping.js'; + +interface MapConceptsBase { + /** Target vocabulary ID (e.g. `'SNOMED'`, `'RxNorm'`). Required. */ + targetVocabulary: string; + mappingType?: 'direct' | 'equivalent' | 'broader' | 'narrower'; + includeInvalid?: boolean; + /** + * Vocabulary release pin. Sent as a `?vocab_release=` query-string + * parameter (NOT in the JSON body) — matches the Python SDK convention. + */ + vocabRelease?: string; +} + +/** + * `mappings.map` accepts exactly one of two input shapes: + * - `sourceConcepts`: numeric OMOP concept IDs to map. + * - `sourceCodes`: native-vocabulary `(vocabulary_id, concept_code)` pairs. + * + * Enforced by the discriminated union at the type level; defended at + * runtime in `mappings.map()` so JS callers / `as any` users get a + * structured `missing_required_field` error rather than a wire 400. + */ +export type MapConceptsOptions = MapConceptsBase & + ( + | { sourceConcepts: number[]; sourceCodes?: never } + | { sourceConcepts?: never; sourceCodes: SourceCodeRef[] } + ); diff --git a/src/mappings/interfaces/mapping.ts b/src/mappings/interfaces/mapping.ts new file mode 100644 index 0000000..90d9e09 --- /dev/null +++ b/src/mappings/interfaces/mapping.ts @@ -0,0 +1,86 @@ +export interface MappingQuality { + confidence_score: number; + equivalence_type?: string; + semantic_similarity?: number; + mapping_source?: string; + validation_status?: string; + last_reviewed_date?: string; +} + +export interface MappingContext { + source_table?: string; + target_table?: string; + scope?: string; +} + +/** + * Single mapping row returned by `mappings.get` / `mappings.map`. + * + * Minimum live-API shape: `{ source_concept_id, source_concept_name, + * target_concept_id, target_concept_name, relationship_id }`. The + * remaining `source_*` / `target_*` metadata fields and `confidence` + * are populated when the server has them expanded (e.g. when + * `targetVocabulary` was supplied or the row carries a mapping score). + */ +export interface Mapping { + source_concept_id: number; + source_concept_name: string; + source_vocabulary_id?: string; + source_concept_code?: string; + target_concept_id: number; + target_concept_name: string; + target_vocabulary_id?: string; + target_concept_code?: string; + target_domain_id?: string; + target_concept_class_id?: string; + relationship_id: string; + /** Server-side mapping-quality score in `[0, 1]`. */ + confidence?: number; + mapping_type?: string; + invalid_reason?: string | null; + quality?: MappingQuality; + context?: MappingContext; +} + +export interface MappingsSummary { + total_source_concepts?: number; + total_mappings?: number; + mapped_concepts?: number; + unmapped_concepts?: number; +} + +export interface MappingsListResult { + mappings: Mapping[]; + summary?: MappingsSummary; +} + +/** + * Failed-mapping entry in `MapConceptsResult.failed_mappings`. Discriminated + * by which input variant the failure traces back to — every failed mapping + * carries either a `source_concept_id` (from the `sourceConcepts` input) or + * a `source_code` (from the `sourceCodes` input), never an empty object. + * `reason` is optional, mirroring `BatchConceptResult.failed_concepts` in + * `concepts/interfaces/`. + */ +export type FailedMapping = + | { source_concept_id: number; source_code?: never; reason?: string } + | { + source_concept_id?: never; + source_code: { vocabulary_id: string; concept_code: string }; + reason?: string; + }; + +export interface MapConceptsResult { + mappings: Mapping[]; + failed_mappings?: FailedMapping[]; + summary?: MappingsSummary; +} + +/** + * Reference to a non-standard concept by its vocabulary code, used as + * input to `mappings.map({ sourceCodes: [...] })`. + */ +export interface SourceCodeRef { + vocabulary_id: string; + concept_code: string; +} diff --git a/src/mappings/mappings.ts b/src/mappings/mappings.ts new file mode 100644 index 0000000..6be6b6b --- /dev/null +++ b/src/mappings/mappings.ts @@ -0,0 +1,68 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { PostOptions } from '../common/interfaces/post-options.js'; +import { syntheticError } from '../common/utils/synthetic-error.js'; +import { toSnakeCaseKeys } from '../common/utils/to-snake-case.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { GetMappingsOptions } from './interfaces/get-mappings-options.js'; +import type { MapConceptsOptions } from './interfaces/map-concepts-options.js'; +import type { MapConceptsResult, MappingsListResult } from './interfaces/mapping.js'; + +export class Mappings { + constructor(private readonly client: OMOPHub) {} + + /** + * List the mappings already defined for a single concept. + * + * @see https://docs.omophub.com/api-reference/mappings/get + */ + async get( + conceptId: number, + options: GetMappingsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/mappings`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * Map a batch of source concepts (or vocabulary codes) to a target + * vocabulary. + * + * Exactly one of `sourceConcepts` or `sourceCodes` must be supplied — + * enforced by the `MapConceptsOptions` discriminated union and + * re-validated at runtime so JS callers also get a structured error. + * + * `vocabRelease` is sent as a query-string parameter (NOT in the JSON + * body) — matches the Python SDK convention. + * + * **Procedure mappings:** for Procedure-domain sources the API applies + * a fallback vocabulary priority when `targetVocabulary` is left to + * "best fit" semantics: SNOMED → LOINC → CPT4 → HCPCS → ICD10PCS → + * ICD9Proc → OPCS4 → OMOP Extension. + */ + async map( + options: MapConceptsOptions & PostOptions, + ): Promise> { + const hasConcepts = Array.isArray(options.sourceConcepts) && options.sourceConcepts.length > 0; + const hasCodes = Array.isArray(options.sourceCodes) && options.sourceCodes.length > 0; + if (hasConcepts === hasCodes) { + return syntheticError( + 'missing_required_field', + 'Provide exactly one of `sourceConcepts` or `sourceCodes` with at least one entry.', + ); + } + + const { vocabRelease, signal, headers, query, idempotencyKey, ...bodyFields } = options; + const body = toSnakeCaseKeys(bodyFields); + return this.client.post('/concepts/map', body, { + signal, + headers, + query: { vocabRelease, ...query }, + idempotencyKey, + }); + } +} diff --git a/src/relationships/interfaces/get-relationships-options.ts b/src/relationships/interfaces/get-relationships-options.ts new file mode 100644 index 0000000..43f929f --- /dev/null +++ b/src/relationships/interfaces/get-relationships-options.ts @@ -0,0 +1,19 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; +import type { VocabReleaseMixin } from '../../common/interfaces/vocab-release.js'; + +/** + * Options for `relationships.get(conceptId, ...)`. + * + * Shares the wire endpoint with `concepts.relationships(conceptId, ...)` — + * the two methods are kept as parallel entry points so users who think + * concept-first or relationship-first both have a discoverable surface. + * Keep field lists in sync with `ConceptRelationshipsOptions`. + */ +export interface GetRelationshipsOptions extends PaginationOptions, VocabReleaseMixin { + relationshipIds?: string[]; + vocabularyIds?: string[]; + domainIds?: string[]; + includeInvalid?: boolean; + standardOnly?: boolean; + includeReverse?: boolean; +} diff --git a/src/relationships/interfaces/index.ts b/src/relationships/interfaces/index.ts new file mode 100644 index 0000000..b484ea3 --- /dev/null +++ b/src/relationships/interfaces/index.ts @@ -0,0 +1,8 @@ +export type { GetRelationshipsOptions } from './get-relationships-options.js'; +export type { ListRelationshipTypesOptions } from './list-relationship-types-options.js'; +export type { + Relationship, + RelationshipsResult, + RelationshipType, + RelationshipTypesResult, +} from './relationship.js'; diff --git a/src/relationships/interfaces/list-relationship-types-options.ts b/src/relationships/interfaces/list-relationship-types-options.ts new file mode 100644 index 0000000..4cc2402 --- /dev/null +++ b/src/relationships/interfaces/list-relationship-types-options.ts @@ -0,0 +1,3 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export type ListRelationshipTypesOptions = PaginationOptions; diff --git a/src/relationships/interfaces/relationship.ts b/src/relationships/interfaces/relationship.ts new file mode 100644 index 0000000..5b66a70 --- /dev/null +++ b/src/relationships/interfaces/relationship.ts @@ -0,0 +1,31 @@ +import type { ConceptRelationship } from '../../concepts/interfaces/concept.js'; + +/** + * Per-concept relationship entry. Aliased from `ConceptRelationship` + * (defined in concepts/interfaces/) so users reading relationship code + * see the canonical name and the two stay in sync. + */ +export type Relationship = ConceptRelationship; + +/** + * Metadata about a relationship type from `GET /relationships/types`. + */ +export interface RelationshipType { + relationship_id: string; + relationship_name: string; + is_hierarchical: boolean; + is_defining: boolean; + is_symmetric: boolean; + is_transitive: boolean; + reverse_relationship_id?: string; + primary_vocabularies?: string[]; + defines_ancestry?: boolean; +} + +export interface RelationshipsResult { + relationships: Relationship[]; +} + +export interface RelationshipTypesResult { + relationship_types: RelationshipType[]; +} diff --git a/src/relationships/relationships.ts b/src/relationships/relationships.ts new file mode 100644 index 0000000..34b6be1 --- /dev/null +++ b/src/relationships/relationships.ts @@ -0,0 +1,47 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { GetRelationshipsOptions } from './interfaces/get-relationships-options.js'; +import type { ListRelationshipTypesOptions } from './interfaces/list-relationship-types-options.js'; +import type { RelationshipsResult, RelationshipTypesResult } from './interfaces/relationship.js'; + +export class Relationships { + constructor(private readonly client: OMOPHub) {} + + /** + * List a concept's relationships. + * + * Hits the same wire endpoint as `client.concepts.relationships(...)` — + * kept as a separate method so callers thinking in relationship-first + * terms have a discoverable surface. Keep parameter lists in sync. + * + * @see https://docs.omophub.com/api-reference/relationships/get + */ + async get( + conceptId: number, + options: GetRelationshipsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get(`/concepts/${conceptId}/relationships`, { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * List the catalog of available relationship types. + * + * @see https://docs.omophub.com/api-reference/relationships/types + */ + async types( + options: ListRelationshipTypesOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get('/relationships/types', { + signal, + headers, + query: { ...flags, ...query }, + }); + } +} diff --git a/src/search/interfaces/advanced-search-options.ts b/src/search/interfaces/advanced-search-options.ts new file mode 100644 index 0000000..10a1565 --- /dev/null +++ b/src/search/interfaces/advanced-search-options.ts @@ -0,0 +1,21 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +/** + * camelCase input shape — `toSnakeCaseKeys` recurses into the array and + * converts to `relationship_id` / `target_concept_id` at the wire. + */ +export interface RelationshipFilter { + relationshipId: string; + targetConceptId?: number; + direction?: 'forward' | 'reverse'; +} + +export interface AdvancedSearchOptions extends PaginationOptions { + vocabularyIds?: string[]; + domainIds?: string[]; + conceptClassIds?: string[]; + standardConceptsOnly?: boolean; + includeInvalid?: boolean; + /** Restrict matches to concepts with these specific relationships. */ + relationshipFilters?: RelationshipFilter[]; +} diff --git a/src/search/interfaces/autocomplete-options.ts b/src/search/interfaces/autocomplete-options.ts new file mode 100644 index 0000000..960fdeb --- /dev/null +++ b/src/search/interfaces/autocomplete-options.ts @@ -0,0 +1,6 @@ +export interface AutocompleteOptions { + vocabularyIds?: string[]; + domains?: string[]; + /** Maximum number of suggestions. Default 10 at the API; max 100. */ + pageSize?: number; +} diff --git a/src/search/interfaces/basic-search-options.ts b/src/search/interfaces/basic-search-options.ts new file mode 100644 index 0000000..99190e4 --- /dev/null +++ b/src/search/interfaces/basic-search-options.ts @@ -0,0 +1,16 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface BasicSearchOptions extends PaginationOptions { + vocabularyIds?: string[]; + domainIds?: string[]; + conceptClassIds?: string[]; + /** `'S'` standard, `'C'` classification, `'N'` non-standard. */ + standardConcept?: 'S' | 'C' | 'N'; + includeSynonyms?: boolean; + includeInvalid?: boolean; + /** Minimum relevance score (0–1). */ + minScore?: number; + exactMatch?: boolean; + sortBy?: 'relevance' | 'name' | 'concept_count' | 'last_updated'; + sortOrder?: 'asc' | 'desc'; +} diff --git a/src/search/interfaces/bulk-basic-options.ts b/src/search/interfaces/bulk-basic-options.ts new file mode 100644 index 0000000..e22242b --- /dev/null +++ b/src/search/interfaces/bulk-basic-options.ts @@ -0,0 +1,6 @@ +import type { BulkSearchDefaults } from './bulk-search.js'; + +export interface BulkBasicOptions { + /** Shared params applied to every search in the batch unless overridden. */ + defaults?: BulkSearchDefaults; +} diff --git a/src/search/interfaces/bulk-search.ts b/src/search/interfaces/bulk-search.ts new file mode 100644 index 0000000..65c76b5 --- /dev/null +++ b/src/search/interfaces/bulk-search.ts @@ -0,0 +1,66 @@ +import type { Concept } from '../../concepts/interfaces/concept.js'; +import type { SemanticSearchResult } from './semantic-search-result.js'; + +export interface BulkSearchDefaults { + vocabulary_ids?: string[]; + domain_ids?: string[]; + page_size?: number; +} + +export interface BulkBasicSearchInput { + /** Caller-supplied ID echoed back on the matching result for correlation. */ + search_id: string; + query: string; + vocabulary_ids?: string[]; + domain_ids?: string[]; + page_size?: number; +} + +export interface BulkSemanticSearchInput { + search_id: string; + query: string; + threshold?: number; + page_size?: number; + vocabulary_ids?: string[]; + domain_ids?: string[]; +} + +export type BulkSearchStatus = 'completed' | 'failed'; + +export interface BulkBasicResultItem { + search_id: string; + query: string; + status: BulkSearchStatus; + results: Concept[]; + error?: string; + duration?: number; +} + +/** + * `POST /search/bulk` returns a **bare array** of per-search result + * items, NOT a summary wrapper. Use `data.length` for the count and + * `data.filter(r => r.status === 'completed').length` for completed. + */ +export type BulkBasicSearchResponse = BulkBasicResultItem[]; + +export interface BulkSemanticResultItem { + search_id: string; + query: string; + status: BulkSearchStatus; + results: SemanticSearchResult[]; + error?: string; + duration?: number; +} + +/** + * `POST /search/semantic-bulk` returns a wrapper object — unlike + * `bulkBasic` which is a bare array. The semantic endpoint adds aggregate + * counts and a total duration to the response. + */ +export interface BulkSemanticSearchResponse { + results: BulkSemanticResultItem[]; + total_searches: number; + completed_count: number; + failed_count: number; + total_duration?: number; +} diff --git a/src/search/interfaces/bulk-semantic-options.ts b/src/search/interfaces/bulk-semantic-options.ts new file mode 100644 index 0000000..8d75e44 --- /dev/null +++ b/src/search/interfaces/bulk-semantic-options.ts @@ -0,0 +1,10 @@ +import type { BulkSearchDefaults } from './bulk-search.js'; + +export interface BulkSemanticDefaults extends BulkSearchDefaults { + threshold?: number; +} + +export interface BulkSemanticOptions { + /** Shared params applied to every search in the batch unless overridden. */ + defaults?: BulkSemanticDefaults; +} diff --git a/src/search/interfaces/index.ts b/src/search/interfaces/index.ts new file mode 100644 index 0000000..2a563a4 --- /dev/null +++ b/src/search/interfaces/index.ts @@ -0,0 +1,36 @@ +export type { AdvancedSearchOptions, RelationshipFilter } from './advanced-search-options.js'; +export type { AutocompleteOptions } from './autocomplete-options.js'; +export type { BasicSearchOptions } from './basic-search-options.js'; +export type { BulkBasicOptions } from './bulk-basic-options.js'; +export type { + BulkBasicResultItem, + BulkBasicSearchInput, + BulkBasicSearchResponse, + BulkSearchDefaults, + BulkSearchStatus, + BulkSemanticResultItem, + BulkSemanticSearchInput, + BulkSemanticSearchResponse, +} from './bulk-search.js'; +export type { BulkSemanticDefaults, BulkSemanticOptions } from './bulk-semantic-options.js'; +export type { PaginateOptions } from './paginate-options.js'; +export type { + AutocompleteEntry, + AutocompleteResult, + SearchFacet, + SearchFacets, + SearchMetadata, + SearchResult, +} from './search-result.js'; +export type { SemanticSearchOptions } from './semantic-search-options.js'; +export type { + SemanticSearchMetadata, + SemanticSearchResult, + SemanticSearchResultSet, +} from './semantic-search-result.js'; +export type { SimilarSearchOptions } from './similar-search-options.js'; +export type { + SimilarConcept, + SimilarSearchMetadata, + SimilarSearchResult, +} from './similar-search-result.js'; diff --git a/src/search/interfaces/paginate-options.ts b/src/search/interfaces/paginate-options.ts new file mode 100644 index 0000000..fe404d7 --- /dev/null +++ b/src/search/interfaces/paginate-options.ts @@ -0,0 +1,9 @@ +/** + * Per-call knobs for the `*Iter` and `*All` paginating variants. + * - `pageSize`: items requested per page (defaults to 100 for the iter helpers). + * - `maxPages`: hard stop on pages fetched, regardless of `has_next`. + */ +export interface PaginateOptions { + pageSize?: number; + maxPages?: number; +} diff --git a/src/search/interfaces/search-result.ts b/src/search/interfaces/search-result.ts new file mode 100644 index 0000000..43d61cc --- /dev/null +++ b/src/search/interfaces/search-result.ts @@ -0,0 +1,54 @@ +import type { Concept } from '../../concepts/interfaces/concept.js'; + +/** + * A single facet bucket — e.g. "SNOMED: 1,243". + */ +export interface SearchFacet { + value: string; + count: number; + label?: string; +} + +export interface SearchFacets { + vocabularies?: SearchFacet[]; + domains?: SearchFacet[]; + concept_classes?: SearchFacet[]; +} + +export interface SearchMetadata { + query?: string; + total_results?: number; + processing_time_ms?: number; + query_enhanced?: boolean; + enhanced_query?: string; +} + +/** + * Canonical `search.basic` and `search.advanced` payload — normalised at + * the resource boundary so `concepts` is always a `Concept[]`, even when + * the legacy server form returned `{ data: [...] }` instead. + */ +export interface SearchResult { + concepts: Concept[]; + facets?: SearchFacets; + search_metadata?: SearchMetadata; +} + +/** + * One entry in `AutocompleteResult.suggestions`. The server nests the + * concept under a `suggestion` field and may add scoring fields alongside. + */ +export interface AutocompleteEntry { + suggestion: Concept; + match_score?: number; + match_type?: string; +} + +/** + * `GET /search/suggest` returns `{ query, suggestions: [...] }` — the + * caller's original query is echoed back. Wrapped, not a bare array. + */ +export interface AutocompleteResult { + query: string; + suggestions: AutocompleteEntry[]; +} diff --git a/src/search/interfaces/semantic-search-options.ts b/src/search/interfaces/semantic-search-options.ts new file mode 100644 index 0000000..21d4c1f --- /dev/null +++ b/src/search/interfaces/semantic-search-options.ts @@ -0,0 +1,10 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface SemanticSearchOptions extends PaginationOptions { + vocabularyIds?: string[]; + domainIds?: string[]; + standardConcept?: 'S' | 'C' | 'N'; + conceptClassId?: string; + /** Similarity threshold (0–1). */ + threshold?: number; +} diff --git a/src/search/interfaces/semantic-search-result.ts b/src/search/interfaces/semantic-search-result.ts new file mode 100644 index 0000000..85f344b --- /dev/null +++ b/src/search/interfaces/semantic-search-result.ts @@ -0,0 +1,21 @@ +import type { ConceptSummary } from '../../concepts/interfaces/concept.js'; + +export interface SemanticSearchResult extends ConceptSummary { + similarity_score: number; + matched_text?: string; + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; +} + +export interface SemanticSearchMetadata { + query: string; + threshold?: number; + total_candidates?: number; + processing_time_ms?: number; +} + +export interface SemanticSearchResultSet { + results: SemanticSearchResult[]; + search_metadata?: SemanticSearchMetadata; +} diff --git a/src/search/interfaces/similar-search-options.ts b/src/search/interfaces/similar-search-options.ts new file mode 100644 index 0000000..6015cc1 --- /dev/null +++ b/src/search/interfaces/similar-search-options.ts @@ -0,0 +1,30 @@ +interface SimilarSearchBase { + /** `'semantic'`, `'lexical'`, or `'hybrid'` (default). */ + algorithm?: 'semantic' | 'lexical' | 'hybrid'; + /** Similarity floor (0–1). Default 0.7 at the API. */ + similarityThreshold?: number; + pageSize?: number; + vocabularyIds?: string[]; + domainIds?: string[]; + standardConcept?: 'S' | 'C' | 'N'; + includeInvalid?: boolean; + includeScores?: boolean; + includeExplanations?: boolean; +} + +/** + * Exactly one of `conceptId`, `conceptName`, or `query` must be supplied. + * + * Encoded as a discriminated union so TypeScript enforces the XOR at the + * call site. A runtime check in `search.similar()` defends against JS + * callers / `as any` users. + * + * Note: `query` here is the free-text search variant. Because of this it + * conflicts with `PerCallOptions.query` (the escape-hatch params record), + * which is why `search.similar()` uses a two-arg signature + * `similar(options, requestOptions)` rather than merged options. + */ +export type SimilarSearchOptions = + | (SimilarSearchBase & { conceptId: number; conceptName?: never; query?: never }) + | (SimilarSearchBase & { conceptId?: never; conceptName: string; query?: never }) + | (SimilarSearchBase & { conceptId?: never; conceptName?: never; query: string }); diff --git a/src/search/interfaces/similar-search-result.ts b/src/search/interfaces/similar-search-result.ts new file mode 100644 index 0000000..87eda01 --- /dev/null +++ b/src/search/interfaces/similar-search-result.ts @@ -0,0 +1,29 @@ +import type { ConceptSummary } from '../../concepts/interfaces/concept.js'; + +export interface SimilarConcept extends ConceptSummary { + similarity_score: number; + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; + scores?: { + semantic?: number; + lexical?: number; + hybrid?: number; + }; + explanation?: string; +} + +export interface SimilarSearchMetadata { + original_query: string; + algorithm_used: 'semantic' | 'lexical' | 'hybrid'; + similarity_threshold: number; + total_candidates: number; + results_returned: number; + processing_time_ms: number; + embedding_latency_ms?: number; +} + +export interface SimilarSearchResult { + similar_concepts: SimilarConcept[]; + search_metadata: SimilarSearchMetadata; +} diff --git a/src/search/search.ts b/src/search/search.ts new file mode 100644 index 0000000..046ab2e --- /dev/null +++ b/src/search/search.ts @@ -0,0 +1,366 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { PostOptions } from '../common/interfaces/post-options.js'; +import { + normaliseBasicSearchData, + normaliseSemanticSearchData, +} from '../common/utils/normalize-search-response.js'; +import { type PaginateAllResult, paginate, paginateAll } from '../common/utils/paginate.js'; +import { syntheticError } from '../common/utils/synthetic-error.js'; +import { toSnakeCaseKeys } from '../common/utils/to-snake-case.js'; +import type { Concept } from '../concepts/interfaces/concept.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { AdvancedSearchOptions } from './interfaces/advanced-search-options.js'; +import type { AutocompleteOptions } from './interfaces/autocomplete-options.js'; +import type { BasicSearchOptions } from './interfaces/basic-search-options.js'; +import type { BulkBasicOptions } from './interfaces/bulk-basic-options.js'; +import type { + BulkBasicSearchInput, + BulkBasicSearchResponse, + BulkSemanticSearchInput, + BulkSemanticSearchResponse, +} from './interfaces/bulk-search.js'; +import type { BulkSemanticOptions } from './interfaces/bulk-semantic-options.js'; +import type { PaginateOptions } from './interfaces/paginate-options.js'; +import type { AutocompleteResult, SearchResult } from './interfaces/search-result.js'; +import type { SemanticSearchOptions } from './interfaces/semantic-search-options.js'; +import type { + SemanticSearchResult, + SemanticSearchResultSet, +} from './interfaces/semantic-search-result.js'; +import type { SimilarSearchOptions } from './interfaces/similar-search-options.js'; +import type { SimilarSearchResult } from './interfaces/similar-search-result.js'; + +const ITER_DEFAULT_PAGE_SIZE = 100; + +export class Search { + constructor(private readonly client: OMOPHub) {} + + // ─── Basic keyword search ────────────────────────────────────────── + + /** + * Keyword search across concepts. + * + * Normalises the response so `data.concepts` is always a `Concept[]`, + * regardless of whether the server returned `{ concepts: [...] }`, + * `{ data: [...] }`, or a bare array. + */ + async basic( + query: string, + options: BasicSearchOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query: extraQuery, ...flags } = options; + const response = await this.client.get('/search/concepts', { + signal, + headers, + // positional `query` spread LAST so user-supplied options.query can't + // silently override the primary search term. + query: { ...flags, ...extraQuery, query }, + }); + if (response.error) { + return { data: null, error: response.error, meta: null, headers: response.headers }; + } + return { + data: normaliseBasicSearchData(response.data), + error: null, + meta: response.meta, + headers: response.headers, + }; + } + + /** + * Async iterator that walks the basic-search pages, yielding one + * concept at a time. Throws `OMOPHubIteratorError` if any page fails. + */ + basicIter( + query: string, + options: BasicSearchOptions & GetOptions & PaginateOptions = {}, + ): AsyncGenerator { + const { maxPages, pageSize, ...rest } = options; + return paginate( + async (page, size) => { + const r = await this.basic(query, { ...rest, page, pageSize: size }); + if (r.error) return { ...r, data: null } as never; + return { + ...r, + data: { + data: r.data.concepts, + meta: { pagination: derivePagination(r, page, size, r.data.concepts.length) }, + }, + }; + }, + { pageSize: pageSize ?? ITER_DEFAULT_PAGE_SIZE, maxPages }, + ); + } + + /** + * Eagerly collects every basic-search page into a single array of + * concepts. Errors are accumulated rather than thrown. + */ + async basicAll( + query: string, + options: BasicSearchOptions & GetOptions & PaginateOptions = {}, + ): Promise> { + const { maxPages, pageSize, ...rest } = options; + return paginateAll( + async (page, size) => { + const r = await this.basic(query, { ...rest, page, pageSize: size }); + if (r.error) return { ...r, data: null } as never; + return { + ...r, + data: { + data: r.data.concepts, + meta: { pagination: derivePagination(r, page, size, r.data.concepts.length) }, + }, + }; + }, + { pageSize: pageSize ?? ITER_DEFAULT_PAGE_SIZE, maxPages }, + ); + } + + // ─── Advanced (POST) search ──────────────────────────────────────── + + /** + * Advanced search with relationship filters. POST body so complex + * filter graphs can be expressed without URL-length pressure. + */ + async advanced( + query: string, + options: AdvancedSearchOptions & PostOptions = {}, + ): Promise> { + const { signal, headers, query: extraQuery, idempotencyKey, ...flags } = options; + const body = toSnakeCaseKeys({ query, ...flags }); + const response = await this.client.post('/search/advanced', body, { + signal, + headers, + query: extraQuery, + idempotencyKey, + }); + if (response.error) { + return { data: null, error: response.error, meta: null, headers: response.headers }; + } + return { + data: normaliseBasicSearchData(response.data), + error: null, + meta: response.meta, + headers: response.headers, + }; + } + + // ─── Autocomplete ────────────────────────────────────────────────── + + /** + * Lightweight typeahead suggestions. The server returns + * `{ query, suggestions: [{ suggestion: Concept, match_score?, match_type? }] }` + * — `query` echoes the input, `suggestions` is the array. + */ + async autocomplete( + query: string, + options: AutocompleteOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query: extraQuery, ...flags } = options; + return this.client.get('/search/suggest', { + signal, + headers, + query: { ...flags, ...extraQuery, query }, + }); + } + + // ─── Semantic search ─────────────────────────────────────────────── + + /** + * Semantic (embedding-based) search. + * + * Normalises the response so `data.results` is always a populated array, + * regardless of whether the server returned `{ results: [...] }`, + * `{ data: [...] }`, or a bare array — same defensive shape-handling + * the iter / all variants apply. + */ + async semantic( + query: string, + options: SemanticSearchOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query: extraQuery, ...flags } = options; + const response = await this.client.get('/concepts/semantic-search', { + signal, + headers, + query: { ...flags, ...extraQuery, query }, + }); + if (response.error) { + return { data: null, error: response.error, meta: null, headers: response.headers }; + } + const results = normaliseSemanticSearchData(response.data); + const raw = + response.data && typeof response.data === 'object' && !Array.isArray(response.data) + ? (response.data as Record) + : {}; + const normalised: SemanticSearchResultSet = { results }; + if (raw.search_metadata && typeof raw.search_metadata === 'object') { + normalised.search_metadata = + raw.search_metadata as SemanticSearchResultSet['search_metadata']; + } + return { + data: normalised, + error: null, + meta: response.meta, + headers: response.headers, + }; + } + + /** + * Async iterator over semantic-search results. + */ + semanticIter( + query: string, + options: SemanticSearchOptions & GetOptions & PaginateOptions = {}, + ): AsyncGenerator { + const { maxPages, pageSize, ...rest } = options; + return paginate( + async (page, size) => { + const r = await this.semantic(query, { ...rest, page, pageSize: size }); + if (r.error) return { ...r, data: null } as never; + const results = normaliseSemanticSearchData(r.data); + return { + ...r, + data: { + data: results, + meta: { pagination: derivePagination(r, page, size, results.length) }, + }, + }; + }, + { pageSize: pageSize ?? ITER_DEFAULT_PAGE_SIZE, maxPages }, + ); + } + + /** + * Eagerly collect every semantic-search page. + */ + async semanticAll( + query: string, + options: SemanticSearchOptions & GetOptions & PaginateOptions = {}, + ): Promise> { + const { maxPages, pageSize, ...rest } = options; + return paginateAll( + async (page, size) => { + const r = await this.semantic(query, { ...rest, page, pageSize: size }); + if (r.error) return { ...r, data: null } as never; + const results = normaliseSemanticSearchData(r.data); + return { + ...r, + data: { + data: results, + meta: { pagination: derivePagination(r, page, size, results.length) }, + }, + }; + }, + { pageSize: pageSize ?? ITER_DEFAULT_PAGE_SIZE, maxPages }, + ); + } + + // ─── Bulk endpoints ──────────────────────────────────────────────── + + /** + * Run up to 50 basic searches in a single request. Returns a result + * per search keyed by the caller-supplied `search_id`. + */ + async bulkBasic( + searches: BulkBasicSearchInput[], + options: BulkBasicOptions & PostOptions = {}, + ): Promise> { + if (!Array.isArray(searches) || searches.length < 1 || searches.length > 50) { + return syntheticError( + 'validation_error', + '`searches` must be an array of 1–50 items.', + ); + } + const { signal, headers, query, idempotencyKey, defaults } = options; + const body = toSnakeCaseKeys({ searches, defaults }); + return this.client.post('/search/bulk', body, { + signal, + headers, + query, + idempotencyKey, + }); + } + + /** + * Run up to 25 semantic searches in a single request. + */ + async bulkSemantic( + searches: BulkSemanticSearchInput[], + options: BulkSemanticOptions & PostOptions = {}, + ): Promise> { + if (!Array.isArray(searches) || searches.length < 1 || searches.length > 25) { + return syntheticError( + 'validation_error', + '`searches` must be an array of 1–25 items.', + ); + } + const { signal, headers, query, idempotencyKey, defaults } = options; + const body = toSnakeCaseKeys({ searches, defaults }); + return this.client.post('/search/semantic-bulk', body, { + signal, + headers, + query, + idempotencyKey, + }); + } + + // ─── Similarity search ───────────────────────────────────────────── + + /** + * Similarity search by `conceptId`, `conceptName`, or `query`. Exactly + * one of the three must be supplied — TS enforces via the discriminated + * `SimilarSearchOptions` type; this method also defends at runtime so + * JS callers get a structured error rather than a wire 400. + * + * Uses a two-arg signature because `query` is one of the XOR fields + * and would otherwise collide with `PerCallOptions.query`. + */ + async similar( + options: SimilarSearchOptions, + requestOptions: PostOptions = {}, + ): Promise> { + // Tight checks rather than `!== undefined`: a JS caller passing + // `null`, `''`, or `NaN` for one of the XOR fields should be treated + // as "not provided" and rejected synthetically, not forwarded to the + // API as a malformed POST. + const hasConceptId = + typeof options.conceptId === 'number' && Number.isFinite(options.conceptId); + const hasConceptName = + typeof options.conceptName === 'string' && options.conceptName.length > 0; + const hasQuery = typeof options.query === 'string' && options.query.length > 0; + const provided = [hasConceptId, hasConceptName, hasQuery].filter(Boolean).length; + if (provided !== 1) { + return syntheticError( + 'missing_required_field', + 'Provide exactly one of `conceptId`, `conceptName`, or `query` (non-empty).', + ); + } + const body = toSnakeCaseKeys(options); + return this.client.post('/search/similar', body, requestOptions); + } +} + +/** + * Best-effort pagination metadata for endpoints that don't return one + * (e.g. semantic search). When `actualCount < pageSize` we infer + * end-of-results — same heuristic Python's `paginate_all()` uses. + */ +function derivePagination( + response: OMOPHubResponse, + page: number, + pageSize: number, + actualCount: number, +) { + const fromMeta = response.meta?.pagination; + if (fromMeta) return fromMeta; + return { + page, + page_size: pageSize, + total_items: actualCount, + total_pages: actualCount < pageSize ? page : page + 1, + has_next: actualCount >= pageSize, + has_previous: page > 1, + }; +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..112dba0 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,5 @@ +/** + * SDK version. Bumped in lockstep with package.json. Surfaced in the + * User-Agent header so server-side logs can attribute traffic to a release. + */ +export const __version__ = '0.0.1'; diff --git a/src/vocabularies/interfaces/index.ts b/src/vocabularies/interfaces/index.ts new file mode 100644 index 0000000..2d942d0 --- /dev/null +++ b/src/vocabularies/interfaces/index.ts @@ -0,0 +1,13 @@ +export type { ListVocabulariesOptions } from './list-vocabularies-options.js'; +export type { + ConceptClass, + ListConceptClassesResult, + ListVocabulariesResult, + ListVocabularyDomainsResult, + Vocabulary, + VocabularyConceptsResult, + VocabularyDomain, + VocabularyStats, + VocabularySummary, +} from './vocabulary.js'; +export type { VocabularyConceptsOptions } from './vocabulary-concepts-options.js'; diff --git a/src/vocabularies/interfaces/list-vocabularies-options.ts b/src/vocabularies/interfaces/list-vocabularies-options.ts new file mode 100644 index 0000000..d2e0dd6 --- /dev/null +++ b/src/vocabularies/interfaces/list-vocabularies-options.ts @@ -0,0 +1,8 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface ListVocabulariesOptions extends PaginationOptions { + includeStats?: boolean; + includeInactive?: boolean; + sortBy?: 'name' | 'concept_count' | 'last_updated'; + sortOrder?: 'asc' | 'desc'; +} diff --git a/src/vocabularies/interfaces/vocabulary-concepts-options.ts b/src/vocabularies/interfaces/vocabulary-concepts-options.ts new file mode 100644 index 0000000..07b7615 --- /dev/null +++ b/src/vocabularies/interfaces/vocabulary-concepts-options.ts @@ -0,0 +1,13 @@ +import type { PaginationOptions } from '../../common/interfaces/pagination.js'; + +export interface VocabularyConceptsOptions extends PaginationOptions { + /** Free-text search inside the vocabulary. */ + search?: string; + /** `'S'` standard, `'C'` classification, `'all'` (default), or `'N'` non-standard. */ + standardConcept?: 'S' | 'C' | 'N' | 'all'; + includeInvalid?: boolean; + includeRelationships?: boolean; + includeSynonyms?: boolean; + sortBy?: 'name' | 'concept_count' | 'last_updated'; + sortOrder?: 'asc' | 'desc'; +} diff --git a/src/vocabularies/interfaces/vocabulary.ts b/src/vocabularies/interfaces/vocabulary.ts new file mode 100644 index 0000000..d651376 --- /dev/null +++ b/src/vocabularies/interfaces/vocabulary.ts @@ -0,0 +1,81 @@ +/** + * OMOP vocabulary metadata. Snake_case keys match the wire payload — + * no translation applied to responses. + */ +export interface Vocabulary { + vocabulary_id: string; + vocabulary_name: string; + vocabulary_concept_id?: number; + vocabulary_reference?: string; + vocabulary_version?: string; + /** ISO timestamp the vocabulary row was added to the deployment. */ + created_at?: string; + /** ISO timestamp the vocabulary row was last refreshed. */ + updated_at?: string; +} + +export interface VocabularyStats { + vocabulary_id: string; + vocabulary_name: string; + total_concepts: number; + standard_concepts?: number; + classification_concepts?: number; + invalid_concepts?: number; + active_concepts?: number; + valid_start_date?: string; + valid_end_date?: string; + last_updated?: string; +} + +export interface VocabularySummary extends Vocabulary { + stats?: VocabularyStats; +} + +/** + * Domain entry returned by `GET /vocabularies/domains`. Distinct from + * the richer `Domain` type returned by `GET /domains` — this one is + * scoped to the vocabulary catalog. + */ +export interface VocabularyDomain { + domain_id: string; + domain_name: string; + domain_concept_id?: number; +} + +/** + * Concept-class entry returned by `GET /vocabularies/concept-classes`. + */ +export interface ConceptClass { + concept_class_id: string; + concept_class_name: string; + concept_class_concept_id?: number; +} + +/** + * `GET /vocabularies` returns a named-wrapper object with the array under + * `vocabularies` (NOT a bare array, NOT a generic `data` envelope). + * Pagination metadata lives on the outer `Response.meta.pagination`. + * + * The `includeStats` query flag does NOT embed per-item stats here — + * the live API ignores it for the list endpoint. Use + * `client.vocabularies.stats(vocabularyId)` to fetch stats per vocabulary. + */ +export interface ListVocabulariesResult { + vocabularies: Vocabulary[]; +} + +/** + * `GET /vocabularies/{id}/concepts` returns a **bare array** of full + * `Concept` rows. Pagination metadata lives on the outer `Response.meta`. + */ +export type VocabularyConceptsResult = import('../../concepts/interfaces/concept.js').Concept[]; + +export interface ListVocabularyDomainsResult { + domains: VocabularyDomain[]; +} + +/** + * `GET /vocabularies/concept-classes` returns a **bare array** of + * concept-class rows. + */ +export type ListConceptClassesResult = ConceptClass[]; diff --git a/src/vocabularies/vocabularies.ts b/src/vocabularies/vocabularies.ts new file mode 100644 index 0000000..bc023e6 --- /dev/null +++ b/src/vocabularies/vocabularies.ts @@ -0,0 +1,106 @@ +import type { OMOPHub } from '../client.js'; +import type { GetOptions } from '../common/interfaces/get-options.js'; +import type { Response as OMOPHubResponse } from '../interfaces.js'; +import type { ListVocabulariesOptions } from './interfaces/list-vocabularies-options.js'; +import type { + ListConceptClassesResult, + ListVocabulariesResult, + ListVocabularyDomainsResult, + Vocabulary, + VocabularyConceptsResult, + VocabularyStats, +} from './interfaces/vocabulary.js'; +import type { VocabularyConceptsOptions } from './interfaces/vocabulary-concepts-options.js'; + +export class Vocabularies { + constructor(private readonly client: OMOPHub) {} + + /** + * List vocabularies. Result is wrapped under `vocabularies`; pagination + * metadata lives on the outer `Response.meta.pagination`. + * + * @see https://docs.omophub.com/api-reference/vocabularies/list + */ + async list( + options: ListVocabulariesOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get('/vocabularies', { + signal, + headers, + query: { ...flags, ...query }, + }); + } + + /** + * Fetch a single vocabulary's metadata by its ID (e.g. `SNOMED`, `RxNorm`). + */ + async get(vocabularyId: string, options: GetOptions = {}): Promise> { + return this.client.get( + `/vocabularies/${encodeURIComponent(vocabularyId)}`, + options, + ); + } + + /** + * Concept counts and breakdowns for a single vocabulary. + */ + async stats( + vocabularyId: string, + options: GetOptions = {}, + ): Promise> { + return this.client.get( + `/vocabularies/${encodeURIComponent(vocabularyId)}/stats`, + options, + ); + } + + /** + * Per-domain breakdown for one vocabulary/domain pair. + */ + async domainStats( + vocabularyId: string, + domainId: string, + options: GetOptions = {}, + ): Promise>> { + return this.client.get>( + `/vocabularies/${encodeURIComponent(vocabularyId)}/stats/domains/${encodeURIComponent(domainId)}`, + options, + ); + } + + /** + * Vocabulary-scoped domain catalog. Distinct from `client.domains.list()` + * which hits `/domains` — this one returns domains as they appear in the + * vocabulary metadata table. Wrapped under `domains`. + */ + async domains(options: GetOptions = {}): Promise> { + return this.client.get('/vocabularies/domains', options); + } + + /** + * Concept-class catalog across all vocabularies. Returns a **bare array** + * of `ConceptClass` rows — confirmed against the live API; not wrapped. + */ + async conceptClasses( + options: GetOptions = {}, + ): Promise> { + return this.client.get('/vocabularies/concept-classes', options); + } + + /** + * Paginated listing of concepts within a single vocabulary. Returns a + * **bare array** of `Concept` rows — confirmed against the live API; + * pagination metadata on outer `Response.meta`. + */ + async concepts( + vocabularyId: string, + options: VocabularyConceptsOptions & GetOptions = {}, + ): Promise> { + const { signal, headers, query, ...flags } = options; + return this.client.get( + `/vocabularies/${encodeURIComponent(vocabularyId)}/concepts`, + { signal, headers, query: { ...flags, ...query } }, + ); + } +} diff --git a/test/auth/auth.test.ts b/test/auth/auth.test.ts new file mode 100644 index 0000000..8a76e5e --- /dev/null +++ b/test/auth/auth.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { getApiKey, hasApiKey, setApiKey } from '../../src/auth/auth.js'; + +describe('auth helpers', () => { + let originalKey: string | undefined; + + beforeEach(() => { + originalKey = process.env.OMOPHUB_API_KEY; + delete process.env.OMOPHUB_API_KEY; + }); + + afterEach(() => { + if (originalKey !== undefined) process.env.OMOPHUB_API_KEY = originalKey; + else delete process.env.OMOPHUB_API_KEY; + }); + + test('getApiKey returns undefined when env is unset', () => { + expect(getApiKey()).toBeUndefined(); + }); + + test('getApiKey returns the env value when set', () => { + process.env.OMOPHUB_API_KEY = 'oh_test_value'; + expect(getApiKey()).toBe('oh_test_value'); + }); + + test('setApiKey writes to OMOPHUB_API_KEY', () => { + setApiKey('oh_set_value'); + expect(process.env.OMOPHUB_API_KEY).toBe('oh_set_value'); + expect(getApiKey()).toBe('oh_set_value'); + }); + + test('hasApiKey returns false when unset or empty', () => { + expect(hasApiKey()).toBe(false); + process.env.OMOPHUB_API_KEY = ''; + expect(hasApiKey()).toBe(false); + }); + + test('hasApiKey returns true when set to a non-empty string', () => { + process.env.OMOPHUB_API_KEY = 'oh_present'; + expect(hasApiKey()).toBe(true); + }); + + test('setApiKey rejects empty / whitespace-only keys (consistent with hasApiKey)', () => { + expect(() => setApiKey('')).toThrowError(/non-empty/); + expect(() => setApiKey(' ')).toThrowError(/non-empty/); + // env stays unset, no silent write + expect(process.env.OMOPHUB_API_KEY).toBeUndefined(); + }); + + test('setApiKey strips surrounding whitespace before persisting', () => { + // A leading newline or trailing space slipped from a `.env` parser or + // copy-paste must not leak into the Authorization header. + setApiKey(' oh_padded_value\n'); + expect(process.env.OMOPHUB_API_KEY).toBe('oh_padded_value'); + expect(getApiKey()).toBe('oh_padded_value'); + }); + + test('setApiKey re-throws read-only env errors as OMOPHubError', async () => { + const { OMOPHubError } = await import('../../src/errors.js'); + // Node's `process.env` is a Proxy that rejects accessor descriptors, + // so we wrap it in our own Proxy whose `set` trap throws — simulating + // a frozen / read-only env in an edge runtime — and swap it in + // temporarily. + const realEnv = process.env; + const readOnlyEnv = new Proxy( + {}, + { + get() { + return undefined; + }, + set() { + throw new TypeError('process.env is read-only here'); + }, + }, + ) as NodeJS.ProcessEnv; + Object.defineProperty(process, 'env', { + configurable: true, + get: () => readOnlyEnv, + }); + try { + expect(() => setApiKey('oh_test_value')).toThrowError(OMOPHubError); + expect(() => setApiKey('oh_test_value')).toThrowError(/could not write to process.env/); + } finally { + Object.defineProperty(process, 'env', { + configurable: true, + writable: true, + value: realEnv, + }); + } + }); +}); diff --git a/test/client-http.test.ts b/test/client-http.test.ts new file mode 100644 index 0000000..b47e0ed --- /dev/null +++ b/test/client-http.test.ts @@ -0,0 +1,336 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { OMOPHub } from '../src/client.js'; +import { mockApiEnvelope, mockApiErrorBody, mockVocabulary } from './fixtures/index.js'; +import { + createMockFetch, + enqueueError, + enqueueNetworkError, + enqueueSuccess, + lastCall, +} from './helpers/mock-fetch.js'; + +describe('OMOPHub HTTP dispatch', () => { + let originalRandom: typeof Math.random; + let originalKey: string | undefined; + + beforeEach(() => { + originalRandom = Math.random; + Math.random = () => 0; + originalKey = process.env.OMOPHUB_API_KEY; + delete process.env.OMOPHUB_API_KEY; + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + Math.random = originalRandom; + if (originalKey !== undefined) process.env.OMOPHUB_API_KEY = originalKey; + vi.useRealTimers(); + }); + + test('GET sends the right URL, method, and headers', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [mockVocabulary()]); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error, headers } = await client.get('/vocabularies'); + + expect(error).toBeNull(); + expect(data).toEqual([mockVocabulary()]); + expect(headers).not.toBeNull(); + + const { url, init } = lastCall(fetchMock); + expect(url).toBe('https://api.omophub.com/v1/vocabularies'); + expect(init.method).toBe('GET'); + const requestHeaders = new Headers(init.headers); + expect(requestHeaders.get('authorization')).toBe('Bearer oh_test'); + expect(requestHeaders.get('user-agent')).toMatch(/^omophub-node\//); + expect(requestHeaders.get('content-type')).toBe('application/json'); + expect(init.body).toBeUndefined(); + }); + + test('GET serialises query params, converting camelCase to snake_case', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, []); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.get('/vocabularies', { + query: { page: 2, pageSize: 50, vocabularyIds: ['SNOMED', 'ICD10'] }, + }); + + const { url } = lastCall(fetchMock); + expect(url).toBe( + 'https://api.omophub.com/v1/vocabularies?page=2&page_size=50&vocabulary_ids=SNOMED%2CICD10', + ); + }); + + test('POST sends JSON body and sets Idempotency-Key when provided', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { result: 'ok' }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.post('/concepts/batch', { concept_ids: [1, 2, 3] }, { idempotencyKey: 'idem_1' }); + + const { init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + expect(init.body).toBe(JSON.stringify({ concept_ids: [1, 2, 3] })); + expect(new Headers(init.headers).get('idempotency-key')).toBe('idem_1'); + }); + + test('POST does NOT set Idempotency-Key when not provided', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, {}); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.post('/concepts/batch', { x: 1 }); + + const { init } = lastCall(fetchMock); + expect(new Headers(init.headers).has('idempotency-key')).toBe(false); + }); + + test('returns ErrorResponse on 4xx without retrying', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'concept missing')); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error, headers } = await client.get('/concepts/1'); + + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + expect(error?.message).toBe('concept missing'); + expect(error?.statusCode).toBe(404); + expect(headers).not.toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('retries on 429 honouring Retry-After', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 429, mockApiErrorBody('rate_limit_exceeded', 'slow'), { + 'retry-after': '0', + }); + enqueueSuccess(fetchMock, { ok: true }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error } = await client.get('/foo'); + + expect(error).toBeNull(); + expect(data).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('retries on 503 with exponential backoff and eventually succeeds', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 503); + enqueueError(fetchMock, 503); + enqueueSuccess(fetchMock, { ok: true }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error } = await client.get('/foo'); + + expect(error).toBeNull(); + expect(data).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + test('gives up after maxRetries on persistent 503', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 503); + enqueueError(fetchMock, 503); + enqueueError(fetchMock, 503); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 2 }); + const { data, error } = await client.get('/foo'); + + expect(data).toBeNull(); + expect(error?.name).toBe('service_unavailable'); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + test('retries on network error and then succeeds', async () => { + const fetchMock = createMockFetch(); + enqueueNetworkError(fetchMock); + enqueueSuccess(fetchMock, { ok: true }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 2 }); + const { error } = await client.get('/foo'); + + expect(error).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('returns connection_error after exhausting retries on network failure', async () => { + const fetchMock = createMockFetch(); + enqueueNetworkError(fetchMock, new Error('ECONNREFUSED')); + enqueueNetworkError(fetchMock, new Error('ECONNREFUSED')); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 1 }); + const { data, error, headers } = await client.get('/foo'); + + expect(data).toBeNull(); + expect(error?.name).toBe('connection_error'); + expect(error?.message).toBe('ECONNREFUSED'); + expect(headers).toBeNull(); + }); + + test('DOES retry POST without an Idempotency-Key on 429 (pre-processing rejection)', async () => { + // 429 means the server declined before touching state (RFC 9110 + // §15.5.29), so retry can't create duplicates — distinct from 5xx + // where the upstream may have partially processed. The FHIR e2e + // suite is the canonical case: every `fhir.*` endpoint is POST. + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 429, mockApiErrorBody('rate_limit_exceeded', 'slow'), { + 'retry-after': '0', + }); + enqueueSuccess(fetchMock, { ok: true }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error } = await client.post('/concepts/batch', { concept_ids: [1] }); + + expect(error).toBeNull(); + expect(data).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('does NOT retry POST without an Idempotency-Key on 503', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 503); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error } = await client.post('/concepts/batch', { concept_ids: [1] }); + + expect(data).toBeNull(); + expect(error?.name).toBe('service_unavailable'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('retries POST WITH an Idempotency-Key on 503', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 503); + enqueueSuccess(fetchMock, { ok: true }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error } = await client.post( + '/concepts/batch', + { concept_ids: [1] }, + { idempotencyKey: 'idem_42' }, + ); + + expect(error).toBeNull(); + expect(data).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('does NOT retry POST without an Idempotency-Key on network error', async () => { + const fetchMock = createMockFetch(); + enqueueNetworkError(fetchMock); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 3 }); + const { data, error } = await client.post('/concepts/batch', { concept_ids: [1] }); + + expect(data).toBeNull(); + expect(error?.name).toBe('connection_error'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('attaches X-Vocab-Version header when vocabVersion is set', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, {}); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, vocabVersion: '2025.1' }); + await client.get('/concepts/201826'); + + const { init } = lastCall(fetchMock); + expect(new Headers(init.headers).get('x-vocab-version')).toBe('2025.1'); + }); + + test('per-call headers merge onto client headers', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, {}); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.get('/concepts/1', { headers: { 'X-Custom': 'yes' } }); + + const { init } = lastCall(fetchMock); + const requestHeaders = new Headers(init.headers); + expect(requestHeaders.get('x-custom')).toBe('yes'); + expect(requestHeaders.get('authorization')).toBe('Bearer oh_test'); + }); + + test('caller AbortSignal propagates as a thrown AbortError, not a return value', async () => { + const fetchMock = createMockFetch(); + fetchMock.mockImplementation((_url, init) => { + const signal = (init as RequestInit | undefined)?.signal; + return new Promise((_resolve, reject) => { + if (signal) { + signal.addEventListener('abort', () => { + const abortErr = new Error('aborted'); + abortErr.name = 'AbortError'; + reject(abortErr); + }); + } + }); + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0, timeoutMs: 0 }); + const controller = new AbortController(); + const pending = client.get('/slow', { signal: controller.signal }); + controller.abort(); + + await expect(pending).rejects.toThrow(/aborted/); + }); + + test('timeout aborts return timeout_error', async () => { + // Pure fake timers (overrides the shouldAdvanceTime config from beforeEach) + // so the timeout fires deterministically rather than depending on wall-clock. + vi.useFakeTimers({ shouldAdvanceTime: false }); + const fetchMock = createMockFetch(); + fetchMock.mockImplementation((_url, init) => { + const signal = (init as RequestInit | undefined)?.signal; + return new Promise((_resolve, reject) => { + if (signal) { + signal.addEventListener('abort', () => { + const abortErr = new Error('aborted'); + abortErr.name = 'AbortError'; + reject(abortErr); + }); + } + }); + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0, timeoutMs: 10 }); + const pending = client.get('/slow'); + await vi.advanceTimersByTimeAsync(20); + const { data, error } = await pending; + + expect(data).toBeNull(); + expect(error?.name).toBe('timeout_error'); + }); + + test('reads OMOPHUB_API_URL env var for baseUrl', async () => { + const originalUrl = process.env.OMOPHUB_API_URL; + process.env.OMOPHUB_API_URL = 'https://staging.omophub.com/v1'; + try { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, []); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.get('/vocabularies'); + expect(lastCall(fetchMock).url).toBe('https://staging.omophub.com/v1/vocabularies'); + } finally { + if (originalUrl !== undefined) process.env.OMOPHUB_API_URL = originalUrl; + else delete process.env.OMOPHUB_API_URL; + } + }); + + test('returns headers as a plain Record on every code path', async () => { + const fetchMock = createMockFetch(); + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(mockApiEnvelope({ ok: true })), { + status: 200, + headers: { 'content-type': 'application/json', 'x-request-id': 'req_99' }, + }), + ); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { headers } = await client.get('/foo'); + expect(headers?.['x-request-id']).toBe('req_99'); + }); +}); diff --git a/test/client.test.ts b/test/client.test.ts new file mode 100644 index 0000000..6b9bff8 --- /dev/null +++ b/test/client.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { OMOPHub } from '../src/client.js'; +import { createMockFetch, enqueueSuccess, lastCall } from './helpers/mock-fetch.js'; + +describe('OMOPHub constructor', () => { + let originalKey: string | undefined; + let originalUrl: string | undefined; + + beforeEach(() => { + originalKey = process.env.OMOPHUB_API_KEY; + originalUrl = process.env.OMOPHUB_API_URL; + delete process.env.OMOPHUB_API_KEY; + delete process.env.OMOPHUB_API_URL; + }); + + afterEach(() => { + if (originalKey !== undefined) process.env.OMOPHUB_API_KEY = originalKey; + if (originalUrl !== undefined) process.env.OMOPHUB_API_URL = originalUrl; + }); + + test('accepts an API key argument and defaults other options', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, {}); + const client = new OMOPHub('oh_test_key', { fetch: fetchMock }); + await client.get('/anything'); + + expect(client.baseUrl).toBe('https://api.omophub.com/v1'); + expect(client.timeoutMs).toBe(30_000); + expect(client.maxRetries).toBe(3); + expect(client.userAgent).toMatch(/^omophub-node\/\d+\.\d+\.\d+/); + expect(client.vocabVersion).toBeUndefined(); + expect(new Headers(lastCall(fetchMock).init.headers).get('authorization')).toBe( + 'Bearer oh_test_key', + ); + }); + + test('throws if no API key is provided and OMOPHUB_API_KEY is unset', () => { + expect(() => new OMOPHub()).toThrowError(/Missing API key/); + }); + + test('reads OMOPHUB_API_KEY from env when no argument is provided', async () => { + process.env.OMOPHUB_API_KEY = 'oh_env_key'; + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, {}); + const client = new OMOPHub(undefined, { fetch: fetchMock }); + await client.get('/anything'); + expect(new Headers(lastCall(fetchMock).init.headers).get('authorization')).toBe( + 'Bearer oh_env_key', + ); + }); + + test('argument takes precedence over OMOPHUB_API_KEY env', async () => { + process.env.OMOPHUB_API_KEY = 'oh_env_key'; + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, {}); + const client = new OMOPHub('oh_arg_key', { fetch: fetchMock }); + await client.get('/anything'); + expect(new Headers(lastCall(fetchMock).init.headers).get('authorization')).toBe( + 'Bearer oh_arg_key', + ); + }); + + test('reads OMOPHUB_API_URL from env when no baseUrl option is provided', () => { + process.env.OMOPHUB_API_URL = 'https://staging.omophub.com/v1'; + const client = new OMOPHub('oh_test'); + expect(client.baseUrl).toBe('https://staging.omophub.com/v1'); + }); + + test('baseUrl option takes precedence over OMOPHUB_API_URL env', () => { + process.env.OMOPHUB_API_URL = 'https://staging.omophub.com/v1'; + const client = new OMOPHub('oh_test', { baseUrl: 'https://custom.example.com' }); + expect(client.baseUrl).toBe('https://custom.example.com'); + }); + + test('accepts overrides for timeoutMs, maxRetries, userAgent, vocabVersion', () => { + const client = new OMOPHub('oh_test', { + timeoutMs: 5_000, + maxRetries: 0, + userAgent: 'my-app/1.0.0', + vocabVersion: '2025.1', + }); + expect(client.timeoutMs).toBe(5_000); + expect(client.maxRetries).toBe(0); + expect(client.userAgent).toBe('my-app/1.0.0'); + expect(client.vocabVersion).toBe('2025.1'); + }); + + test('does not expose the API key as a public field', () => { + const client = new OMOPHub('oh_secret'); + expect((client as unknown as Record).apiKey).toBeUndefined(); + expect(Object.keys(client)).not.toContain('apiKey'); + }); +}); diff --git a/test/common/utils/backoff.test.ts b/test/common/utils/backoff.test.ts new file mode 100644 index 0000000..f1919fb --- /dev/null +++ b/test/common/utils/backoff.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { backoffMs, isRetryableStatus } from '../../../src/common/utils/backoff.js'; + +describe('isRetryableStatus', () => { + test('retries 429 and 5xx-ish statuses', () => { + expect(isRetryableStatus(429)).toBe(true); + expect(isRetryableStatus(502)).toBe(true); + expect(isRetryableStatus(503)).toBe(true); + expect(isRetryableStatus(504)).toBe(true); + }); + + test('does not retry 4xx (except 429) or 2xx', () => { + expect(isRetryableStatus(200)).toBe(false); + expect(isRetryableStatus(400)).toBe(false); + expect(isRetryableStatus(401)).toBe(false); + expect(isRetryableStatus(404)).toBe(false); + expect(isRetryableStatus(500)).toBe(false); + }); +}); + +describe('backoffMs', () => { + beforeEach(() => { + vi.spyOn(Math, 'random').mockReturnValue(0); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('honours Retry-After in seconds when within 60s cap', () => { + expect(backoffMs(0, '2')).toBe(2000); + expect(backoffMs(5, '30')).toBe(30_000); + }); + + test('caps Retry-After above 60s at 60s rather than dropping it', () => { + expect(backoffMs(0, '120')).toBe(60_000); + expect(backoffMs(0, '600')).toBe(60_000); + }); + + test('floors Retry-After of 0 at the 100ms minimum to avoid spam-retry', () => { + expect(backoffMs(0, '0')).toBe(100); + }); + + test('exponential backoff doubles per attempt, capped at 8s', () => { + expect(backoffMs(0, null)).toBe(500); + expect(backoffMs(1, null)).toBe(1000); + expect(backoffMs(2, null)).toBe(2000); + expect(backoffMs(3, null)).toBe(4000); + expect(backoffMs(4, null)).toBe(8000); + expect(backoffMs(5, null)).toBe(8000); + expect(backoffMs(10, null)).toBe(8000); + }); + + test('jitter scales down by up to 25%', () => { + vi.spyOn(Math, 'random').mockReturnValue(1); + expect(backoffMs(0, null)).toBeCloseTo(500 * 0.75, 5); + }); + + test('falls back to exponential for unparseable Retry-After', () => { + expect(backoffMs(0, 'garbage')).toBe(500); + }); + + test('handles HTTP-date Retry-After values', () => { + const fiveSecondsFromNow = new Date(Date.now() + 5_000).toUTCString(); + const result = backoffMs(0, fiveSecondsFromNow); + expect(result).toBeGreaterThanOrEqual(4_000); + expect(result).toBeLessThanOrEqual(6_000); + }); + + test('rejects malformed delta-seconds (whitespace, decimals, signs, empty)', () => { + // Each falls back to exponential — the canonical "value not parseable" + // signal. Without strict parsing, `Number('')` would silently return 0 + // and we'd retry instantly with no backoff. + expect(backoffMs(0, '')).toBe(500); + expect(backoffMs(0, ' 5 ')).toBe(500); + expect(backoffMs(0, '1.5')).toBe(500); + expect(backoffMs(0, '+5')).toBe(500); + expect(backoffMs(0, '5e3')).toBe(500); + expect(backoffMs(0, '-5')).toBe(500); + }); + + test('rejects non-HTTP-date strings that Date.parse would otherwise accept', () => { + // `Date.parse('2024-01-01')` succeeds but ISO dates are not valid + // Retry-After values per RFC 9110 (which requires IMF-fixdate / + // rfc850-date / asctime-date). Same for various permissive inputs. + expect(backoffMs(0, '2024-01-01')).toBe(500); + expect(backoffMs(0, '2024-01-01T00:00:00Z')).toBe(500); + expect(backoffMs(0, 'tomorrow')).toBe(500); + expect(backoffMs(0, '1 hour')).toBe(500); + }); + + test('rejects malformed dates that merely start with an HTTP-date weekday prefix', () => { + // V8's `Date.parse` will accept a strict-looking date with trailing + // junk and return a finite timestamp. The full-string regex match + // rejects these. + expect(backoffMs(0, 'Sunday is a holiday')).toBe(500); + expect(backoffMs(0, 'Mon Nov 6 12:00:00 1994 garbage')).toBe(500); + expect(backoffMs(0, 'Sun, 06 Nov 1994 08:49:37 GMT extra')).toBe(500); + expect(backoffMs(0, 'Sun, 06 Nov 1994 08:49:37 PST')).toBe(500); // wrong zone + expect(backoffMs(0, 'Sun, 6 Nov 1994 08:49:37 GMT')).toBe(500); // 1-digit day + }); + + test('accepts canonical rfc850-date and asctime-date HTTP-date formats', () => { + // Construct the same instant as the IMF-fixdate test, in each of the + // two obsolete forms RFC 9110 still requires us to honour. + const future = new Date(Date.now() + 5_000); + const yy = String(future.getUTCFullYear() % 100).padStart(2, '0'); + const yyyy = String(future.getUTCFullYear()); + const monthAbbr = future.toUTCString().slice(8, 11); + const dayName = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][future.getUTCDay()]; + const fullDayName = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ][future.getUTCDay()]; + const dd = String(future.getUTCDate()).padStart(2, '0'); + const HH = String(future.getUTCHours()).padStart(2, '0'); + const MM = String(future.getUTCMinutes()).padStart(2, '0'); + const SS = String(future.getUTCSeconds()).padStart(2, '0'); + + const rfc850 = `${fullDayName}, ${dd}-${monthAbbr}-${yy} ${HH}:${MM}:${SS} GMT`; + const asctime = `${dayName} ${monthAbbr} ${dd} ${HH}:${MM}:${SS} ${yyyy}`; + + expect(backoffMs(0, rfc850)).toBeGreaterThanOrEqual(4_000); + expect(backoffMs(0, rfc850)).toBeLessThanOrEqual(6_000); + expect(backoffMs(0, asctime)).toBeGreaterThanOrEqual(4_000); + expect(backoffMs(0, asctime)).toBeLessThanOrEqual(6_000); + }); +}); diff --git a/test/common/utils/build-query.test.ts b/test/common/utils/build-query.test.ts new file mode 100644 index 0000000..3961bbd --- /dev/null +++ b/test/common/utils/build-query.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'vitest'; +import { appendQuery, buildQuery } from '../../../src/common/utils/build-query.js'; + +describe('buildQuery', () => { + test('returns empty string for undefined or empty input', () => { + expect(buildQuery(undefined)).toBe(''); + expect(buildQuery({})).toBe(''); + }); + + test('converts camelCase keys to snake_case', () => { + expect(buildQuery({ pageSize: 50 })).toBe('page_size=50'); + expect(buildQuery({ includeRelationships: true })).toBe('include_relationships=true'); + }); + + test('drops null and undefined values', () => { + expect(buildQuery({ page: 1, foo: null, bar: undefined })).toBe('page=1'); + }); + + test('joins arrays with commas', () => { + expect(buildQuery({ vocabularyIds: ['SNOMED', 'ICD10'] })).toBe( + 'vocabulary_ids=SNOMED%2CICD10', + ); + }); + + test('drops empty arrays', () => { + expect(buildQuery({ page: 1, vocabularyIds: [] })).toBe('page=1'); + }); + + test('stringifies booleans and numbers', () => { + expect(buildQuery({ page: 1, includeStats: false })).toBe('page=1&include_stats=false'); + }); + + test('preserves multiple keys', () => { + expect(buildQuery({ page: 1, pageSize: 50, includeStats: true })).toBe( + 'page=1&page_size=50&include_stats=true', + ); + }); + + test('deduplicates keys after snake-case conversion — last write wins', () => { + // Simulates `{ ...flags (camelCase), ...userQuery (snake_case) }` merge. + // Without dedup, the URL would carry `page_size=10&page_size=20`. + expect(buildQuery({ pageSize: 10, page_size: 20 })).toBe('page_size=20'); + }); +}); + +describe('appendQuery', () => { + test('returns the path unchanged when query is empty', () => { + expect(appendQuery('/concepts', '')).toBe('/concepts'); + }); + + test('appends with ? when path has no query', () => { + expect(appendQuery('/concepts', 'page=1')).toBe('/concepts?page=1'); + }); + + test('appends with & when path already has a query', () => { + expect(appendQuery('/concepts?foo=bar', 'page=1')).toBe('/concepts?foo=bar&page=1'); + }); +}); diff --git a/test/common/utils/normalize-search-response.test.ts b/test/common/utils/normalize-search-response.test.ts new file mode 100644 index 0000000..ab9eac0 --- /dev/null +++ b/test/common/utils/normalize-search-response.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'vitest'; +import { + normaliseBasicSearchData, + normaliseSemanticSearchData, +} from '../../../src/common/utils/normalize-search-response.js'; + +describe('normaliseBasicSearchData', () => { + test('passes through modern { concepts, facets, search_metadata } shape', () => { + const raw = { + concepts: [{ concept_id: 1 }], + facets: { vocabularies: [] }, + search_metadata: { query: 'diabetes' }, + }; + expect(normaliseBasicSearchData(raw)).toEqual(raw); + }); + + test('unwraps legacy { data: [...] } shape into concepts', () => { + const raw = { data: [{ concept_id: 1 }, { concept_id: 2 }] }; + expect(normaliseBasicSearchData(raw)).toEqual({ + concepts: [{ concept_id: 1 }, { concept_id: 2 }], + }); + }); + + test('wraps a bare array into { concepts: [...] }', () => { + expect(normaliseBasicSearchData([{ concept_id: 1 }])).toEqual({ + concepts: [{ concept_id: 1 }], + }); + }); + + test('returns empty concepts for null / non-object / no arrays present', () => { + expect(normaliseBasicSearchData(null)).toEqual({ concepts: [] }); + expect(normaliseBasicSearchData('weird')).toEqual({ concepts: [] }); + expect(normaliseBasicSearchData({ unrelated: 1 })).toEqual({ concepts: [] }); + }); +}); + +describe('normaliseSemanticSearchData', () => { + test('extracts results array from { results: [...] } shape', () => { + const raw = { + results: [{ concept_id: 1, similarity_score: 0.9 }], + search_metadata: {}, + }; + expect(normaliseSemanticSearchData(raw)).toEqual([{ concept_id: 1, similarity_score: 0.9 }]); + }); + + test('passes through a bare array', () => { + const raw = [{ concept_id: 1, similarity_score: 0.9 }]; + expect(normaliseSemanticSearchData(raw)).toEqual(raw); + }); + + test('falls back to { data: [...] } if results missing', () => { + const raw = { data: [{ concept_id: 1, similarity_score: 0.9 }] }; + expect(normaliseSemanticSearchData(raw)).toEqual([{ concept_id: 1, similarity_score: 0.9 }]); + }); + + test('returns empty array for null / non-object / no arrays present', () => { + expect(normaliseSemanticSearchData(null)).toEqual([]); + expect(normaliseSemanticSearchData({})).toEqual([]); + }); +}); diff --git a/test/common/utils/paginate.test.ts b/test/common/utils/paginate.test.ts new file mode 100644 index 0000000..8521f80 --- /dev/null +++ b/test/common/utils/paginate.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test, vi } from 'vitest'; +import { paginate, paginateAll } from '../../../src/common/utils/paginate.js'; +import { OMOPHubIteratorError } from '../../../src/errors.js'; +import type { Response as OMOPHubResponse } from '../../../src/interfaces.js'; + +function successPage(items: T[], hasNext: boolean, page = 1, pageSize = 100) { + return Promise.resolve({ + data: { + data: items, + meta: { + pagination: { + page, + page_size: pageSize, + total_items: hasNext ? page * pageSize + 1 : items.length, + total_pages: hasNext ? page + 1 : page, + has_next: hasNext, + has_previous: page > 1, + }, + }, + }, + error: null, + meta: null, + headers: {}, + } satisfies OMOPHubResponse<{ + data: T[]; + meta: { pagination: import('../../../src/common/interfaces/pagination.js').PaginationMeta }; + }>); +} + +function errorPage(name: string, message: string, status: number | null = 500) { + return Promise.resolve({ + data: null, + error: { name: name as never, message, statusCode: status }, + meta: null, + headers: {}, + } as OMOPHubResponse); +} + +describe('paginate (async generator)', () => { + test('yields items across multiple pages until has_next === false', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => successPage([1, 2], true, 1, s)) + .mockImplementationOnce((_p, s) => successPage([3, 4], false, 2, s)); + + const out: number[] = []; + for await (const item of paginate(fetchPage, { pageSize: 2 })) { + out.push(item); + } + expect(out).toEqual([1, 2, 3, 4]); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + test('respects maxPages', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => successPage([1], true, 1, s)) + .mockImplementationOnce((_p, s) => successPage([2], true, 2, s)) + .mockImplementationOnce((_p, s) => successPage([3], true, 3, s)); + const out: number[] = []; + for await (const item of paginate(fetchPage, { pageSize: 1, maxPages: 2 })) { + out.push(item); + } + expect(out).toEqual([1, 2]); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + test('throws OMOPHubIteratorError on page failure', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => successPage([1, 2], true, 1, s)) + .mockImplementationOnce(() => errorPage('service_unavailable', 'down', 503)); + + const gen = paginate(fetchPage, { pageSize: 2 }); + const collected: number[] = []; + let caught: unknown; + try { + for await (const item of gen) collected.push(item); + } catch (e) { + caught = e; + } + expect(collected).toEqual([1, 2]); + expect(caught).toBeInstanceOf(OMOPHubIteratorError); + expect((caught as OMOPHubIteratorError).code).toBe('service_unavailable'); + expect((caught as OMOPHubIteratorError).statusCode).toBe(503); + }); + + test('stops on empty page even when has_next is true', async () => { + const fetchPage = vi.fn().mockImplementationOnce((_p, s) => successPage([], true, 1, s)); + const out: number[] = []; + for await (const item of paginate(fetchPage, { pageSize: 2 })) out.push(item); + expect(out).toEqual([]); + expect(fetchPage).toHaveBeenCalledTimes(1); + }); +}); + +describe('paginateAll (eager collect)', () => { + test('accumulates items across pages and returns errors as values', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => successPage(['a', 'b'], true, 1, s)) + .mockImplementationOnce(() => errorPage('rate_limit_exceeded', 'slow down', 429)); + + const result = await paginateAll(fetchPage, { pageSize: 2 }); + expect(result.data).toEqual(['a', 'b']); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.name).toBe('rate_limit_exceeded'); + expect(result.pagesFetched).toBe(2); + }); + + test('walks every page when no errors', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => successPage(['a'], true, 1, s)) + .mockImplementationOnce((_p, s) => successPage(['b'], false, 2, s)); + const result = await paginateAll(fetchPage, { pageSize: 1 }); + expect(result.data).toEqual(['a', 'b']); + expect(result.errors).toEqual([]); + expect(result.pagesFetched).toBe(2); + }); +}); + +describe('hasNextPage — outer envelope pagination', () => { + // Some endpoints return a bare `T[]` as `response.data` and carry + // pagination on the outer `response.meta.pagination` (the canonical + // location per SDK convention). The pagination helper must honour that + // — earlier behaviour hard-coded arrays as non-paginated. + function bareArrayPage(items: T[], hasNext: boolean, page = 1, pageSize = 100) { + return Promise.resolve({ + data: items, + error: null, + meta: { + pagination: { + page, + page_size: pageSize, + total_items: hasNext ? page * pageSize + 1 : items.length, + total_pages: hasNext ? page + 1 : page, + has_next: hasNext, + has_previous: page > 1, + }, + }, + headers: {}, + } as unknown as OMOPHubResponse); + } + + test('paginate walks pages when data is bare array + outer meta says has_next', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => bareArrayPage([1, 2], true, 1, s)) + .mockImplementationOnce((_p, s) => bareArrayPage([3, 4], false, 2, s)); + const collected: number[] = []; + for await (const n of paginate(fetchPage, { pageSize: 2 })) collected.push(n); + expect(collected).toEqual([1, 2, 3, 4]); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + test('paginateAll walks pages when data is bare array + outer meta says has_next', async () => { + const fetchPage = vi + .fn() + .mockImplementationOnce((_p, s) => bareArrayPage(['a', 'b'], true, 1, s)) + .mockImplementationOnce((_p, s) => bareArrayPage(['c'], false, 2, s)); + const result = await paginateAll(fetchPage, { pageSize: 2 }); + expect(result.data).toEqual(['a', 'b', 'c']); + expect(result.pagesFetched).toBe(2); + }); +}); diff --git a/test/common/utils/parse-error.test.ts b/test/common/utils/parse-error.test.ts new file mode 100644 index 0000000..bec699f --- /dev/null +++ b/test/common/utils/parse-error.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from 'vitest'; +import { + connectionError, + parseErrorResponse, + timeoutError, +} from '../../../src/common/utils/parse-error.js'; + +function makeResponse( + status: number, + body: unknown, + headers: Record = {}, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +describe('parseErrorResponse', () => { + test('maps 400 to validation_error', async () => { + const err = await parseErrorResponse( + makeResponse(400, { success: false, error: { message: 'bad input' } }), + ); + expect(err.name).toBe('validation_error'); + expect(err.message).toBe('bad input'); + expect(err.statusCode).toBe(400); + }); + + test('maps 401 to invalid_api_key', async () => { + const err = await parseErrorResponse( + makeResponse(401, { success: false, error: { message: 'bad key' } }), + ); + expect(err.name).toBe('invalid_api_key'); + }); + + test('maps 403 to restricted_api_key', async () => { + const err = await parseErrorResponse(makeResponse(403, { message: 'forbidden' })); + expect(err.name).toBe('restricted_api_key'); + }); + + test('maps 404 to not_found', async () => { + const err = await parseErrorResponse(makeResponse(404, { message: 'gone' })); + expect(err.name).toBe('not_found'); + }); + + test('maps 429 to rate_limit_exceeded and parses Retry-After', async () => { + const err = await parseErrorResponse( + makeResponse(429, { message: 'slow down' }, { 'retry-after': '15' }), + ); + expect(err.name).toBe('rate_limit_exceeded'); + expect(err.retryAfter).toBe(15); + }); + + test('maps 503 to service_unavailable', async () => { + const err = await parseErrorResponse(makeResponse(503, { message: 'down' })); + expect(err.name).toBe('service_unavailable'); + }); + + test('maps other 5xx to internal_server_error', async () => { + const err = await parseErrorResponse(makeResponse(500, { message: 'boom' })); + expect(err.name).toBe('internal_server_error'); + }); + + test('falls back to application_error for unmapped statuses', async () => { + const err = await parseErrorResponse(makeResponse(418, { message: 'teapot' })); + expect(err.name).toBe('application_error'); + }); + + test('captures x-request-id header', async () => { + const err = await parseErrorResponse( + makeResponse(400, { message: 'bad' }, { 'x-request-id': 'req_xyz' }), + ); + expect(err.requestId).toBe('req_xyz'); + }); + + test('captures details from error envelope', async () => { + const err = await parseErrorResponse( + makeResponse(400, { + success: false, + error: { message: 'bad', details: { field: 'concept_id' } }, + }), + ); + expect(err.details).toEqual({ field: 'concept_id' }); + }); + + test('handles non-JSON body gracefully', async () => { + const response = new Response('not json', { + status: 500, + headers: { 'content-type': 'text/plain' }, + }); + const err = await parseErrorResponse(response); + expect(err.name).toBe('internal_server_error'); + expect(err.message).toBe('HTTP 500'); + }); + + test('respects known error code from server when status is unmapped', async () => { + const err = await parseErrorResponse( + makeResponse(418, { + success: false, + error: { code: 'tier_limit_exceeded', message: 'upgrade' }, + }), + ); + expect(err.name).toBe('tier_limit_exceeded'); + }); + + test('body error code wins over the status map when both are present', async () => { + const err = await parseErrorResponse( + makeResponse(400, { + success: false, + error: { code: 'missing_required_field', message: 'no concept_id' }, + }), + ); + expect(err.name).toBe('missing_required_field'); + }); + + test('parses Retry-After on 503 (not just 429)', async () => { + const err = await parseErrorResponse( + makeResponse(503, { message: 'down' }, { 'retry-after': '30' }), + ); + expect(err.name).toBe('service_unavailable'); + expect(err.retryAfter).toBe(30); + }); +}); + +describe('connectionError', () => { + test('uses the Error message when available', () => { + const err = connectionError(new Error('socket hang up')); + expect(err.name).toBe('connection_error'); + expect(err.message).toBe('socket hang up'); + expect(err.statusCode).toBeNull(); + }); + + test('falls back to a default message for non-Error throws', () => { + const err = connectionError('weird'); + expect(err.message).toBe('Network error'); + }); +}); + +describe('timeoutError', () => { + test('returns a timeout_error with null statusCode', () => { + const err = timeoutError(); + expect(err.name).toBe('timeout_error'); + expect(err.statusCode).toBeNull(); + }); +}); diff --git a/test/common/utils/synthetic-error.test.ts b/test/common/utils/synthetic-error.test.ts new file mode 100644 index 0000000..4a7d14b --- /dev/null +++ b/test/common/utils/synthetic-error.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'vitest'; +import { syntheticError } from '../../../src/common/utils/synthetic-error.js'; + +describe('syntheticError', () => { + test('builds a Response with data null and the supplied error fields', () => { + const result = syntheticError<{ id: number }>('validation_error', 'bad input', { + field: 'concept_id', + }); + expect(result.data).toBeNull(); + expect(result.error?.name).toBe('validation_error'); + expect(result.error?.message).toBe('bad input'); + expect(result.error?.statusCode).toBeNull(); + expect(result.error?.details).toEqual({ field: 'concept_id' }); + expect(result.meta).toBeNull(); + expect(result.headers).toBeNull(); + }); + + test('omits details when not provided', () => { + const result = syntheticError('not_found', 'gone'); + expect(result.error?.details).toBeUndefined(); + }); +}); diff --git a/test/common/utils/to-snake-case.test.ts b/test/common/utils/to-snake-case.test.ts new file mode 100644 index 0000000..af45728 --- /dev/null +++ b/test/common/utils/to-snake-case.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'vitest'; +import { camelToSnakeCase, toSnakeCaseKeys } from '../../../src/common/utils/to-snake-case.js'; + +describe('camelToSnakeCase', () => { + test('converts camelCase to snake_case', () => { + expect(camelToSnakeCase('conceptId')).toBe('concept_id'); + expect(camelToSnakeCase('vocabularyIds')).toBe('vocabulary_ids'); + expect(camelToSnakeCase('includeRelationships')).toBe('include_relationships'); + }); + + test('leaves snake_case unchanged', () => { + expect(camelToSnakeCase('already_snake')).toBe('already_snake'); + }); + + test('handles single-word identifiers', () => { + expect(camelToSnakeCase('page')).toBe('page'); + }); + + test('treats consecutive uppercase as a single acronym', () => { + expect(camelToSnakeCase('FHIRResource')).toBe('fhir_resource'); + expect(camelToSnakeCase('XMLHttpRequest')).toBe('xml_http_request'); + }); + + test('preserves trailing uppercase runs', () => { + expect(camelToSnakeCase('userID')).toBe('user_id'); + expect(camelToSnakeCase('exportPDF')).toBe('export_pdf'); + }); + + test('splits a lone leading uppercase from the next word (lodash-compatible)', () => { + expect(camelToSnakeCase('OAuthToken')).toBe('o_auth_token'); + }); +}); + +describe('toSnakeCaseKeys', () => { + test('converts object keys recursively', () => { + const input = { + conceptId: 1, + includeRelationships: true, + nested: { sourceCodes: ['SNOMED:123'] }, + }; + expect(toSnakeCaseKeys(input)).toEqual({ + concept_id: 1, + include_relationships: true, + nested: { source_codes: ['SNOMED:123'] }, + }); + }); + + test('passes primitives through', () => { + expect(toSnakeCaseKeys(42)).toBe(42); + expect(toSnakeCaseKeys('hi')).toBe('hi'); + expect(toSnakeCaseKeys(null)).toBeNull(); + expect(toSnakeCaseKeys(undefined)).toBeUndefined(); + }); + + test('walks arrays of objects', () => { + const input = [{ conceptId: 1 }, { conceptId: 2 }]; + expect(toSnakeCaseKeys(input)).toEqual([{ concept_id: 1 }, { concept_id: 2 }]); + }); + + test('preserves Date instances rather than collapsing them to {}', () => { + const d = new Date('2026-05-29T00:00:00Z'); + const input = { startedAt: d, count: 5 }; + const out = toSnakeCaseKeys(input); + expect(out).toEqual({ started_at: d, count: 5 }); + expect(out.started_at).toBeInstanceOf(Date); + }); + + test('preserves Map and Set instances', () => { + const m = new Map([['a', 1]]); + const s = new Set([1, 2]); + const out = toSnakeCaseKeys({ someMap: m, someSet: s }); + expect(out.some_map).toBe(m); + expect(out.some_set).toBe(s); + }); + + test('preserves user class instances', () => { + class Coding { + constructor( + public system: string, + public code: string, + ) {} + } + const c = new Coding('http://snomed.info/sct', '44054006'); + const out = toSnakeCaseKeys({ sourceCoding: c }); + expect(out.source_coding).toBe(c); + expect(out.source_coding).toBeInstanceOf(Coding); + }); + + test('does not let a `__proto__` key mutate the result prototype', () => { + // `JSON.parse` produces an own `__proto__` property (vs. an object + // literal, which would set [[Prototype]]). A naive `out['__proto__'] = v` + // would invoke Object.prototype's setter and replace the result's + // prototype with `v`. + const malicious = JSON.parse('{"__proto__":{"polluted":true}}'); + const out = toSnakeCaseKeys(malicious) as Record; + expect((out as { polluted?: unknown }).polluted).toBeUndefined(); + // Object.prototype must remain unaffected globally either way. + expect(({} as { polluted?: unknown }).polluted).toBeUndefined(); + }); +}); diff --git a/test/common/utils/unwrap-envelope.test.ts b/test/common/utils/unwrap-envelope.test.ts new file mode 100644 index 0000000..e9cc1f3 --- /dev/null +++ b/test/common/utils/unwrap-envelope.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'vitest'; +import { unwrapEnvelope } from '../../../src/common/utils/unwrap-envelope.js'; + +describe('unwrapEnvelope', () => { + test('extracts data and meta from a standard envelope', () => { + const body = { + success: true, + data: { vocabulary_id: 'SNOMED' }, + meta: { request_id: 'req_1', timestamp: '2026-05-29T00:00:00Z', vocab_release: '2025.1' }, + }; + const { data, meta } = unwrapEnvelope(body); + expect(data).toEqual({ vocabulary_id: 'SNOMED' }); + expect(meta).toEqual({ + request_id: 'req_1', + timestamp: '2026-05-29T00:00:00Z', + vocab_release: '2025.1', + }); + }); + + test('extracts pagination from meta', () => { + const body = { + data: [{ id: 1 }], + meta: { + pagination: { page: 1, page_size: 20, total_items: 1, total_pages: 1, has_next: false }, + }, + }; + const { data, meta } = unwrapEnvelope(body); + expect(data).toEqual([{ id: 1 }]); + expect(meta?.pagination).toEqual({ + page: 1, + page_size: 20, + total_items: 1, + total_pages: 1, + has_next: false, + }); + }); + + test('returns null meta when envelope has success but no meta', () => { + expect(unwrapEnvelope({ success: true, data: 42 })).toEqual({ data: 42, meta: null }); + }); + + test('treats raw payload as data when no `data` key is present', () => { + expect(unwrapEnvelope([1, 2, 3])).toEqual({ data: [1, 2, 3], meta: null }); + expect(unwrapEnvelope('plain')).toEqual({ data: 'plain', meta: null }); + }); + + test('does NOT unwrap a payload that happens to have a `data` key but no envelope sibling', () => { + // A user-shaped payload like `{ data: [...] }` with no success/meta is + // returned as-is so we never accidentally peel a layer the API didn't add. + const userPayload = { data: [{ concept_id: 1 }] }; + expect(unwrapEnvelope(userPayload)).toEqual({ data: userPayload, meta: null }); + }); + + test('handles null body', () => { + expect(unwrapEnvelope(null)).toEqual({ data: null, meta: null }); + }); +}); diff --git a/test/concepts/concepts.test.ts b/test/concepts/concepts.test.ts new file mode 100644 index 0000000..5f5c644 --- /dev/null +++ b/test/concepts/concepts.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { + DIABETES_CONCEPT_ID, + mockApiErrorBody, + mockConcept, + mockPagination, +} from '../fixtures/index.js'; +import { createMockFetch, enqueueError, enqueueSuccess, lastCall } from '../helpers/mock-fetch.js'; + +describe('client.concepts.get', () => { + test('hits GET /concepts/{id}', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockConcept()); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.concepts.get(DIABETES_CONCEPT_ID); + expect(error).toBeNull(); + expect(data?.concept_id).toBe(DIABETES_CONCEPT_ID); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/concepts/201826'); + }); + + test('accepts concept_id = 0 (the unmapped sentinel; R-SDK bug fix)', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockConcept({ concept_id: 0, concept_name: 'No matching concept' })); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.concepts.get(0); + expect(error).toBeNull(); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/concepts/0'); + }); + + test('serialises include_* flags + vocab_release as snake-case query params', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockConcept()); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.get(DIABETES_CONCEPT_ID, { + includeRelationships: true, + includeSynonyms: true, + includeHierarchy: false, + vocabRelease: '2025.1', + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('include_relationships=true'); + expect(url).toContain('include_synonyms=true'); + expect(url).toContain('include_hierarchy=false'); + expect(url).toContain('vocab_release=2025.1'); + }); + + test('returns ErrorResponse on 404', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'concept missing')); + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { data, error } = await client.concepts.get(9_999_999); + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + }); +}); + +describe('client.concepts.getByCode', () => { + test('hits GET /concepts/by-code/{vocab}/{code} with URL-encoding', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockConcept()); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.getByCode('SNOMED', '44054006'); + expect(lastCall(fetchMock).url).toBe( + 'https://api.omophub.com/v1/concepts/by-code/SNOMED/44054006', + ); + }); + + test('URL-encodes a concept_code that contains a slash', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockConcept()); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.getByCode('LOINC', '8480-6/8462-4'); + expect(lastCall(fetchMock).url).toBe( + 'https://api.omophub.com/v1/concepts/by-code/LOINC/8480-6%2F8462-4', + ); + }); +}); + +describe('client.concepts.batch', () => { + test('hits POST /concepts/batch with snake-cased body', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { concepts: [mockConcept()] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.batch({ + conceptIds: [201826, 1112807], + includeRelationships: true, + vocabularyFilter: ['SNOMED', 'RxNorm'], + standardOnly: false, + }); + const { init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + concept_ids: [201826, 1112807], + include_relationships: true, + vocabulary_filter: ['SNOMED', 'RxNorm'], + standard_only: false, + }); + }); + + test('rejects empty conceptIds synthetically without hitting the network', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.concepts.batch({ conceptIds: [] }); + expect(data).toBeNull(); + expect(error?.name).toBe('validation_error'); + expect(error?.message).toMatch(/1–100/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects >100 conceptIds synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const tooMany = Array.from({ length: 101 }, (_, i) => i + 1); + const { error } = await client.concepts.batch({ conceptIds: tooMany }); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('returns validation_error (not TypeError) when JS caller omits conceptIds', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + // Simulates a JS caller / `as any` user passing a malformed payload. + const { data, error } = await client.concepts.batch( + {} as unknown as Parameters[0], + ); + expect(data).toBeNull(); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('returns validation_error when conceptIds is not an array', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.concepts.batch({ + conceptIds: 'not-an-array' as unknown as number[], + }); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('forwards idempotencyKey to the Idempotency-Key header', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { concepts: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.batch({ + conceptIds: [1], + idempotencyKey: 'idem_batch_1', + }); + expect(new Headers(lastCall(fetchMock).init.headers).get('idempotency-key')).toBe( + 'idem_batch_1', + ); + }); +}); + +describe('client.concepts.suggest', () => { + test('hits GET /concepts/suggest with paginated query', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + data: [{ suggestion: 'diabetes', concept_id: 201826 }], + meta: { pagination: mockPagination() }, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.suggest('diab', { + pageSize: 5, + vocabularyIds: ['SNOMED'], + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/suggest'); + expect(url).toContain('query=diab'); + expect(url).toContain('page_size=5'); + expect(url).toContain('vocabulary_ids=SNOMED'); + }); + + test('positional `query` wins over a `query` key inside options.query', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [], meta: { pagination: mockPagination() } }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.suggest('diab', { + query: { query: 'override-attempt', trace: 'on' }, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('query=diab'); + expect(url).not.toContain('query=override-attempt'); + expect(url).toContain('trace=on'); + }); +}); + +describe('client.concepts.related', () => { + test('hits GET /concepts/{id}/related', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { related_concepts: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.related(201826, { + relationshipTypes: ['Maps to', 'Is a'], + minScore: 0.5, + pageSize: 20, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/201826/related'); + expect(url).toContain('relationship_types=Maps+to%2CIs+a'); + expect(url).toContain('min_score=0.5'); + }); +}); + +describe('client.concepts.relationships', () => { + test('hits GET /concepts/{id}/relationships (shared endpoint with relationships.get)', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { relationships: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.relationships(201826, { + relationshipIds: ['Maps to'], + standardOnly: true, + includeReverse: true, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/201826/relationships'); + expect(url).toContain('relationship_ids=Maps+to'); + expect(url).toContain('standard_only=true'); + expect(url).toContain('include_reverse=true'); + }); +}); + +describe('client.concepts.recommended', () => { + test('hits POST /concepts/recommended with body + page query params', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { recommendations: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.concepts.recommended({ + conceptIds: [201826], + relationshipTypes: ['Is a'], + standardOnly: true, + page: 1, + pageSize: 100, + }); + const { url, init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + expect(url).toContain('page=1'); + expect(url).toContain('page_size=100'); + const body = JSON.parse(init.body as string); + expect(body.concept_ids).toEqual([201826]); + expect(body.relationship_types).toEqual(['Is a']); + expect(body.standard_only).toBe(true); + }); + + test('rejects >100 conceptIds, >20 relationshipTypes, >50 vocab/domain ids synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + + expect((await client.concepts.recommended({ conceptIds: [] })).error?.name).toBe( + 'validation_error', + ); + + // JS-caller path: conceptIds missing entirely. + expect( + ( + await client.concepts.recommended( + {} as unknown as Parameters[0], + ) + ).error?.name, + ).toBe('validation_error'); + + expect( + ( + await client.concepts.recommended({ + conceptIds: [1], + relationshipTypes: Array.from({ length: 21 }, (_, i) => `r${i}`), + }) + ).error?.message, + ).toMatch(/relationshipTypes.*20/); + + expect( + ( + await client.concepts.recommended({ + conceptIds: [1], + vocabularyIds: Array.from({ length: 51 }, (_, i) => `v${i}`), + }) + ).error?.message, + ).toMatch(/vocabularyIds.*50/); + + expect( + ( + await client.concepts.recommended({ + conceptIds: [1], + domainIds: Array.from({ length: 51 }, (_, i) => `d${i}`), + }) + ).error?.message, + ).toMatch(/domainIds.*50/); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/test/domains/domains.test.ts b/test/domains/domains.test.ts new file mode 100644 index 0000000..6e60591 --- /dev/null +++ b/test/domains/domains.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { mockApiErrorBody, mockDomain, mockPagination } from '../fixtures/index.js'; +import { createMockFetch, enqueueError, enqueueSuccess, lastCall } from '../helpers/mock-fetch.js'; + +describe('client.domains.list', () => { + test('hits GET /domains', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [mockDomain()]); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.domains.list(); + expect(error).toBeNull(); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/domains'); + expect(data).toEqual([mockDomain()]); + }); + + test('appends include_stats when requested', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [mockDomain()]); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.domains.list({ includeStats: true }); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/domains?include_stats=true'); + }); +}); + +describe('client.domains.concepts', () => { + test('hits GET /domains/{id}/concepts with snake-cased filters', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [], meta: { pagination: mockPagination() } }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.domains.concepts('Condition', { + vocabularyIds: ['SNOMED', 'ICD10CM'], + standardOnly: true, + includeInvalid: false, + page: 2, + pageSize: 50, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/domains/Condition/concepts'); + expect(url).toContain('vocabulary_ids=SNOMED%2CICD10CM'); + expect(url).toContain('standard_only=true'); + expect(url).toContain('include_invalid=false'); + expect(url).toContain('page=2'); + expect(url).toContain('page_size=50'); + }); + + test('URL-encodes the domain ID', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [], meta: { pagination: mockPagination() } }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.domains.concepts('Drug Exposure'); + expect(lastCall(fetchMock).url).toBe( + 'https://api.omophub.com/v1/domains/Drug%20Exposure/concepts', + ); + }); + + test('returns ErrorResponse on 404 for unknown domain', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'unknown domain')); + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { data, error } = await client.domains.concepts('Nope'); + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + }); +}); diff --git a/test/errors.test.ts b/test/errors.test.ts new file mode 100644 index 0000000..5144c39 --- /dev/null +++ b/test/errors.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHubError, OMOPHubIteratorError } from '../src/errors.js'; + +describe('OMOPHubError', () => { + test('is an Error subclass with the right name', () => { + const err = new OMOPHubError('boom'); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('OMOPHubError'); + expect(err.message).toBe('boom'); + }); +}); + +describe('OMOPHubIteratorError', () => { + test('carries statusCode and code fields', () => { + const err = new OMOPHubIteratorError('rate limited', 429, 'rate_limit_exceeded'); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('OMOPHubIteratorError'); + expect(err.statusCode).toBe(429); + expect(err.code).toBe('rate_limit_exceeded'); + }); + + test('accepts a null statusCode for client-side failures', () => { + const err = new OMOPHubIteratorError('lost connection', null, 'connection_error'); + expect(err.statusCode).toBeNull(); + expect(err.code).toBe('connection_error'); + }); +}); diff --git a/test/fhir/fhir-url.test.ts b/test/fhir/fhir-url.test.ts new file mode 100644 index 0000000..434bae3 --- /dev/null +++ b/test/fhir/fhir-url.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'vitest'; +import { omophubFhirUrl } from '../../src/fhir/fhir-url.js'; + +describe('omophubFhirUrl', () => { + test('defaults to r4', () => { + expect(omophubFhirUrl()).toBe('https://fhir.omophub.com/fhir/r4'); + }); + + test('accepts r4b, r5, r6', () => { + expect(omophubFhirUrl('r4b')).toBe('https://fhir.omophub.com/fhir/r4b'); + expect(omophubFhirUrl('r5')).toBe('https://fhir.omophub.com/fhir/r5'); + expect(omophubFhirUrl('r6')).toBe('https://fhir.omophub.com/fhir/r6'); + }); +}); diff --git a/test/fhir/fhir.test.ts b/test/fhir/fhir.test.ts new file mode 100644 index 0000000..77c4dd7 --- /dev/null +++ b/test/fhir/fhir.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { createMockFetch, enqueueSuccess, lastCall } from '../helpers/mock-fetch.js'; + +const SNOMED_SYSTEM = 'http://snomed.info/sct'; +const DIABETES_CODE = '44054006'; + +const mockResolution = { + input: { system: SNOMED_SYSTEM, code: DIABETES_CODE }, + resolution: { + source_concept: { + concept_id: 1, + concept_name: '', + vocabulary_id: 'SNOMED', + concept_code: DIABETES_CODE, + }, + standard_concept: { + concept_id: 201826, + concept_name: 'Type 2 diabetes mellitus', + vocabulary_id: 'SNOMED', + concept_code: DIABETES_CODE, + }, + mapping_type: 'direct', + target_table: 'condition_occurrence', + }, +}; + +describe('client.fhir.resolve', () => { + test('hits POST /fhir/resolve with flat-form coding fields', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.fhir.resolve({ + system: SNOMED_SYSTEM, + code: DIABETES_CODE, + resourceType: 'Condition', + includeRecommendations: true, + recommendationsLimit: 3, + }); + const { init, url } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + expect(url).toBe('https://api.omophub.com/v1/fhir/resolve'); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + system: SNOMED_SYSTEM, + code: DIABETES_CODE, + resource_type: 'Condition', + include_recommendations: true, + recommendations_limit: 3, + }); + }); + + test('accepts nested coding-object form', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.fhir.resolve({ + coding: { + system: SNOMED_SYSTEM, + code: DIABETES_CODE, + display: 'Type 2 diabetes', + userSelected: true, + }, + resourceType: 'Condition', + }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body).toEqual({ + system: SNOMED_SYSTEM, + code: DIABETES_CODE, + display: 'Type 2 diabetes', + user_selected: true, + resource_type: 'Condition', + }); + }); + + test('flat fields override coding-object fields when both supplied', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + // Provide flat values for every overlapping field — flat must win + // across the board, not just the field the caller "happens to also pass". + await client.fhir.resolve({ + coding: { + system: 'old-system', + code: 'old-code', + display: 'old-display', + vocabularyId: 'OLD_VOCAB', + }, + system: SNOMED_SYSTEM, + code: DIABETES_CODE, + display: 'Type 2 diabetes (flat)', + vocabularyId: 'SNOMED', + }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body.system).toBe(SNOMED_SYSTEM); + expect(body.code).toBe(DIABETES_CODE); + expect(body.display).toBe('Type 2 diabetes (flat)'); + expect(body.vocabulary_id).toBe('SNOMED'); + }); + + test('flat fields inherit from coding only when the flat value is absent', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.fhir.resolve({ + coding: { + system: SNOMED_SYSTEM, + code: 'coding-code', + display: 'inherited-from-coding', + }, + code: DIABETES_CODE, // only `code` provided flat + }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body.code).toBe(DIABETES_CODE); // flat wins + expect(body.system).toBe(SNOMED_SYSTEM); // inherited from coding + expect(body.display).toBe('inherited-from-coding'); // inherited from coding + }); + + test('rejects synthetically when neither flat code nor coding.code is provided', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolve({ system: 'foo' }); + expect(error?.name).toBe('missing_required_field'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects coding-object without a code', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolve({ coding: { system: 'foo' } }); + expect(error?.name).toBe('missing_required_field'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects when coding is mistakenly passed as an array (JS-caller hardening)', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + // A caller passing `coding: [...]` (mistaking for FHIR `CodeableConcept.coding`) + // would otherwise slip past the object check since `typeof [] === 'object'`. + const { error } = await client.fhir.resolve({ + coding: [{ system: SNOMED_SYSTEM, code: DIABETES_CODE }] as unknown as Parameters< + typeof client.fhir.resolve + >[0]['coding'], + }); + expect(error?.name).toBe('missing_required_field'); + expect(error?.statusCode).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects empty string code (JS-caller hardening)', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolve({ code: '' }); + expect(error?.name).toBe('missing_required_field'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('accepts top-level `display` alone for semantic fallback', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolve({ display: 'blood glucose' }); + expect(error).toBeNull(); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + test('accepts nested `coding.display` alone for semantic fallback', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolve({ coding: { display: 'blood glucose' } }); + expect(error).toBeNull(); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + test('passes onUnmapped through as snake_case', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockResolution); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.fhir.resolve({ + code: DIABETES_CODE, + onUnmapped: 'sentinel', + }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body.on_unmapped).toBe('sentinel'); + }); +}); + +describe('client.fhir.resolveBatch', () => { + test('hits POST /fhir/resolve/batch with 1-100 codings', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { results: [], summary: { total: 2, resolved: 2, failed: 0 } }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.fhir.resolveBatch( + [ + { system: SNOMED_SYSTEM, code: DIABETES_CODE }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9' }, + ], + { resourceType: 'Condition', includeQuality: true }, + ); + const { url, init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + expect(url).toBe('https://api.omophub.com/v1/fhir/resolve/batch'); + const body = JSON.parse(init.body as string); + expect(body.codings).toHaveLength(2); + expect(body.resource_type).toBe('Condition'); + expect(body.include_quality).toBe(true); + }); + + test('rejects empty array synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolveBatch([]); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects >100 codings synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const tooMany = Array.from({ length: 101 }, (_, i) => ({ + system: SNOMED_SYSTEM, + code: `${i}`, + })); + const { error } = await client.fhir.resolveBatch(tooMany); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('client.fhir.resolveCodeableConcept', () => { + test('hits POST /fhir/resolve/codeable-concept with 1-20 codings', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { input: {}, alternatives: [], unresolved: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.fhir.resolveCodeableConcept( + [ + { system: SNOMED_SYSTEM, code: DIABETES_CODE }, + { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9', userSelected: true }, + ], + { text: 'Type 2 diabetes', resourceType: 'Condition' }, + ); + const { url, init } = lastCall(fetchMock); + expect(url).toBe('https://api.omophub.com/v1/fhir/resolve/codeable-concept'); + const body = JSON.parse(init.body as string); + expect(body.coding).toHaveLength(2); + expect(body.coding[1].user_selected).toBe(true); + expect(body.text).toBe('Type 2 diabetes'); + expect(body.resource_type).toBe('Condition'); + }); + + test('rejects >20 codings synthetically (lower cap than resolveBatch)', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const tooMany = Array.from({ length: 21 }, (_, i) => ({ + system: SNOMED_SYSTEM, + code: `${i}`, + })); + const { error } = await client.fhir.resolveCodeableConcept(tooMany); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects empty array synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.fhir.resolveCodeableConcept([]); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/test/fixtures/index.ts b/test/fixtures/index.ts new file mode 100644 index 0000000..a474b6d --- /dev/null +++ b/test/fixtures/index.ts @@ -0,0 +1,68 @@ +import type { PaginationMeta } from '../../src/common/interfaces/pagination.js'; +import type { Concept } from '../../src/concepts/interfaces/concept.js'; +import type { Domain } from '../../src/domains/interfaces/domain.js'; +import type { Vocabulary } from '../../src/vocabularies/interfaces/vocabulary.js'; + +export const DIABETES_CONCEPT_ID = 201826; +export const ASPIRIN_CONCEPT_ID = 1112807; +export const HYPERTENSION_CONCEPT_ID = 316866; +export const COVID_CONCEPT_ID = 37311061; +export const MOCK_API_KEY = 'oh_test_key_12345'; + +export const mockVocabulary = (overrides: Partial = {}): Vocabulary => ({ + vocabulary_id: 'SNOMED', + vocabulary_name: 'SNOMED CT', + vocabulary_concept_id: 44819097, + vocabulary_reference: 'SNOMED CT International Edition', + vocabulary_version: '2024-09-01', + ...overrides, +}); + +export const mockPagination = (overrides: Partial = {}): PaginationMeta => ({ + page: 1, + page_size: 20, + total_items: 1, + total_pages: 1, + has_next: false, + has_previous: false, + ...overrides, +}); + +export const mockApiEnvelope = (data: T, meta: Record = {}) => ({ + success: true as const, + data, + meta: { + request_id: 'req_test_12345', + timestamp: '2026-05-29T00:00:00Z', + ...meta, + }, +}); + +export const mockApiErrorBody = ( + code: string, + message: string, + details?: Record, +) => ({ + success: false as const, + error: details ? { code, message, details } : { code, message }, +}); + +export const mockConcept = (overrides: Partial = {}): Concept => ({ + concept_id: DIABETES_CONCEPT_ID, + concept_name: 'Type 2 diabetes mellitus', + vocabulary_id: 'SNOMED', + concept_code: '44054006', + domain_id: 'Condition', + concept_class_id: 'Clinical Finding', + standard_concept: 'S', + valid_start_date: '2002-01-31', + valid_end_date: '2099-12-31', + ...overrides, +}); + +export const mockDomain = (overrides: Partial = {}): Domain => ({ + domain_id: 'Condition', + domain_name: 'Condition', + domain_concept_id: 19, + ...overrides, +}); diff --git a/test/helpers/mock-fetch.ts b/test/helpers/mock-fetch.ts new file mode 100644 index 0000000..998f126 --- /dev/null +++ b/test/helpers/mock-fetch.ts @@ -0,0 +1,76 @@ +import { type Mock, vi } from 'vitest'; +import { mockApiEnvelope } from '../fixtures/index.js'; + +export type MockFetch = Mock; + +export function createMockFetch(): MockFetch { + return vi.fn() as MockFetch; +} + +export function enqueueSuccess( + fetchMock: MockFetch, + data: T, + headers: Record = {}, +): void { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(mockApiEnvelope(data)), { + status: 200, + headers: { 'content-type': 'application/json', ...headers }, + }), + ); +} + +export function enqueueRawBody( + fetchMock: MockFetch, + body: unknown, + status = 200, + headers: Record = {}, +): void { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }), + ); +} + +export function enqueueError( + fetchMock: MockFetch, + status: number, + // Default body omits `error.code` so the HTTP-status → error-name mapping + // in parseErrorResponse drives the outcome. Tests that want to assert + // a specific server-supplied code pass it explicitly. + body: unknown = { + success: false, + error: { message: `HTTP ${status}` }, + }, + headers: Record = {}, +): void { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }), + ); +} + +export function enqueueNetworkError( + fetchMock: MockFetch, + err: Error = new Error('ECONNREFUSED'), +): void { + fetchMock.mockRejectedValueOnce(err); +} + +export function lastCall(fetchMock: MockFetch): { url: string; init: RequestInit } { + const calls = fetchMock.mock.calls; + if (calls.length === 0) throw new Error('fetchMock has no calls'); + const call = calls[calls.length - 1]; + if (!call) throw new Error('fetchMock has no calls'); + const [arg0, arg1] = call; + const url = typeof arg0 === 'string' ? arg0 : (arg0 as URL).toString(); + return { url, init: (arg1 ?? {}) as RequestInit }; +} + +export function headersFromInit(init: RequestInit): Headers { + return new Headers(init.headers); +} diff --git a/test/hierarchy/hierarchy.test.ts b/test/hierarchy/hierarchy.test.ts new file mode 100644 index 0000000..9759b6b --- /dev/null +++ b/test/hierarchy/hierarchy.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { DIABETES_CONCEPT_ID, mockApiErrorBody } from '../fixtures/index.js'; +import { createMockFetch, enqueueError, enqueueSuccess, lastCall } from '../helpers/mock-fetch.js'; + +describe('client.hierarchy.get', () => { + test('hits GET /concepts/{id}/hierarchy', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { concepts: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.hierarchy.get(DIABETES_CONCEPT_ID); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/concepts/201826/hierarchy'); + }); + + test('snake-cases format, vocabularyIds, maxLevels, relationshipTypes, includeInvalid', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { nodes: [], edges: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.hierarchy.get(DIABETES_CONCEPT_ID, { + format: 'graph', + vocabularyIds: ['SNOMED', 'ICD10CM'], + maxLevels: 5, + relationshipTypes: ['Is a'], + includeInvalid: false, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('format=graph'); + expect(url).toContain('vocabulary_ids=SNOMED%2CICD10CM'); + expect(url).toContain('max_levels=5'); + expect(url).toContain('relationship_types=Is+a'); + expect(url).toContain('include_invalid=false'); + }); +}); + +describe('client.hierarchy.ancestors', () => { + test('hits GET /concepts/{id}/ancestors with pagination + flags', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { ancestors: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.hierarchy.ancestors(DIABETES_CONCEPT_ID, { + vocabularyIds: ['SNOMED'], + maxLevels: 10, + includePaths: true, + includeDistance: true, + page: 2, + pageSize: 50, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/201826/ancestors'); + expect(url).toContain('vocabulary_ids=SNOMED'); + expect(url).toContain('max_levels=10'); + expect(url).toContain('include_paths=true'); + expect(url).toContain('include_distance=true'); + expect(url).toContain('page=2'); + expect(url).toContain('page_size=50'); + }); +}); + +describe('client.hierarchy.descendants', () => { + test('hits GET /concepts/{id}/descendants', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { descendants: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.hierarchy.descendants(DIABETES_CONCEPT_ID, { + maxLevels: 3, + domainIds: ['Condition'], + includeDistance: false, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/201826/descendants'); + expect(url).toContain('max_levels=3'); + expect(url).toContain('domain_ids=Condition'); + expect(url).toContain('include_distance=false'); + }); + + test('returns ErrorResponse on 404', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'concept missing')); + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { data, error } = await client.hierarchy.descendants(9_999_999); + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + }); +}); diff --git a/test/mappings/mappings.test.ts b/test/mappings/mappings.test.ts new file mode 100644 index 0000000..52fc745 --- /dev/null +++ b/test/mappings/mappings.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { DIABETES_CONCEPT_ID, mockApiErrorBody } from '../fixtures/index.js'; +import { createMockFetch, enqueueError, enqueueSuccess, lastCall } from '../helpers/mock-fetch.js'; + +describe('client.mappings.get', () => { + test('hits GET /concepts/{id}/mappings', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { mappings: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.mappings.get(DIABETES_CONCEPT_ID, { + targetVocabulary: 'ICD10CM', + includeInvalid: false, + vocabRelease: '2025.1', + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/201826/mappings'); + expect(url).toContain('target_vocabulary=ICD10CM'); + expect(url).toContain('include_invalid=false'); + expect(url).toContain('vocab_release=2025.1'); + }); + + test('returns ErrorResponse on 404', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'no mappings')); + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { error } = await client.mappings.get(9_999_999); + expect(error?.name).toBe('not_found'); + }); +}); + +describe('client.mappings.map', () => { + test('POST /concepts/map with sourceConcepts variant', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { mappings: [], summary: { total_source_concepts: 2 } }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [201826, 1112807], + mappingType: 'direct', + includeInvalid: false, + }); + const { init, url } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + expect(url).toBe('https://api.omophub.com/v1/concepts/map'); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + target_vocabulary: 'SNOMED', + source_concepts: [201826, 1112807], + mapping_type: 'direct', + include_invalid: false, + }); + }); + + test('POST /concepts/map with sourceCodes variant', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { mappings: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceCodes: [ + { vocabulary_id: 'ICD10CM', concept_code: 'E11.9' }, + { vocabulary_id: 'ICD10CM', concept_code: 'I10' }, + ], + }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body.source_codes).toEqual([ + { vocabulary_id: 'ICD10CM', concept_code: 'E11.9' }, + { vocabulary_id: 'ICD10CM', concept_code: 'I10' }, + ]); + expect(body.source_concepts).toBeUndefined(); + }); + + test('vocabRelease is sent as a QUERY param (not in the JSON body)', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { mappings: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [201826], + vocabRelease: '2025.1', + }); + const { url, init } = lastCall(fetchMock); + expect(url).toContain('vocab_release=2025.1'); + const body = JSON.parse(init.body as string); + expect(body.vocab_release).toBeUndefined(); + expect(body.target_vocabulary).toBe('SNOMED'); + }); + + test('rejects zero-of XOR options synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + } as unknown as Parameters[0]); + expect(error?.name).toBe('missing_required_field'); + expect(error?.message).toMatch(/exactly one/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects both-of XOR options synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [1], + sourceCodes: [{ vocabulary_id: 'X', concept_code: 'Y' }], + } as unknown as Parameters[0]); + expect(error?.name).toBe('missing_required_field'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects empty sourceConcepts array synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [], + }); + expect(error?.name).toBe('missing_required_field'); + expect(error?.message).toMatch(/at least one entry/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects empty sourceCodes array synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceCodes: [], + }); + expect(error?.name).toBe('missing_required_field'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('forwards idempotencyKey to the Idempotency-Key header', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { mappings: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.mappings.map({ + targetVocabulary: 'SNOMED', + sourceConcepts: [201826], + idempotencyKey: 'idem_map_42', + }); + expect(new Headers(lastCall(fetchMock).init.headers).get('idempotency-key')).toBe( + 'idem_map_42', + ); + }); +}); diff --git a/test/relationships/relationships.test.ts b/test/relationships/relationships.test.ts new file mode 100644 index 0000000..6b2aea0 --- /dev/null +++ b/test/relationships/relationships.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { DIABETES_CONCEPT_ID } from '../fixtures/index.js'; +import { createMockFetch, enqueueSuccess, lastCall } from '../helpers/mock-fetch.js'; + +describe('client.relationships.get', () => { + test('hits GET /concepts/{id}/relationships', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { relationships: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.relationships.get(DIABETES_CONCEPT_ID, { + relationshipIds: ['Maps to', 'Is a'], + standardOnly: true, + includeReverse: false, + page: 1, + pageSize: 100, + vocabRelease: '2025.1', + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/201826/relationships'); + expect(url).toContain('relationship_ids=Maps+to%2CIs+a'); + expect(url).toContain('standard_only=true'); + expect(url).toContain('include_reverse=false'); + expect(url).toContain('page=1'); + expect(url).toContain('page_size=100'); + expect(url).toContain('vocab_release=2025.1'); + }); + + test('produces identical URL to concepts.relationships() for shared params', async () => { + const a = createMockFetch(); + const b = createMockFetch(); + enqueueSuccess(a, { relationships: [] }); + enqueueSuccess(b, { relationships: [] }); + const sharedOpts = { + relationshipIds: ['Maps to'], + vocabularyIds: ['SNOMED'], + includeReverse: true, + }; + + const clientA = new OMOPHub('oh_test', { fetch: a }); + const clientB = new OMOPHub('oh_test', { fetch: b }); + await clientA.relationships.get(DIABETES_CONCEPT_ID, sharedOpts); + await clientB.concepts.relationships(DIABETES_CONCEPT_ID, sharedOpts); + + expect(lastCall(a).url).toBe(lastCall(b).url); + }); +}); + +describe('client.relationships.types', () => { + test('hits GET /relationships/types with pagination', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { relationship_types: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.relationships.types({ page: 2, pageSize: 50 }); + const { url } = lastCall(fetchMock); + expect(url).toBe('https://api.omophub.com/v1/relationships/types?page=2&page_size=50'); + }); + + test('uses no query params by default', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { relationship_types: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.relationships.types(); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/relationships/types'); + }); +}); diff --git a/test/search/search.test.ts b/test/search/search.test.ts new file mode 100644 index 0000000..84976f2 --- /dev/null +++ b/test/search/search.test.ts @@ -0,0 +1,508 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { OMOPHubIteratorError } from '../../src/errors.js'; +import { + DIABETES_CONCEPT_ID, + mockApiErrorBody, + mockConcept, + mockPagination, +} from '../fixtures/index.js'; +import { + createMockFetch, + enqueueError, + enqueueRawBody, + enqueueSuccess, + lastCall, +} from '../helpers/mock-fetch.js'; + +describe('client.search.basic', () => { + test('hits GET /search/concepts with snake-cased filters', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { concepts: [mockConcept()] }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.search.basic('diabetes', { + vocabularyIds: ['SNOMED', 'ICD10CM'], + domainIds: ['Condition'], + standardConcept: 'S', + pageSize: 50, + sortBy: 'relevance', + }); + + expect(error).toBeNull(); + expect(data?.concepts).toEqual([mockConcept()]); + const { url } = lastCall(fetchMock); + expect(url).toContain('/search/concepts'); + expect(url).toContain('query=diabetes'); + expect(url).toContain('vocabulary_ids=SNOMED%2CICD10CM'); + expect(url).toContain('domain_ids=Condition'); + expect(url).toContain('standard_concept=S'); + expect(url).toContain('page_size=50'); + expect(url).toContain('sort_by=relevance'); + }); + + test('normalises legacy { data: [...] } shape into { concepts }', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [mockConcept()] }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data } = await client.search.basic('diabetes'); + expect(data?.concepts).toEqual([mockConcept()]); + }); + + test('normalises bare-array response into { concepts }', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [mockConcept()]); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data } = await client.search.basic('diabetes'); + expect(data?.concepts).toEqual([mockConcept()]); + }); + + test('positional `query` wins over options.query.query', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { concepts: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.basic('aspirin', { + query: { query: 'override-attempt', trace: 'on' }, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('query=aspirin'); + expect(url).not.toContain('query=override-attempt'); + expect(url).toContain('trace=on'); + }); + + test('returns ErrorResponse on 404', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'no results')); + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { data, error } = await client.search.basic('zzz-not-found'); + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + }); +}); + +describe('client.search.basicIter', () => { + test('yields concepts across multiple pages', async () => { + const fetchMock = createMockFetch(); + enqueueRawBody(fetchMock, { + success: true, + data: { + concepts: [mockConcept({ concept_id: 1 }), mockConcept({ concept_id: 2 })], + }, + meta: { pagination: mockPagination({ page: 1, page_size: 2, has_next: true }) }, + }); + enqueueRawBody(fetchMock, { + success: true, + data: { + concepts: [mockConcept({ concept_id: 3 })], + }, + meta: { pagination: mockPagination({ page: 2, page_size: 2, has_next: false }) }, + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const out: number[] = []; + for await (const c of client.search.basicIter('diabetes', { pageSize: 2 })) { + out.push(c.concept_id); + } + expect(out).toEqual([1, 2, 3]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('throws OMOPHubIteratorError when a page fails', async () => { + const fetchMock = createMockFetch(); + enqueueRawBody(fetchMock, { + success: true, + data: { concepts: [mockConcept()] }, + meta: { pagination: mockPagination({ has_next: true }) }, + }); + enqueueError(fetchMock, 503); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const out: number[] = []; + let caught: unknown; + try { + for await (const c of client.search.basicIter('diabetes', { pageSize: 1 })) { + out.push(c.concept_id); + } + } catch (e) { + caught = e; + } + expect(out).toEqual([DIABETES_CONCEPT_ID]); + expect(caught).toBeInstanceOf(OMOPHubIteratorError); + expect((caught as OMOPHubIteratorError).code).toBe('service_unavailable'); + }); +}); + +describe('client.search.basicAll', () => { + test('collects across pages into a flat data array', async () => { + const fetchMock = createMockFetch(); + enqueueRawBody(fetchMock, { + success: true, + data: { concepts: [mockConcept({ concept_id: 1 })] }, + meta: { pagination: mockPagination({ page: 1, page_size: 1, has_next: true }) }, + }); + enqueueRawBody(fetchMock, { + success: true, + data: { concepts: [mockConcept({ concept_id: 2 })] }, + meta: { pagination: mockPagination({ page: 2, page_size: 1, has_next: false }) }, + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, errors, pagesFetched } = await client.search.basicAll('diabetes', { + pageSize: 1, + }); + expect(data.map((c) => c.concept_id)).toEqual([1, 2]); + expect(errors).toEqual([]); + expect(pagesFetched).toBe(2); + }); + + test('respects maxPages', async () => { + const fetchMock = createMockFetch(); + enqueueRawBody(fetchMock, { + success: true, + data: { concepts: [mockConcept({ concept_id: 1 })] }, + meta: { pagination: mockPagination({ page: 1, page_size: 1, has_next: true }) }, + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { pagesFetched } = await client.search.basicAll('diabetes', { + pageSize: 1, + maxPages: 1, + }); + expect(pagesFetched).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('client.search.advanced', () => { + test('hits POST /search/advanced with snake-cased body', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { concepts: [mockConcept()] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.advanced('diabetes type 2', { + vocabularyIds: ['SNOMED'], + standardConceptsOnly: true, + relationshipFilters: [{ relationshipId: 'Is a', targetConceptId: 201826 }], + }); + const { init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + query: 'diabetes type 2', + vocabulary_ids: ['SNOMED'], + standard_concepts_only: true, + relationship_filters: [{ relationship_id: 'Is a', target_concept_id: 201826 }], + }); + }); +}); + +describe('client.search.autocomplete', () => { + test('hits GET /search/suggest with positional query', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [{ suggestion: 'diabetes', concept_id: 201826 }]); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.search.autocomplete('diab', { + vocabularyIds: ['SNOMED'], + pageSize: 5, + }); + expect(error).toBeNull(); + expect(data).toEqual([{ suggestion: 'diabetes', concept_id: 201826 }]); + const { url } = lastCall(fetchMock); + expect(url).toContain('/search/suggest'); + expect(url).toContain('query=diab'); + expect(url).toContain('vocabulary_ids=SNOMED'); + expect(url).toContain('page_size=5'); + }); +}); + +describe('client.search.semantic', () => { + test('hits GET /concepts/semantic-search with snake-cased query', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { results: [], search_metadata: {} }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.semantic('high blood sugar', { + vocabularyIds: ['SNOMED'], + standardConcept: 'S', + threshold: 0.85, + pageSize: 20, + }); + const { url } = lastCall(fetchMock); + expect(url).toContain('/concepts/semantic-search'); + expect(url).toContain('query=high+blood+sugar'); + expect(url).toContain('vocabulary_ids=SNOMED'); + expect(url).toContain('standard_concept=S'); + expect(url).toContain('threshold=0.85'); + }); + + test('normalises a bare-array response into { results: [...] }', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [ + { + concept_id: 1, + concept_name: 'A', + vocabulary_id: 'X', + concept_code: 'a', + similarity_score: 0.9, + }, + ]); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.search.semantic('q'); + expect(error).toBeNull(); + expect(data?.results).toHaveLength(1); + expect(data?.results[0]?.concept_id).toBe(1); + }); + + test('normalises legacy { data: [...] } shape into { results: [...] }', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + data: [ + { + concept_id: 2, + concept_name: 'B', + vocabulary_id: 'X', + concept_code: 'b', + similarity_score: 0.7, + }, + ], + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data } = await client.search.semantic('q'); + expect(data?.results).toHaveLength(1); + expect(data?.results[0]?.concept_id).toBe(2); + }); +}); + +describe('client.search.semanticIter', () => { + test('yields semantic results across pages, unwrapping the { results: [...] } shape', async () => { + const fetchMock = createMockFetch(); + enqueueRawBody(fetchMock, { + success: true, + data: { + results: [ + { + concept_id: 1, + concept_name: 'A', + vocabulary_id: 'X', + concept_code: 'a', + similarity_score: 0.9, + }, + ], + }, + meta: { pagination: mockPagination({ page: 1, page_size: 1, has_next: true }) }, + }); + enqueueRawBody(fetchMock, { + success: true, + data: { + results: [ + { + concept_id: 2, + concept_name: 'B', + vocabulary_id: 'X', + concept_code: 'b', + similarity_score: 0.8, + }, + ], + }, + meta: { pagination: mockPagination({ page: 2, page_size: 1, has_next: false }) }, + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const out: number[] = []; + for await (const r of client.search.semanticIter('blood sugar', { pageSize: 1 })) { + out.push(r.concept_id); + } + expect(out).toEqual([1, 2]); + }); +}); + +describe('client.search.bulkBasic', () => { + test('hits POST /search/bulk with snake-cased body', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + results: [], + total_searches: 2, + completed_searches: 2, + failed_searches: 0, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.bulkBasic( + [ + { search_id: 'q1', query: 'diabetes' }, + { search_id: 'q2', query: 'hypertension' }, + ], + { defaults: { vocabulary_ids: ['SNOMED'], page_size: 5 } }, + ); + const { init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body.searches).toHaveLength(2); + expect(body.searches[0]).toEqual({ search_id: 'q1', query: 'diabetes' }); + expect(body.defaults).toEqual({ vocabulary_ids: ['SNOMED'], page_size: 5 }); + }); + + test('rejects >50 searches synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const tooMany = Array.from({ length: 51 }, (_, i) => ({ + search_id: `q${i}`, + query: `${i}`, + })); + const { error } = await client.search.bulkBasic(tooMany); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects empty searches synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.search.bulkBasic([]); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('client.search.bulkSemantic', () => { + test('hits POST /search/semantic-bulk with snake-cased body', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + results: [], + total_searches: 1, + completed_count: 1, + failed_count: 0, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.bulkSemantic([{ search_id: 'sq1', query: 'blood sugar', threshold: 0.8 }]); + const { init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body.searches[0].threshold).toBe(0.8); + }); + + test('rejects >25 searches synthetically (lower cap than bulkBasic)', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const tooMany = Array.from({ length: 26 }, (_, i) => ({ + search_id: `q${i}`, + query: `${i}`, + })); + const { error } = await client.search.bulkSemantic(tooMany); + expect(error?.name).toBe('validation_error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('client.search.similar', () => { + test('hits POST /search/similar with conceptId variant', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + similar_concepts: [], + search_metadata: { + original_query: '201826', + algorithm_used: 'hybrid', + similarity_threshold: 0.7, + total_candidates: 0, + results_returned: 0, + processing_time_ms: 1, + }, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.similar({ conceptId: 201826, algorithm: 'hybrid' }); + const { init } = lastCall(fetchMock); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ concept_id: 201826, algorithm: 'hybrid' }); + }); + + test('accepts conceptName variant', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + similar_concepts: [], + search_metadata: { + original_query: 'diabetes', + algorithm_used: 'semantic', + similarity_threshold: 0.7, + total_candidates: 0, + results_returned: 0, + processing_time_ms: 1, + }, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.similar({ conceptName: 'diabetes', algorithm: 'semantic' }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body.concept_name).toBe('diabetes'); + }); + + test('accepts query variant', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + similar_concepts: [], + search_metadata: { + original_query: 'high blood sugar', + algorithm_used: 'hybrid', + similarity_threshold: 0.7, + total_candidates: 0, + results_returned: 0, + processing_time_ms: 1, + }, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.similar({ query: 'high blood sugar' }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body.query).toBe('high blood sugar'); + }); + + test('rejects zero-of XOR options synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + // Cast through unknown — TS would normally reject this at compile time. + const { error } = await client.search.similar( + {} as unknown as Parameters[0], + ); + expect(error?.name).toBe('missing_required_field'); + expect(error?.message).toMatch(/exactly one/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('rejects multiple-of XOR options synthetically', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { error } = await client.search.similar({ + conceptId: 1, + conceptName: 'x', + } as unknown as Parameters[0]); + expect(error?.name).toBe('missing_required_field'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('treats null/empty/NaN XOR values as not-provided (JS-caller hardening)', async () => { + const fetchMock = createMockFetch(); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + + expect( + ( + await client.search.similar({ + conceptId: null, + } as unknown as Parameters[0]) + ).error?.name, + ).toBe('missing_required_field'); + + expect( + ( + await client.search.similar({ + query: '', + } as unknown as Parameters[0]) + ).error?.name, + ).toBe('missing_required_field'); + + expect( + ( + await client.search.similar({ + conceptId: Number.NaN, + } as unknown as Parameters[0]) + ).error?.name, + ).toBe('missing_required_field'); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/test/version.test.ts b/test/version.test.ts new file mode 100644 index 0000000..a9cf587 --- /dev/null +++ b/test/version.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, test } from 'vitest'; +import { __version__ } from '../src/version.js'; + +describe('__version__', () => { + test('is a semver-like string', () => { + expect(__version__).toMatch(/^\d+\.\d+\.\d+/); + }); +}); diff --git a/test/vocabularies/vocabularies.test.ts b/test/vocabularies/vocabularies.test.ts new file mode 100644 index 0000000..2896a20 --- /dev/null +++ b/test/vocabularies/vocabularies.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from 'vitest'; +import { OMOPHub } from '../../src/client.js'; +import { mockApiErrorBody, mockPagination, mockVocabulary } from '../fixtures/index.js'; +import { + createMockFetch, + enqueueError, + enqueueRawBody, + enqueueSuccess, + lastCall, +} from '../helpers/mock-fetch.js'; + +describe('client.vocabularies.list', () => { + test('hits GET /vocabularies with no params by default', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [mockVocabulary()], meta: { pagination: mockPagination() } }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.vocabularies.list(); + + expect(error).toBeNull(); + expect(data?.data).toEqual([mockVocabulary()]); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/vocabularies'); + }); + + test('serialises options as snake_case query params', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [], meta: { pagination: mockPagination() } }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.list({ + page: 2, + pageSize: 50, + includeStats: true, + includeInactive: false, + sortBy: 'name', + sortOrder: 'desc', + }); + + const { url } = lastCall(fetchMock); + expect(url).toContain('page=2'); + expect(url).toContain('page_size=50'); + expect(url).toContain('include_stats=true'); + expect(url).toContain('include_inactive=false'); + expect(url).toContain('sort_by=name'); + expect(url).toContain('sort_order=desc'); + }); + + test('exposes pagination metadata under data.meta.pagination', async () => { + const fetchMock = createMockFetch(); + const pagination = mockPagination({ + page: 3, + page_size: 10, + total_items: 42, + total_pages: 5, + has_next: true, + has_previous: true, + }); + enqueueRawBody(fetchMock, { + success: true, + data: { data: [mockVocabulary()], meta: { pagination } }, + meta: { request_id: 'req_paginated' }, + }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, meta } = await client.vocabularies.list({ page: 3, pageSize: 10 }); + + expect(data?.meta.pagination).toEqual(pagination); + expect(meta?.request_id).toBe('req_paginated'); + }); + + test('returns ErrorResponse on 401', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 401, mockApiErrorBody('invalid_api_key', 'bad key')); + + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { data, error } = await client.vocabularies.list(); + + expect(data).toBeNull(); + expect(error?.name).toBe('invalid_api_key'); + expect(error?.statusCode).toBe(401); + }); + + test('per-call query overrides spread onto the request', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [], meta: { pagination: mockPagination() } }); + + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.list({ + pageSize: 10, + query: { vocab_release: '2025.1', trace: true }, + }); + + const { url } = lastCall(fetchMock); + expect(url).toContain('page_size=10'); + expect(url).toContain('vocab_release=2025.1'); + expect(url).toContain('trace=true'); + }); +}); + +describe('client.vocabularies new methods', () => { + test('get hits /vocabularies/{id}', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockVocabulary()); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.vocabularies.get('SNOMED'); + expect(error).toBeNull(); + expect(data?.vocabulary_id).toBe('SNOMED'); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/vocabularies/SNOMED'); + }); + + test('get URL-encodes the vocabulary ID', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, mockVocabulary()); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.get('Vocab With Spaces'); + expect(lastCall(fetchMock).url).toBe( + 'https://api.omophub.com/v1/vocabularies/Vocab%20With%20Spaces', + ); + }); + + test('stats hits /vocabularies/{id}/stats', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { vocabulary_id: 'SNOMED', total_concepts: 350_000 }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.stats('SNOMED'); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/vocabularies/SNOMED/stats'); + }); + + test('domainStats hits /vocabularies/{vocab}/stats/domains/{domain}', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { domain_id: 'Condition' }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.domainStats('SNOMED', 'Condition'); + expect(lastCall(fetchMock).url).toBe( + 'https://api.omophub.com/v1/vocabularies/SNOMED/stats/domains/Condition', + ); + }); + + test('domains hits /vocabularies/domains (vocab-scoped, NOT /domains)', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [{ domain_id: 'Condition', domain_name: 'Condition' }]); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const { data, error } = await client.vocabularies.domains(); + expect(error).toBeNull(); + expect(data?.[0]?.domain_id).toBe('Condition'); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/vocabularies/domains'); + }); + + test('conceptClasses hits /vocabularies/concept-classes', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, [ + { concept_class_id: 'Clinical Finding', concept_class_name: 'Clinical Finding' }, + ]); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.conceptClasses(); + expect(lastCall(fetchMock).url).toBe('https://api.omophub.com/v1/vocabularies/concept-classes'); + }); + + test('concepts hits /vocabularies/{id}/concepts with snake-cased options', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { data: [], meta: { pagination: mockPagination() } }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.vocabularies.concepts('SNOMED', { + search: 'diabetes', + standardConcept: 'S', + includeInvalid: false, + page: 2, + pageSize: 50, + sortBy: 'name', + sortOrder: 'asc', + }); + + const { url } = lastCall(fetchMock); + expect(url).toContain('/vocabularies/SNOMED/concepts'); + expect(url).toContain('search=diabetes'); + expect(url).toContain('standard_concept=S'); + expect(url).toContain('include_invalid=false'); + expect(url).toContain('page=2'); + expect(url).toContain('page_size=50'); + expect(url).toContain('sort_by=name'); + expect(url).toContain('sort_order=asc'); + }); + + test('returns ErrorResponse on 404 for unknown vocabulary', async () => { + const fetchMock = createMockFetch(); + enqueueError(fetchMock, 404, mockApiErrorBody('not_found', 'vocabulary missing')); + const client = new OMOPHub('oh_test', { fetch: fetchMock, maxRetries: 0 }); + const { data, error } = await client.vocabularies.get('NOPE'); + expect(data).toBeNull(); + expect(error?.name).toBe('not_found'); + }); +}); diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json new file mode 100644 index 0000000..d35fd88 --- /dev/null +++ b/tsconfig.e2e.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["e2e/**/*.ts", "vitest.e2e.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsconfig.examples.json b/tsconfig.examples.json new file mode 100644 index 0000000..126df32 --- /dev/null +++ b/tsconfig.examples.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["src/**/*.ts", "examples/**/*.ts"], + "exclude": ["node_modules", "dist", "test", "e2e"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6512183 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..272814f --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + thresholds: { statements: 90 }, + }, + }, +}); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..f9310eb --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs'; +import { defineConfig } from 'vitest/config'; + +// Minimal .env loader — just enough to pick up OMOPHUB_API_KEY for the +// e2e suite. Keeping a runtime dotenv dependency out of the package. +// +// Supported syntax (subset of dotenv): +// - KEY=value +// - KEY="value with spaces or # hash" (quoted: # is literal) +// - KEY='value' +// - KEY=value # inline comment (unquoted: trailing # stripped) +// - export KEY=value (export prefix allowed) +// - # full-line comments (ignored) +try { + const env = readFileSync(new URL('.env', import.meta.url), 'utf8'); + for (const rawLine of env.split('\n')) { + const line = rawLine.trimStart(); + if (line === '' || line.startsWith('#')) continue; + const match = line.match(/^(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/); + if (!match) continue; + const [, key, rest] = match; + if (!key) continue; + let value = (rest ?? '').trimEnd(); + // Quoted values: take the content between matching quotes literally + // (a `#` inside quotes is part of the value, not a comment). + const quoted = value.match(/^(['"])((?:\\.|(?!\1).)*)\1\s*(?:#.*)?$/); + if (quoted) { + value = quoted[2] ?? ''; + } else { + // Unquoted: strip a trailing inline comment introduced by ` #` + // (whitespace + hash). A `#` with no preceding whitespace is + // treated as part of the value (matches dotenv conventions). + const commentIdx = value.search(/\s+#/); + if (commentIdx !== -1) value = value.slice(0, commentIdx); + value = value.trim(); + } + if (process.env[key] === undefined) { + process.env[key] = value; + } + } +} catch { + // .env is optional — tests skip themselves if OMOPHUB_API_KEY isn't set. +} + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['e2e/**/*.test.ts'], + // Live API can be slow — generous per-test timeout + testTimeout: 90_000, + hookTimeout: 90_000, + // E2E hits a shared API; run sequentially to avoid rate-limit hot-spotting + fileParallelism: false, + sequence: { concurrent: false }, + }, +});