diff --git a/.changeset/typed-typescript-client.md b/.changeset/typed-typescript-client.md new file mode 100644 index 00000000..3007d92e --- /dev/null +++ b/.changeset/typed-typescript-client.md @@ -0,0 +1,12 @@ +--- +'lens': minor +--- + +Add a generated TypeScript client at `clients/typescript`. `npm run client:gen` +regenerates `clients/typescript/src/schema.d.ts` from `openapi.yaml` with +`openapi-typescript`, and `openapi-fetch` gives a typed `createLensClient`, so +every request and response type is derived from the spec and nothing is +hand-maintained. CI regenerates the client and fails if the committed copy +differs from `openapi.yaml`, then typechecks it with +`npm run client:typecheck`. `clients/typescript/src/example.ts` is a documented +usage example; the README explains how to regenerate and use the client. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3ecf75e..9b927282 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,12 @@ jobs: cache: npm - run: npm ci || npm install - run: node node_modules/prisma/build/index.js generate + - name: Generate TypeScript client from openapi.yaml + run: npm run client:gen + - name: Fail if the committed client is out of date + run: git diff --exit-code -- clients/typescript + - name: Typecheck the generated TypeScript client + run: npm run client:typecheck - run: npx tsc --noEmit - run: npm run build - run: npm test --if-present diff --git a/README.md b/README.md index 86430e78..06552d9e 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,43 @@ Or interactively at [http://localhost:3002/graphiql](http://localhost:3002/graph > **Note:** the current `GATED_ROUTES` map in [`src/middleware/x402.ts`](src/middleware/x402.ts) gates `/price`, `/pools`, and `/candles` only — `/graphql` is not gated. If you intend price data from GraphQL to require the same payment as REST, extend `GATED_ROUTES` (or add a per-resolver guard). +## TypeScript Client + +A typed TypeScript client is generated from [`openapi.yaml`](openapi.yaml) with +[`openapi-typescript`](https://github.com/openapi-ts/openapi-typescript) and +[`openapi-fetch`](https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-fetch). +It is committed at [`clients/typescript`](clients/typescript) — **no type is +hand-written**, so a change to the spec is a change to the client. + +```bash +# Regenerate clients/typescript/src/schema.d.ts from the spec +npm run client:gen + +# Type-check the generated client +npm run client:typecheck +``` + +CI runs `npm run client:gen` and fails if the committed client differs from the +regenerated output, so the client can never drift from `openapi.yaml`. + +```typescript +import { createLensClient } from "./clients/typescript/src/index.js"; + +const client = createLensClient({ baseUrl: "http://localhost:3002" }); + +const { data, error } = await client.GET("/price/{assetA}/{assetB}", { + params: { path: { assetA: "XLM", assetB: "USDC" } }, +}); +if (error) throw new Error(`request failed: ${error}`); + +// `data` is `AggregatedPrice`, derived from the spec. +console.log(`XLM/USDC vwap1h: ${data?.vwap1h}`); +``` + +A runnable version lives in +[`clients/typescript/src/example.ts`](clients/typescript/src/example.ts); run it +against a local server with `npx tsx clients/typescript/src/example.ts`. + ## Documentation Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md). Lens also *publishes* prices: [`docs/sep40-oracle-publisher.md`](docs/sep40-oracle-publisher.md) covers the SEP-40 contract in `oracle/contract/`, the deviation-and-interval publication policy in `src/publish/`, deployment, and key handling. diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts new file mode 100644 index 00000000..5aedab7e --- /dev/null +++ b/clients/typescript/src/client.ts @@ -0,0 +1,23 @@ +import createClient, { type Client, type ClientOptions } from "openapi-fetch"; +import type { paths } from "./schema.js"; + +export type { paths } from "./schema.js"; + +export type LensClient = Client; + +export type LensClientOptions = Omit & { + baseUrl?: string; + fetch?: typeof globalThis.fetch; +}; + +export const DEFAULT_BASE_URL = "http://localhost:3002"; + +export function createLensClient( + options: LensClientOptions = {}, +): LensClient { + return createClient({ + baseUrl: options.baseUrl ?? DEFAULT_BASE_URL, + fetch: options.fetch, + headers: options.headers, + }); +} diff --git a/clients/typescript/src/example.ts b/clients/typescript/src/example.ts new file mode 100644 index 00000000..0f6401a6 --- /dev/null +++ b/clients/typescript/src/example.ts @@ -0,0 +1,50 @@ +/** + * Example: typed Lens client usage. + * + * Every request and response type below comes from `schema.d.ts`, which is + * generated from `openapi.yaml` by `npm run client:gen`. Nothing here is + * hand-maintained, so a spec change shows up as a type error at compile time. + * + * Run it against a local server with: + * + * npx tsx clients/typescript/src/example.ts + */ +import { createLensClient, type paths } from "./client.js"; + +/** A price response, derived from the spec — never written by hand. */ +type AggregatedPrice = paths["/price/{assetA}/{assetB}"]["get"]["responses"][200]["content"]["application/json"]; + +const client = createLensClient({ baseUrl: "http://localhost:3002" }); + +async function main(): Promise { + const status = await client.GET("/status"); + if (status.error) { + throw new Error(`GET /status failed with ${status.response.status}`); + } + console.log(`watching ${status.data.watchedPairs?.length ?? 0} pairs`); + console.log(`last indexed ledger: ${status.data.lastIndexedLedger}`); + + const price = await client.GET("/price/{assetA}/{assetB}", { + params: { path: { assetA: "XLM", assetB: "USDC" } }, + }); + if (price.error) { + throw new Error(`GET /price failed with ${price.response.status}`); + } + + const data: AggregatedPrice | undefined = price.data; + if (!data) { + throw new Error("GET /price returned no body"); + } + console.log(`XLM/USDC best route: ${data.bestRoute}`); + console.log(`XLM/USDC vwap1h: ${data.vwap1h}`); + + const snapshots = await client.GET("/prices/history", { + params: { query: { pair: "XLM/USDC", interval: "1h" } }, + }); + if (snapshots.error) { + throw new Error(`GET /prices/history failed with ${snapshots.response.status}`); + } + console.log(`${snapshots.data?.count ?? 0} snapshots returned`); +} + +void main(); diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts new file mode 100644 index 00000000..e2cdef67 --- /dev/null +++ b/clients/typescript/src/index.ts @@ -0,0 +1,3 @@ +export { createLensClient, DEFAULT_BASE_URL } from "./client.js"; +export type { LensClient, LensClientOptions, paths } from "./client.js"; +export type { components, operations, $defs } from "./schema.js"; diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts new file mode 100644 index 00000000..ec48f136 --- /dev/null +++ b/clients/typescript/src/schema.d.ts @@ -0,0 +1,587 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get API status and indexer state */ + get: operations["getStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/price/{assetA}/{assetB}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get aggregated price for a pair */ + get: operations["getPrice"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/price/{assetA}/{assetB}/route": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get best swap route for a pair and amount */ + get: operations["getBestRoute"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/price/{assetA}/{assetB}/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get price history buckets */ + get: operations["getPriceHistory"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/pools": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get AMM liquidity pool reserves and spot prices */ + get: operations["getPools"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/candles/{assetA}/{assetB}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get OHLCV candle data */ + get: operations["getCandles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/prices/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get historical 1-minute price snapshots + * @description Returns minute-resolution price snapshots for a pair over [from, to], optionally aggregated into 5m or 1h buckets. Snapshots are retained for ~30 days. Gated by x402 (matches the /price prefix). + */ + get: operations["getPriceSnapshots"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/pairs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List all active trading pairs */ + get: operations["listPairs"]; + put?: never; + /** Add a new trading pair (Admin only) */ + post: operations["addPair"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Asset: { + code?: string; + issuer?: string | null; + }; + AggregatedPrice: { + assetA?: string; + assetB?: string; + pairKey?: string; + price?: number; + sdexPrice?: number; + ammPrice?: number; + /** @enum {string} */ + bestRoute?: "SDEX" | "AMM" | "SPLIT" | "UNKNOWN"; + volume24h?: number; + sdexVolume24h?: number; + ammVolume24h?: number; + vwap1m?: number; + vwap5m?: number; + vwap1h?: number; + vwap24h?: number; + priceChange24h?: number; + /** Format: date-time */ + lastUpdated?: string; + sources?: number; + /** @enum {string} */ + confidence?: "high" | "medium" | "low" | "unknown"; + lastTradeAgeSeconds?: number | null; + }; + RouteInfo: { + /** @enum {string} */ + route?: "SDEX" | "AMM" | "SPLIT" | "UNKNOWN"; + sdexPrice?: number; + ammPrice?: number; + estimatedOutput?: number; + slippagePct?: number; + recommendation?: string; + }; + PriceBucket: { + /** Format: date-time */ + bucket?: string; + window?: string; + vwap?: number; + sdexVwap?: number | null; + ammVwap?: number | null; + volume?: number; + tradeCount?: number; + open?: number | null; + close?: number | null; + high?: number | null; + low?: number | null; + }; + }; + responses: { + /** @description Payment Required */ + PaymentRequired: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + x402Version?: number; + accepts?: { + scheme?: string; + price?: string; + network?: string; + payTo?: string; + }[]; + error?: string; + description?: string; + }; + }; + }; + /** @description Rate limit exceeded */ + TooManyRequests: { + headers: { + /** @description Seconds to wait before retrying */ + "Retry-After"?: number; + /** @description Remaining requests in current window */ + "X-RateLimit-Remaining"?: number; + [name: string]: unknown; + }; + content: { + "application/json": { + statusCode?: number; + error?: string; + message?: string; + retryAfter?: string; + }; + }; + }; + }; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description API status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + watchedPairs?: string[]; + lastIndexedLedger?: number | null; + /** Format: date-time */ + lastProcessedAt?: string | null; + }; + }; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getPrice: { + parameters: { + query?: never; + header?: never; + path: { + /** @example XLM */ + assetA: string; + /** @example USDC */ + assetB: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Aggregated price data */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AggregatedPrice"]; + }; + }; + 402: components["responses"]["PaymentRequired"]; + /** @description Pair not watched */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getBestRoute: { + parameters: { + query?: { + amount?: number; + }; + header?: never; + path: { + assetA: string; + assetB: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Best route information */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RouteInfo"]; + }; + }; + 402: components["responses"]["PaymentRequired"]; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getPriceHistory: { + parameters: { + query?: { + window?: "1m" | "5m" | "1h" | "24h"; + limit?: number; + }; + header?: never; + path: { + assetA: string; + assetB: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Price history */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + pairKey?: string; + window?: string; + buckets?: components["schemas"]["PriceBucket"][]; + }; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getPools: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of pools */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + pools?: { + pool_id?: string; + asset_a?: string; + asset_b?: string; + reserve_a?: number; + reserve_b?: number; + spot_price?: number; + fee_bp?: number; + /** Format: date-time */ + timestamp?: string; + }[]; + }; + }; + }; + 402: components["responses"]["PaymentRequired"]; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getCandles: { + parameters: { + query?: { + interval?: "1m" | "5m" | "15m" | "1h" | "4h" | "1d"; + from?: string; + to?: string; + }; + header?: never; + path: { + assetA: string; + assetB: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Candle data */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + pairKey?: string; + interval?: string; + from?: string; + to?: string; + candles?: { + /** Format: date-time */ + time?: string; + open?: number; + high?: number; + low?: number; + close?: number; + volume?: number; + }[]; + }; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 402: components["responses"]["PaymentRequired"]; + 429: components["responses"]["TooManyRequests"]; + }; + }; + getPriceSnapshots: { + parameters: { + query: { + /** @example XLM/USDC */ + pair: string; + interval?: "1m" | "5m" | "1h"; + from?: string; + to?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Historical price snapshots */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + pair?: string; + interval?: string; + /** Format: date-time */ + from?: string; + /** Format: date-time */ + to?: string; + count?: number; + points?: { + /** Format: date-time */ + ts?: string; + price?: number; + volume?: number; + }[]; + }; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 402: components["responses"]["PaymentRequired"]; + 429: components["responses"]["TooManyRequests"]; + }; + }; + listPairs: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of pairs */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + pairs?: { + pairKey?: string; + assetA?: components["schemas"]["Asset"]; + assetB?: components["schemas"]["Asset"]; + latestPrice?: number | null; + /** Format: date-time */ + lastUpdated?: string | null; + }[]; + }; + }; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; + addPair: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @example XLM */ + assetA?: string; + /** @example USDC:GBBD47... */ + assetB?: string; + }; + }; + }; + responses: { + /** @description Pair added */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Pair already exists */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; +} diff --git a/clients/typescript/tsconfig.json b/clients/typescript/tsconfig.json new file mode 100644 index 00000000..b055bc44 --- /dev/null +++ b/clients/typescript/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "./src", + "outDir": "./dist", + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "noEmit": true + }, + "include": ["src/**/*"] +} diff --git a/package-lock.json b/package-lock.json index e2bba06c..3c299e5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,12 +41,36 @@ "@vitest/coverage-v8": "^4.1.5", "fast-check": "^3.0.0", "js-yaml": "^4.2.0", + "openapi-fetch": "^0.17.0", + "openapi-typescript": "^7.13.0", "prisma": "^5.0.0", "tsx": "^4.0.0", "typescript": "^5.0.0", "vitest": "^4.1.5" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "dev": true, @@ -56,7 +80,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", + "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": { @@ -771,8 +797,114 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@lukeed/ms": { - "version": "2.0.2", + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.21", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.21.tgz", + "integrity": "sha512-yXMiBFYJbP5JOaxPzx2tJhK+v8h2Wu2uhad1Ro6GzVT8E3F4GcQ7sbLPJ9w4zvGjfxroD0bIh5B0pDtnLV/qUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.3.2", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@redocly/openapi-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1793,6 +1925,13 @@ "node": ">=18" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/chardet": { "version": "2.1.1", "dev": true, @@ -1812,6 +1951,13 @@ "node": ">=0.10.0" } }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "license": "MIT", @@ -2892,6 +3038,19 @@ "node": ">=8" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inherits": { "version": "2.0.4", "license": "ISC" @@ -3069,13 +3228,25 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "dev": true, "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -3620,6 +3791,57 @@ "wrappy": "1" } }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "dev": true, + "license": "MIT" + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/outdent": { "version": "0.5.0", "dev": true, @@ -3698,6 +3920,24 @@ "quansync": "^0.2.7" } }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "license": "MIT", @@ -3939,6 +4179,16 @@ "node": ">=16.20.0" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "license": "MIT", @@ -4998,6 +5248,19 @@ "version": "1.0.3", "license": "Unlicense" }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.1.0", "license": "MIT", @@ -5102,6 +5365,13 @@ "node": ">= 0.8" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/urijs": { "version": "1.19.11", "license": "MIT" @@ -5371,6 +5641,23 @@ "node": ">=0.4" } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/zod": { "version": "4.5.4", "license": "MIT", diff --git a/package.json b/package.json index 59a5e5d6..cb01d6a3 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "smoke": "node scripts/deployed-smoke.mjs", "test:smoke": "node --test scripts/test-deployed-smoke.mjs", "openapi:gen": "tsx scripts/generate-openapi.ts", - "spec:validate-live": "tsx scripts/validate-live-spec.ts", + "client:gen": "openapi-typescript openapi.yaml -o clients/typescript/src/schema.d.ts", + "client:typecheck": "tsc -p clients/typescript/tsconfig.json --noEmit", "changeset": "changeset", "version-packages": "changeset version", "release": "changeset tag", @@ -59,6 +60,8 @@ "@vitest/coverage-v8": "^4.1.5", "fast-check": "^3.0.0", "js-yaml": "^4.2.0", + "openapi-fetch": "^0.17.0", + "openapi-typescript": "^7.13.0", "prisma": "^5.0.0", "tsx": "^4.0.0", "typescript": "^5.0.0",