diff --git a/bridgeservice/apispec/.gitattributes b/bridgeservice/apispec/.gitattributes new file mode 100644 index 000000000..a58f9d205 --- /dev/null +++ b/bridgeservice/apispec/.gitattributes @@ -0,0 +1,2 @@ +generated/client/** linguist-generated=true +generated/openapi.yaml linguist-generated=true diff --git a/bridgeservice/apispec/.gitignore b/bridgeservice/apispec/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/bridgeservice/apispec/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/bridgeservice/apispec/README.md b/bridgeservice/apispec/README.md new file mode 100644 index 000000000..4be49ce1d --- /dev/null +++ b/bridgeservice/apispec/README.md @@ -0,0 +1,222 @@ +# Spec-first pipeline — demo slice of the bridge API + +A working, runnable demonstration of authoring one bridge-service endpoint +contract-first, next to the same endpoint as the service serves it today. + +Scope is deliberately one route: `GET /bridge/v1/bridges`. + +## The defect this starts from + +`BridgeResponse.GlobalIndex` (`bridgeservice/types/types.go`) is a `*big.Int`. +`encoding/json` writes a `*big.Int` as a **bare JSON number**, so the field goes +out as: + +```json +"global_index": 18446744073709551621 +``` + +The field's `swaggertype:"string"` tag makes the committed Swagger spec claim it +is a string, so the published contract and the wire disagree. + +The value matters. A global index for an L1-origin bridge packs a mainnet flag +into bit 64: deposit count 5 encodes to 2^64+5 = 18446744073709551621. That is +above `Number.MAX_SAFE_INTEGER`, so `JSON.parse` in any JavaScript runtime +returns `18446744073709551616` — a different bridge — without an error, a +warning, or a way for the caller to notice. Every sibling field that carries a +big integer, `amount` included, already uses the `types.BigIntString` wrapper +and is correctly quoted. This one field slipped. + +Nothing catches it. The Go tests decode responses with `encoding/json`, which +round-trips both forms losslessly, so the divergence exists only in the +serialised bytes that no test looks at. + +## What spec-first changes + +The contract becomes an artifact that both sides are generated from, instead of +a description written alongside code that may or may not match it. + +```text + src/schemas.ts Zod schemas + registry (the contract, authored once) + | + | pnpm run generate:spec + v + generated/openapi.yaml OpenAPI 3.0 + | | + | oapi-codegen | pnpm run generate:client + v v + ../oapi/oapi.gen.go generated/client/ + strict gin server codec-aware TypeScript client +``` + +Two properties fall out of that shape. + +**The server cannot drift from the contract, because it does not choose the +wire format.** `oapi-codegen` renders the contract into Go types and a *strict* +`ServerInterface` — one whose method signatures carry typed request and response +objects. A handler that returns the wrong shape does not compile. The +hand-written service, by contrast, gets a `*gin.Context` and full freedom over +what JSON it writes, which is the freedom that let this field drift. + +The big-integer fields carry a vendor extension in the contract: + +```yaml +global_index: + type: string + x-go-type: types.BigIntString + x-go-type-import: + path: github.com/agglayer/aggkit/bridgeservice/types +``` + +so the generated struct field is aggkit's own `types.BigIntString`, which +marshals as a quoted string and accepts a string or a number on the way back +in. (`x-go-type: big.Int` is the obvious-looking choice and is wrong: a raw +`*big.Int` marshals as a bare number *and* rejects a quoted string on unmarshal, +reproducing the defect and adding a new one.) + +**Consumers reject a response that lies, instead of silently corrupting it.** +The TypeScript client is generated by `@hey-api/openapi-ts` with the +`@polygonlabs/zod-to-openapi-heyapi` plugin, which imports the *actual* Zod +schemas the spec was generated from rather than reconstructing them from the +spec. `global_index` and `amount` are `BigIntegerCodec` — wire format a decimal +string, runtime value a `bigint` — so the client validates every response +against the same code that defined the contract and hands the caller exact +values with no double in the path. + +## What the generated client gives you + +The client is not a fetch wrapper — it is the contract, executable on the +consumer's side. Concretely: + +**Errors arrive classified, not as prose to fingerprint.** Every operation's +result narrows into three categories via generated type-predicate guards, with +no casts anywhere: + +- `TransportError` — the request never produced an HTTP response (DNS, abort, + connection reset). `cause` carries the native fetch error. +- `ResponseValidationError` — the server responded, but the body does not + match the contract: a 2xx body failing the response schema, or an error + body matching no registered error schema. `cause` is the `ZodError` with + the exact issue paths; `body` is the offending payload. This is how + contract drift becomes *undeliverable*: the money test below points this + client at today's live endpoint, and the bare-number `global_index` is + refused on the first row — carrying the silently-rounded double on `.body` + as evidence — instead of flowing into the application as a wrong value. +- Typed `${Op}Error` — the body matched a registered error schema for that + status, decoded through its codecs, fully typed. + +Compare that with what consuming this API by hand requires today: matching +substrings of freeform error messages that have already changed between two +release candidates. + +**Codecs run in both directions, so the wire format and the runtime type are +different things — honestly.** `global_index` and `amount` are declared once +as `BigIntegerCodec` (wire: decimal string; runtime: `bigint`). Every response +runs `parseAsync` through the *actual schema objects* the spec was generated +from — not a reconstruction — so the caller receives exact `bigint`s, `Date`s +from ISO strings, and so on, and the TypeScript types agree with the runtime +values by construction. Request-side inputs are encoded back to wire format +the same way: pass a `bigint`, the wire carries the string. + +**React integration is one flag away.** The same plugin emits codec-aware +TanStack Query factories (`queryOptions`, query keys, hooks-ready) per +operation when `tanstackReactQuery: true` is set — this demo keeps it off to +stay minimal, but a frontend consuming this API gets typed, codec-decoding +React hooks from the same one-line config, with no additional authoring. + +**One canonical import surface.** The generated barrel exports the client +singleton, every operation wrapper, the error classes and guards, and the +schema-derived types — a consumer imports from one place and cannot +accidentally reach a wire-shaped variant of a type. + +**It redraws the SDK boundary correctly.** Today's `@agglayer/sdk` spends +over a thousand lines hand-maintaining a typed client, raw-text parsing, and +fixture-derived types — a shadow copy of facts this repo already owns, which +must be re-verified against every aggkit release. With the client generated +*here*, that entire layer disappears from the SDK, which keeps only what is +genuinely SDK-shaped: multi-network aggregation, claim orchestration, +on-chain reads. Two more things fall out for free: any consumer who just +wants to call one aggkit instance can depend on the thin generated client +alone, without pulling the full SDK — and because the client is generated +and published from this repo, **its release cadence is the server's**: a +contract change ships as a client version bump in the same release, so +breaking changes arrive as semver signals instead of surprises discovered +downstream. + +## Running it + +Prerequisites: Go (per `go.mod`), Node 24, pnpm. + +```bash +cd bridgeservice/apispec +pnpm install +pnpm run generate # openapi.yaml, then the TypeScript client +``` + +Regenerate the Go server after any contract change: + +```bash +cd ../oapi && go generate ./... +``` + +Serve both endpoints over one set of canned rows and compare them by hand: + +```bash +go run ./bridgeservice/oapi/demo/cmd # from the repository root + +curl -s 'http://127.0.0.1:8099/bridge/v1/bridges?network_id=0' +# ..."global_index":18446744073709551621,... bare number + +curl -s 'http://127.0.0.1:8099/specfirst/bridge/v1/bridges?network_id=0' +# ..."global_index":"18446744073709551621",... quoted string +``` + +The left-hand endpoint is not a reimplementation. It is the shipped +`BridgeService`, instantiated the way `bridgeservice/bridge_test.go` instantiates +it, with mocked syncers returning the canned rows — same routing, same response +types, same serialisation. + +Both halves are covered by tests: + +```bash +go test ./bridgeservice/oapi/... # wire format, asserted on raw response bytes +cd bridgeservice/apispec && pnpm run demo # the generated client against both endpoints +``` + +`pnpm run demo` builds and starts the Go demo server itself, so it needs no +setup beyond `pnpm install && pnpm run generate`. It asserts that the current +endpoint is *rejected* by the generated client — with a Zod issue reading +`expected string, received number` at `global_index` — and that the generated +endpoint round-trips both big integers as exact `bigint`s. + +## Layout + +| Path | What it is | +| --- | --- | +| `src/schemas.ts` | The contract: Zod schemas mirroring `bridgeservice/types` | +| `src/routes/bridges.ts` | The one registered operation | +| `src/registry.ts` | Registry composition | +| `scripts/generate-spec.ts` | Emits `generated/openapi.yaml` | +| `openapi-ts.config.ts` | Client codegen config | +| `generated/` | Committed generated output — do not edit | +| `test/` | The generated client against both endpoints | +| `../oapi/` | `oapi-codegen` config and generated Go server | +| `../oapi/demo/` | Both servers mounted together, plus Go wire-format tests | + +## What this is not + +- **One route out of eighteen.** Only `GET /bridge/v1/bridges` is modelled. A + real migration covers every operation the bridge service registers. +- **Not wired into the running service.** The generated server is mounted under + a separate prefix by a demo command. Adopting it means the real service + implements the generated interface and the swaggo annotation flow + (`@Summary`/`@Param` comments plus `bridgeservice/docs`) is retired in favour + of the generated document. +- **Fixed data.** The syncer dependencies are mocks and the rows are canned; + nothing here reads a database or a chain. +- **No client is published.** The generated TypeScript client exists so the + contract can be tested from the consumer side. Shipping it to consumers is a + separate decision. + +Adopting this would be a breaking wire change for `global_index` on every +endpoint that carries one, and needs to be sequenced with the consumers that +read it. diff --git a/bridgeservice/apispec/generated/client/client.gen.ts b/bridgeservice/apispec/generated/client/client.gen.ts new file mode 100644 index 000000000..abcb72853 --- /dev/null +++ b/bridgeservice/apispec/generated/client/client.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type ClientOptions, type Config, createClient, createConfig } from './client/index.js'; +import type { ClientOptions as ClientOptions2 } from './types.gen.js'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = (override?: Config) => Config & T>; + +export const client = createClient(createConfig()); diff --git a/bridgeservice/apispec/generated/client/client/client.gen.ts b/bridgeservice/apispec/generated/client/client/client.gen.ts new file mode 100644 index 000000000..f3c1fa9b8 --- /dev/null +++ b/bridgeservice/apispec/generated/client/client/client.gen.ts @@ -0,0 +1,277 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen.js'; +import type { HttpMethod } from '../core/types.gen.js'; +import { getValidRequestBody } from '../core/utils.gen.js'; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen.js'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen.js'; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors(); + + const beforeRequest = async < + TData = unknown, + TResponseStyle extends 'data' | 'fields' = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, + >( + options: RequestOptions, + ) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined as string | undefined, + }; + + if (opts.security) { + await setAuthParams(opts); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const resolvedOpts = opts as typeof opts & + ResolvedRequestOptions; + const url = buildUrl(resolvedOpts); + + return { opts: resolvedOpts, url }; + }; + + const request: Client['request'] = async (options) => { + const throwOnError = options.throwOnError ?? _config.throwOnError; + const responseStyle = options.responseStyle ?? _config.responseStyle; + + let request: Request | undefined; + let response: Response | undefined; + + try { + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + + response = await _fetch(request); + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await response[parseAs](); + break; + case 'json': { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; + } + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + throw jsonError ?? textError; + } catch (error) { + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); + } + } + + finalError = finalError || {}; + + if (throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response, + }; + } + }; + + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url, + }); + }; + + const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); + + return { + buildUrl: _buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/bridgeservice/apispec/generated/client/client/index.ts b/bridgeservice/apispec/generated/client/client/index.ts new file mode 100644 index 000000000..50acaa57b --- /dev/null +++ b/bridgeservice/apispec/generated/client/client/index.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen.js'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen.js'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen.js'; +export { buildClientParams } from '../core/params.gen.js'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen.js'; +export { createClient } from './client.gen.js'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen.js'; +export { createConfig, mergeHeaders } from './utils.gen.js'; diff --git a/bridgeservice/apispec/generated/client/client/types.gen.ts b/bridgeservice/apispec/generated/client/client/types.gen.ts new file mode 100644 index 000000000..b6d86febb --- /dev/null +++ b/bridgeservice/apispec/generated/client/client/types.gen.ts @@ -0,0 +1,218 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen.js'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen.js'; +import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen.js'; +import type { Middleware } from './utils.gen.js'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config + extends Omit, CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> + extends + Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onRequest' + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + headers: Headers; + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record ? TError[keyof TError] : TError; + } + ) & { + /** request may be undefined, because error may be from building the request object itself */ + request?: Request; + /** response may be undefined, because error may be from building the request object itself or from a network error */ + response?: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => RequestResult; + +type SseFn = < + TData = unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick>, 'method'>, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: TData & Options, +) => string; + +export type Client = CoreClient & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/bridgeservice/apispec/generated/client/client/utils.gen.ts b/bridgeservice/apispec/generated/client/client/utils.gen.ts new file mode 100644 index 000000000..271852e16 --- /dev/null +++ b/bridgeservice/apispec/generated/client/client/utils.gen.ts @@ -0,0 +1,316 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen.js'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen.js'; +import { jsonBodySerializer } from '../core/bodySerializer.gen.js'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen.js'; +import { getUrl } from '../core/utils.gen.js'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen.js'; + +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = (contentType: string | null): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export async function setAuthParams( + options: Pick & { + headers: Headers; + }, +): Promise { + for (const auth of options.security ?? []) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +} + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e., their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + /** response may be undefined due to a network error where no response object is produced */ + response: Res | undefined, + /** request may be undefined, because error may be from building the request object itself */ + request: Req | undefined, + options: Options, +) => Err | Promise; + +type ReqInterceptor = (request: Req, options: Options) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/bridgeservice/apispec/generated/client/core/auth.gen.ts b/bridgeservice/apispec/generated/client/core/auth.gen.ts new file mode 100644 index 000000000..3ebf99478 --- /dev/null +++ b/bridgeservice/apispec/generated/client/core/auth.gen.ts @@ -0,0 +1,41 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/bridgeservice/apispec/generated/client/core/bodySerializer.gen.ts b/bridgeservice/apispec/generated/client/core/bodySerializer.gen.ts new file mode 100644 index 000000000..71b4bba2c --- /dev/null +++ b/bridgeservice/apispec/generated/client/core/bodySerializer.gen.ts @@ -0,0 +1,82 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen.js'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: unknown) => unknown; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: (body: unknown): FormData => { + const data = new FormData(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: unknown): string => + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: (body: unknown): string => { + const data = new URLSearchParams(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/bridgeservice/apispec/generated/client/core/params.gen.ts b/bridgeservice/apispec/generated/client/core/params.gen.ts new file mode 100644 index 000000000..6478519bc --- /dev/null +++ b/bridgeservice/apispec/generated/client/core/params.gen.ts @@ -0,0 +1,169 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +}; + +interface Params { + body: unknown; + headers: Record; + path: Record; + query: Record; +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +}; + +export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { + const params: Params = { + body: Object.create(null), + headers: Object.create(null), + path: Object.create(null), + query: Object.create(null), + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + (params[field.in] as Record)[name] = arg; + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + (params[field.in] as Record)[name] = value; + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record)[key.slice(prefix.length)] = value; + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + (params[slot as Slot] as Record)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/bridgeservice/apispec/generated/client/core/pathSerializer.gen.ts b/bridgeservice/apispec/generated/client/core/pathSerializer.gen.ts new file mode 100644 index 000000000..994b2848c --- /dev/null +++ b/bridgeservice/apispec/generated/client/core/pathSerializer.gen.ts @@ -0,0 +1,171 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}) => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; diff --git a/bridgeservice/apispec/generated/client/core/queryKeySerializer.gen.ts b/bridgeservice/apispec/generated/client/core/queryKeySerializer.gen.ts new file mode 100644 index 000000000..5000df606 --- /dev/null +++ b/bridgeservice/apispec/generated/client/core/queryKeySerializer.gen.ts @@ -0,0 +1,117 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown) => { + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { + if (value === null) { + return null; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/bridgeservice/apispec/generated/client/core/serverSentEvents.gen.ts b/bridgeservice/apispec/generated/client/core/serverSentEvents.gen.ts new file mode 100644 index 000000000..94fd8357d --- /dev/null +++ b/bridgeservice/apispec/generated/client/core/serverSentEvents.gen.ts @@ -0,0 +1,242 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen.js'; + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export function createSseClient({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult { + let lastEventId: string | undefined; + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +} diff --git a/bridgeservice/apispec/generated/client/core/types.gen.ts b/bridgeservice/apispec/generated/client/core/types.gen.ts new file mode 100644 index 000000000..9ab193a7e --- /dev/null +++ b/bridgeservice/apispec/generated/client/core/types.gen.ts @@ -0,0 +1,104 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen.js'; +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen.js'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g., converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; +}; diff --git a/bridgeservice/apispec/generated/client/core/utils.gen.ts b/bridgeservice/apispec/generated/client/core/utils.gen.ts new file mode 100644 index 000000000..fad9c2df6 --- /dev/null +++ b/bridgeservice/apispec/generated/client/core/utils.gen.ts @@ -0,0 +1,140 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen.js'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen.js'; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}) { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e., client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/bridgeservice/apispec/generated/client/index.ts b/bridgeservice/apispec/generated/client/index.ts new file mode 100644 index 000000000..8dd0629db --- /dev/null +++ b/bridgeservice/apispec/generated/client/index.ts @@ -0,0 +1,4 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export { client, type CreateClientConfig } from './client.gen.js'; +export { getBridges, type GetBridgesError, type GetBridgesErrors, getBridgesErrorTransformer, type GetBridgesInput, getBridgesInputTransformer, type GetBridgesResponse, type GetBridgesResponses, getBridgesTransformer, isResponseValidationError, isTransportError, isWrapperError, ResponseValidationError, TransportError, type WrapErrors } from './registry-validator.gen.js'; diff --git a/bridgeservice/apispec/generated/client/registry-validator.gen.ts b/bridgeservice/apispec/generated/client/registry-validator.gen.ts new file mode 100644 index 000000000..835242ccf --- /dev/null +++ b/bridgeservice/apispec/generated/client/registry-validator.gen.ts @@ -0,0 +1,135 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { BridgesResult, ErrorResponse, GetBridgesQuery } from '#schemas'; +import { z, type ZodError } from 'zod'; + +import { getBridges as getBridges2, type Options } from './sdk.gen.js'; +import type { GetBridgesData } from './types.gen.js'; + +export type GetBridgesResponses = { + 200: z.output; +}; + +export type GetBridgesResponse = GetBridgesResponses[keyof GetBridgesResponses]; + +export type GetBridgesErrors = { + 400: z.output; + 500: z.output; +}; + +export type GetBridgesError = GetBridgesErrors[keyof GetBridgesErrors]; + +/** + * @internal — emitted by `@polygonlabs/zod-to-openapi-heyapi`. Do not + * instantiate from consumer code; the wrapper constructs these in + * response to fetch transport rejections (DNS / abort / `ECONNRESET`). + * Narrow via the emitted `isTransportError` type-predicate guard. + */ +export class TransportError extends Error { + readonly cause: Error; + constructor(cause: Error) { + super('Request failed before producing an HTTP response'); + (this as Record)[Symbol.for("@polygonlabs/zod-to-openapi-heyapi/is-transport-error")] = true; + this.cause = cause; + this.name = 'TransportError'; + } +} + +/** + * @internal — emitted by `@polygonlabs/zod-to-openapi-heyapi`. Do not + * instantiate from consumer code; the wrapper constructs these when + * `parseAsync` rejects an HTTP error body that did not match any + * registered error schema. `cause` carries the `ZodError` issues; + * `body` is the original wire body for debugging schema drift. + * Narrow via the emitted `isResponseValidationError` type-predicate guard. + */ +export class ResponseValidationError extends Error { + readonly cause: ZodError; + readonly body: unknown; + constructor(cause: ZodError, body: unknown) { + super('API response did not match the registered schema'); + (this as Record)[Symbol.for("@polygonlabs/zod-to-openapi-heyapi/is-response-validation-error")] = true; + this.cause = cause; + this.name = 'ResponseValidationError'; + this.body = body; + } +} + +export const isTransportError = (value: unknown): value is TransportError => typeof value === "object" && value !== null && (value as Record)[Symbol.for("@polygonlabs/zod-to-openapi-heyapi/is-transport-error")] === true; + +export const isResponseValidationError = (value: unknown): value is ResponseValidationError => typeof value === "object" && value !== null && (value as Record)[Symbol.for("@polygonlabs/zod-to-openapi-heyapi/is-response-validation-error")] === true; + +export const isWrapperError = (value: unknown): value is TransportError | ResponseValidationError => typeof value === "object" && value !== null && ((value as Record)[Symbol.for("@polygonlabs/zod-to-openapi-heyapi/is-transport-error")] === true || (value as Record)[Symbol.for("@polygonlabs/zod-to-openapi-heyapi/is-response-validation-error")] === true); + +export type WrapErrors = Promise ? TData[keyof TData] : TData : { + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; +} : TResponseStyle extends "data" ? (TData extends Record ? TData[keyof TData] : TData) | undefined : ({ + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; +} | { + data: undefined; + error: (TError extends Record ? TError[keyof TError] : TError) | TransportError | ResponseValidationError; +}) & { + request: Request; + response: Response; +}>; + +export const getBridgesTransformer = async (data: unknown): Promise> => { + try { + return await BridgesResult.parseAsync(data); + } + catch (err) { + throw new ResponseValidationError(err as ZodError, data); + } +}; + +export const getBridgesErrorTransformer = async (data: unknown): Promise> => await ErrorResponse.parseAsync(data); + +export type GetBridgesInput = Omit & { + query: z.output; +}; + +export const getBridgesInputTransformer = async (input: Pick) => ({ ...input.query !== undefined ? { query: await z.encode(GetBridgesQuery, input.query) } : {} }); + +export const getBridges = async (options: Options): WrapErrors => { + const transformed = await getBridgesInputTransformer(options); + let result; + try { + result = await getBridges2({ ...options, ...transformed } as Options); + } + catch (err) { + if (typeof err === "object" && err !== null && (err as Record)[Symbol.for("@polygonlabs/zod-to-openapi-heyapi/is-response-validation-error")] === true) { + throw err; + } + if (err instanceof Error) { + throw new TransportError(err as Error); + } + let typedErr; + try { + typedErr = await getBridgesErrorTransformer(err); + } + catch (validationError) { + throw new ResponseValidationError(validationError as ZodError, err); + } + throw typedErr; + } + const errorBearing = result as { + error?: unknown; + }; + if (typeof result === "object" && result !== null && "request" in result && "response" in result && (typeof errorBearing.error === "object" && errorBearing.error !== null) && !(typeof errorBearing.error === "object" && errorBearing.error !== null && (errorBearing.error as Record)[Symbol.for("@polygonlabs/zod-to-openapi-heyapi/is-response-validation-error")] === true)) { + if (errorBearing.error instanceof Error) { + errorBearing.error = new TransportError(errorBearing.error as Error); + } + else { + try { + errorBearing.error = await getBridgesErrorTransformer(errorBearing.error); + } + catch (validationError) { + errorBearing.error = new ResponseValidationError(validationError as ZodError, errorBearing.error); + } + } + } + return result as unknown as Awaited>; +}; diff --git a/bridgeservice/apispec/generated/client/sdk.gen.ts b/bridgeservice/apispec/generated/client/sdk.gen.ts new file mode 100644 index 000000000..85377019c --- /dev/null +++ b/bridgeservice/apispec/generated/client/sdk.gen.ts @@ -0,0 +1,31 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { client } from './client.gen.js'; +import type { Client, Options as Options2, TDataShape } from './client/index.js'; +import { type GetBridgesErrors, type GetBridgesResponses, getBridgesTransformer } from './registry-validator.gen.js'; +import type { GetBridgesData } from './types.gen.js'; + +export type Options = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record; +}; + +/** + * Get bridges + * + * Returns a paginated list of bridge events for the specified network. + */ +export const getBridges = (options: Options) => (options.client ?? client).get({ + responseTransformer: getBridgesTransformer, + url: '/bridge/v1/bridges', + ...options +}); diff --git a/bridgeservice/apispec/generated/client/types.gen.ts b/bridgeservice/apispec/generated/client/types.gen.ts new file mode 100644 index 000000000..b846d1d38 --- /dev/null +++ b/bridgeservice/apispec/generated/client/types.gen.ts @@ -0,0 +1,84 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}); +}; + +/** + * Paginated response of bridge events + */ +export type BridgesResult = { + bridges: Array; + count: number; +}; + +/** + * Detailed information about a bridge event + */ +export type BridgeResponse = { + block_num: number; + block_pos: number; + from_address?: string; + tx_hash: string; + /** + * Global index of the bridge event (mainnet flag, rollup id and deposit count packed into a 72-bit integer). Exceeds 2^53 for every L1-origin bridge, so it is carried as a decimal string. + */ + global_index: string; + block_timestamp: number; + leaf_type: number; + origin_network: number; + origin_address: string; + destination_network: number; + destination_address: string; + /** + * Amount of tokens bridged, in the smallest unit of the token. + */ + amount: string; + metadata: string; + deposit_count: number; + bridge_hash: string; + txn_sender: string; + to_address: string; +}; + +/** + * Generic error response structure + */ +export type ErrorResponse = { + error: string; +}; + +export type GetBridgesData = { + body?: never; + path?: never; + query: { + network_id: number; + page_number?: number; + page_size?: number; + from_address?: string; + deposit_count?: number; + }; + url: '/bridge/v1/bridges'; +}; + +export type GetBridgesErrors = { + /** + * Invalid query parameters + */ + 400: ErrorResponse; + /** + * Internal server error + */ + 500: ErrorResponse; +}; + +export type GetBridgesError = GetBridgesErrors[keyof GetBridgesErrors]; + +export type GetBridgesResponses = { + /** + * Paginated bridge events + */ + 200: BridgesResult; +}; + +export type GetBridgesResponse = GetBridgesResponses[keyof GetBridgesResponses]; diff --git a/bridgeservice/apispec/generated/openapi.yaml b/bridgeservice/apispec/generated/openapi.yaml new file mode 100644 index 000000000..1e2474bb1 --- /dev/null +++ b/bridgeservice/apispec/generated/openapi.yaml @@ -0,0 +1,169 @@ +openapi: 3.0.3 +info: + title: aggkit bridge service (spec-first demo slice) + version: 0.0.0 + description: Contract-first description of GET /bridge/v1/bridges. Generated + from Zod schemas; consumed by oapi-codegen (Go server) and + @hey-api/openapi-ts (TypeScript client). +servers: + - url: / +components: + schemas: + BridgesResult: + type: object + properties: + bridges: + type: array + items: + $ref: "#/components/schemas/BridgeResponse" + count: + type: integer + minimum: 0 + required: + - bridges + - count + description: Paginated response of bridge events + BridgeResponse: + type: object + properties: + block_num: + type: integer + minimum: 0 + block_pos: + type: integer + minimum: 0 + from_address: + type: string + tx_hash: + type: string + global_index: + type: string + pattern: ^-?\d+$ + description: Global index of the bridge event (mainnet flag, rollup id and + deposit count packed into a 72-bit integer). Exceeds 2^53 for every + L1-origin bridge, so it is carried as a decimal string. + x-go-type: types.BigIntString + x-go-type-import: + path: github.com/agglayer/aggkit/bridgeservice/types + block_timestamp: + type: integer + minimum: 0 + leaf_type: + type: integer + minimum: 0 + maximum: 255 + origin_network: + type: integer + minimum: 0 + origin_address: + type: string + destination_network: + type: integer + minimum: 0 + destination_address: + type: string + amount: + type: string + pattern: ^-?\d+$ + description: Amount of tokens bridged, in the smallest unit of the token. + x-go-type: types.BigIntString + x-go-type-import: + path: github.com/agglayer/aggkit/bridgeservice/types + metadata: + type: string + deposit_count: + type: integer + minimum: 0 + bridge_hash: + type: string + txn_sender: + type: string + to_address: + type: string + required: + - block_num + - block_pos + - tx_hash + - global_index + - block_timestamp + - leaf_type + - origin_network + - origin_address + - destination_network + - destination_address + - amount + - metadata + - deposit_count + - bridge_hash + - txn_sender + - to_address + description: Detailed information about a bridge event + ErrorResponse: + type: object + properties: + error: + type: string + required: + - error + description: Generic error response structure + parameters: {} +paths: + /bridge/v1/bridges: + get: + operationId: getBridges + summary: Get bridges + description: Returns a paginated list of bridge events for the specified network. + tags: + - bridges + parameters: + - schema: + type: integer + minimum: 0 + required: true + name: network_id + in: query + - schema: + type: integer + minimum: 0 + exclusiveMinimum: true + required: false + name: page_number + in: query + - schema: + type: integer + minimum: 0 + exclusiveMinimum: true + maximum: 1000 + required: false + name: page_size + in: query + - schema: + type: string + required: false + name: from_address + in: query + - schema: + type: integer + minimum: 0 + required: false + name: deposit_count + in: query + responses: + "200": + description: Paginated bridge events + content: + application/json: + schema: + $ref: "#/components/schemas/BridgesResult" + "400": + description: Invalid query parameters + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" diff --git a/bridgeservice/apispec/openapi-ts.config.ts b/bridgeservice/apispec/openapi-ts.config.ts new file mode 100644 index 000000000..60be634be --- /dev/null +++ b/bridgeservice/apispec/openapi-ts.config.ts @@ -0,0 +1,30 @@ +import type { UserConfig } from '@hey-api/openapi-ts'; + +import { defineRegistryClientConfig } from '@polygonlabs/zod-to-openapi-heyapi'; + +import { buildRegistry } from '#schemas'; + +// `defineRegistryClientConfig` locks in the plugin order and flags this +// pipeline depends on -- in particular the registry plugin ahead of +// @hey-api/typescript, so the codec-aware response types win over the +// wire-shape ones. +// +// `schemasFrom` is the specifier baked into the generated client's schema +// imports, so it has to resolve both from the generated code and from inside +// the plugin, which dynamic-imports it to audit that every name it is about to +// emit really exists. The plugin's `await import()` runs from its own location +// under node_modules, so a `#schemas` subpath alias -- the option its README +// suggests for schemas living in the codegen package -- does not resolve; the +// alias is only visible inside the package that declares it. Using the package +// name plus the self-link in package.json devDependencies makes one specifier +// resolve from both places. The payoff is that the generated transformer +// imports the very Zod schemas that produced openapi.yaml, so a response +// violating the contract is rejected by the same code that wrote it. +const config: UserConfig = await defineRegistryClientConfig({ + registry: buildRegistry(), + schemasFrom: '#schemas', + input: './generated/openapi.yaml', + output: { path: './generated/client', clean: true } +}); + +export default config; diff --git a/bridgeservice/apispec/package.json b/bridgeservice/apispec/package.json new file mode 100644 index 000000000..aca905d53 --- /dev/null +++ b/bridgeservice/apispec/package.json @@ -0,0 +1,38 @@ +{ + "name": "@aggkit/bridge-apispec", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Spec-first demo slice for the aggkit bridge REST API: Zod registry -> OpenAPI -> Go server types + TS codec client", + "license": "Apache-2.0", + "packageManager": "pnpm@10.30.3", + "imports": { + "#schemas": "./src/index.ts" + }, + "scripts": { + "generate": "pnpm run generate:spec && pnpm run generate:client", + "generate:spec": "node scripts/generate-spec.ts", + "generate:client": "openapi-ts -f openapi-ts.config.ts", + "demo": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.4.1", + "@polygonlabs/openapi-registry": "^3.0.0", + "@polygonlabs/zod-codecs": "^1.2.0", + "yaml": "^2.6.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@hey-api/openapi-ts": "^0.97.3", + "@polygonlabs/zod-to-openapi-heyapi": "^2.1.0", + "@types/node": "^24.0.0", + "typescript": "^5.5.0", + "vitest": "^3.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } +} diff --git a/bridgeservice/apispec/pnpm-lock.yaml b/bridgeservice/apispec/pnpm-lock.yaml new file mode 100644 index 000000000..1a7c84534 --- /dev/null +++ b/bridgeservice/apispec/pnpm-lock.yaml @@ -0,0 +1,1511 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@asteasolutions/zod-to-openapi': + specifier: ^8.4.1 + version: 8.5.0(zod@4.4.3) + '@polygonlabs/openapi-registry': + specifier: ^3.0.0 + version: 3.0.0(@asteasolutions/zod-to-openapi@8.5.0(zod@4.4.3))(zod@4.4.3) + '@polygonlabs/zod-codecs': + specifier: ^1.2.0 + version: 1.2.0(@asteasolutions/zod-to-openapi@8.5.0(zod@4.4.3))(zod@4.4.3) + yaml: + specifier: ^2.6.0 + version: 2.9.0 + zod: + specifier: ^4.3.6 + version: 4.4.3 + devDependencies: + '@hey-api/openapi-ts': + specifier: ^0.97.3 + version: 0.97.3(typescript@5.9.3) + '@polygonlabs/zod-to-openapi-heyapi': + specifier: ^2.1.0 + version: 2.1.0(@asteasolutions/zod-to-openapi@8.5.0(zod@4.4.3))(@hey-api/openapi-ts@0.97.3(typescript@5.9.3))(zod@4.4.3) + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) + +packages: + + '@asteasolutions/zod-to-openapi@8.5.0': + resolution: {integrity: sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==} + peerDependencies: + zod: ^4.0.0 + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hey-api/codegen-core@0.8.2': + resolution: {integrity: sha512-R2NMf3wq97rh1mjz33WJQU8svz3F0RYUjvx/QzXucjpSqQ3O5huTdDjErG4fMxSr1X+X56NuDrqtGfHmo1TRUQ==} + engines: {node: '>=22.13.0'} + + '@hey-api/json-schema-ref-parser@1.4.2': + resolution: {integrity: sha512-ZhCFSKI2ipZHEbgmtUHdyddvRU3wJ4elgCfYUC7T7hZa4EivSrVflTQf2w+v3TuaYxR1Y2V2kq3otqTttrrK8Q==} + engines: {node: '>=22.13.0'} + + '@hey-api/openapi-ts@0.97.3': + resolution: {integrity: sha512-4sR6/E/POuy7aPZW9DDjhObzZCq7eSJWiW0+epXeKNczoTWEwdOyWFy9Ca/CnXYlZ3oJsrv0ZD0OO+YuczT7CA==} + engines: {node: '>=22.13.0'} + hasBin: true + peerDependencies: + typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc' + + '@hey-api/shared@0.4.5': + resolution: {integrity: sha512-au4eHpBXAe1du0iMp6ESYuEaMS2jsoEyrbcT246btRhI9rMeQFEs7ZjtcMGXGsxhpaR38A8cPGNHx7QOrWAdMw==} + engines: {node: '>=22.13.0'} + + '@hey-api/spec-types@0.2.0': + resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==} + + '@hey-api/types@0.1.4': + resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@polygonlabs/openapi-registry@3.0.0': + resolution: {integrity: sha512-6lMZ/2CKnFo5NZQ/U1q5WQG9Ky7/KJYI+XyIqc/iMBMiINleO5l3eBFBu4S5D0xni2UjLkPHcHGh2vENeYLr9A==} + peerDependencies: + '@asteasolutions/zod-to-openapi': ^8.0.0 + zod: ^4.0.0 + + '@polygonlabs/zod-codecs@1.2.0': + resolution: {integrity: sha512-VFoNcHxOdkjMaD+Ubg+Mr7mDnMjJ64/rZKTLKN0o/+n6XjIs0A8S1QxShvBmiB362uEA7j1bTtT6KaKp6h5/6A==} + peerDependencies: + '@asteasolutions/zod-to-openapi': ^8.0.0 + zod: ^4.0.0 + peerDependenciesMeta: + '@asteasolutions/zod-to-openapi': + optional: true + + '@polygonlabs/zod-to-openapi-heyapi@2.1.0': + resolution: {integrity: sha512-eFsGwIhfRccTOhucWVFYbSyyCa36ZbXJstoHvoJOdM9b4st54kaqdOk7vOoPd+UY2E42BKsRNTCHGEj4yt/p0A==} + peerDependencies: + '@asteasolutions/zod-to-openapi': ^8.0.0 + '@hey-api/openapi-ts': '>=0.97.3' + '@tanstack/react-query': ^5.0.0 + zod: ^4.0.0 + peerDependenciesMeta: + '@tanstack/react-query': + optional: true + + '@rollup/rollup-android-arm-eabi@4.62.5': + resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.5': + resolution: {integrity: sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.5': + resolution: {integrity: sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.5': + resolution: {integrity: sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.5': + resolution: {integrity: sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.5': + resolution: {integrity: sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.5': + resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.5': + resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.5': + resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.5': + resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.5': + resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.5': + resolution: {integrity: sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + resolution: {integrity: sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + resolution: {integrity: sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.5': + resolution: {integrity: sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.5': + resolution: {integrity: sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + openapi3-ts@4.6.1: + resolution: {integrity: sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rollup@4.62.5: + resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.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 + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@asteasolutions/zod-to-openapi@8.5.0(zod@4.4.3)': + dependencies: + openapi3-ts: 4.6.1 + zod: 4.4.3 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@hey-api/codegen-core@0.8.2': + dependencies: + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + c12: 3.3.4 + color-support: 1.1.3 + transitivePeerDependencies: + - magicast + + '@hey-api/json-schema-ref-parser@1.4.2': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.1.1 + + '@hey-api/openapi-ts@0.97.3(typescript@5.9.3)': + dependencies: + '@hey-api/codegen-core': 0.8.2 + '@hey-api/json-schema-ref-parser': 1.4.2 + '@hey-api/shared': 0.4.5 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + '@lukeed/ms': 2.0.2 + ansi-colors: 4.1.3 + color-support: 1.1.3 + commander: 14.0.3 + get-tsconfig: 4.14.0 + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + '@hey-api/shared@0.4.5': + dependencies: + '@hey-api/codegen-core': 0.8.2 + '@hey-api/json-schema-ref-parser': 1.4.2 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + cross-spawn: 7.0.6 + open: 11.0.0 + semver: 7.7.4 + transitivePeerDependencies: + - magicast + + '@hey-api/spec-types@0.2.0': + dependencies: + '@hey-api/types': 0.1.4 + + '@hey-api/types@0.1.4': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jsdevtools/ono@7.1.3': {} + + '@lukeed/ms@2.0.2': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@polygonlabs/openapi-registry@3.0.0(@asteasolutions/zod-to-openapi@8.5.0(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@asteasolutions/zod-to-openapi': 8.5.0(zod@4.4.3) + zod: 4.4.3 + + '@polygonlabs/zod-codecs@1.2.0(@asteasolutions/zod-to-openapi@8.5.0(zod@4.4.3))(zod@4.4.3)': + dependencies: + zod: 4.4.3 + optionalDependencies: + '@asteasolutions/zod-to-openapi': 8.5.0(zod@4.4.3) + + '@polygonlabs/zod-to-openapi-heyapi@2.1.0(@asteasolutions/zod-to-openapi@8.5.0(zod@4.4.3))(@hey-api/openapi-ts@0.97.3(typescript@5.9.3))(zod@4.4.3)': + dependencies: + '@asteasolutions/zod-to-openapi': 8.5.0(zod@4.4.3) + '@hey-api/openapi-ts': 0.97.3(typescript@5.9.3) + zod: 4.4.3 + + '@rollup/rollup-android-arm-eabi@4.62.5': + optional: true + + '@rollup/rollup-android-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-x64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.5': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + ansi-colors@4.1.3: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.1 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + color-support@1.1.3: {} + + commander@14.0.3: {} + + confbox@0.2.4: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.1: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + defu@6.1.7: {} + + destr@2.0.5: {} + + dotenv@17.4.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + exsolve@1.1.1: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@3.3.1: {} + + is-docker@3.0.0: {} + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + ohash@2.0.12: {} + + open@11.0.0: + dependencies: + default-browser: 5.5.1 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + openapi3-ts@4.6.1: + dependencies: + yaml: 2.9.0 + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + powershell-utils@0.1.0: {} + + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + readdirp@5.1.1: {} + + resolve-pkg-maps@1.0.0: {} + + rollup@4.62.5: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.5 + '@rollup/rollup-android-arm64': 4.62.5 + '@rollup/rollup-darwin-arm64': 4.62.5 + '@rollup/rollup-darwin-x64': 4.62.5 + '@rollup/rollup-freebsd-arm64': 4.62.5 + '@rollup/rollup-freebsd-x64': 4.62.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.5 + '@rollup/rollup-linux-arm-musleabihf': 4.62.5 + '@rollup/rollup-linux-arm64-gnu': 4.62.5 + '@rollup/rollup-linux-arm64-musl': 4.62.5 + '@rollup/rollup-linux-loong64-gnu': 4.62.5 + '@rollup/rollup-linux-loong64-musl': 4.62.5 + '@rollup/rollup-linux-ppc64-gnu': 4.62.5 + '@rollup/rollup-linux-ppc64-musl': 4.62.5 + '@rollup/rollup-linux-riscv64-gnu': 4.62.5 + '@rollup/rollup-linux-riscv64-musl': 4.62.5 + '@rollup/rollup-linux-s390x-gnu': 4.62.5 + '@rollup/rollup-linux-x64-gnu': 4.62.5 + '@rollup/rollup-linux-x64-musl': 4.62.5 + '@rollup/rollup-openbsd-x64': 4.62.5 + '@rollup/rollup-openharmony-arm64': 4.62.5 + '@rollup/rollup-win32-arm64-msvc': 4.62.5 + '@rollup/rollup-win32-ia32-msvc': 4.62.5 + '@rollup/rollup-win32-x64-gnu': 4.62.5 + '@rollup/rollup-win32-x64-msvc': 4.62.5 + fsevents: 2.3.3 + + run-applescript@7.1.0: {} + + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + vite-node@3.2.4(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.26 + rollup: 4.62.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 + + vitest@3.2.7(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + yaml@2.9.0: {} + + zod@4.4.3: {} diff --git a/bridgeservice/apispec/scripts/generate-spec.ts b/bridgeservice/apispec/scripts/generate-spec.ts new file mode 100644 index 000000000..8f8130629 --- /dev/null +++ b/bridgeservice/apispec/scripts/generate-spec.ts @@ -0,0 +1,31 @@ +/** + * Emits generated/openapi.yaml from the Zod registry. + * + * OpenAPI 3.0 rather than 3.1: oapi-codegen v2 — the Go generator on the other + * end of this pipeline — reads 3.0 documents, and the demo's whole point is + * that one artifact feeds both generators. + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; +import { stringify } from 'yaml'; + +import { buildRegistry } from '#schemas'; + +const spec = new OpenApiGeneratorV3(buildRegistry().definitions).generateDocument({ + openapi: '3.0.3', + info: { + title: 'aggkit bridge service (spec-first demo slice)', + version: '0.0.0', + description: + 'Contract-first description of GET /bridge/v1/bridges. Generated from Zod schemas; consumed by oapi-codegen (Go server) and @hey-api/openapi-ts (TypeScript client).' + }, + servers: [{ url: '/' }] +}); + +const outPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'generated', 'openapi.yaml'); +mkdirSync(dirname(outPath), { recursive: true }); +writeFileSync(outPath, stringify(spec)); +console.log(`Written: ${outPath}`); diff --git a/bridgeservice/apispec/src/index.ts b/bridgeservice/apispec/src/index.ts new file mode 100644 index 000000000..31fda082c --- /dev/null +++ b/bridgeservice/apispec/src/index.ts @@ -0,0 +1,14 @@ +// Package barrel. This is what `#schemas` resolves to, which is the module +// specifier baked into the generated client's schema imports — so every schema +// the client references must be exported here under its registered name. +// +// Importing this file also runs schemas.ts's `extendZodAndCodecsWithOpenApi` +// side effect before any caller chains `.openapi(...)`. +export { + BridgeResponse, + BridgesResult, + ErrorResponse, + GetBridgesQuery +} from './schemas.ts'; + +export { buildRegistry } from './registry.ts'; diff --git a/bridgeservice/apispec/src/registry.ts b/bridgeservice/apispec/src/registry.ts new file mode 100644 index 000000000..2d1157083 --- /dev/null +++ b/bridgeservice/apispec/src/registry.ts @@ -0,0 +1,14 @@ +/** + * Registry composition for the demo slice of the aggkit bridge API. + * + * `TypedRegistry` accumulates registered operations into its own type as the + * chain is built, so everything downstream — the OpenAPI document, the + * generated Go server, the generated TypeScript client — derives from this one + * value rather than from a hand-maintained parallel description. + */ + +import { TypedRegistry } from '@polygonlabs/openapi-registry'; + +import { addBridgeRoutes } from './routes/bridges.ts'; + +export const buildRegistry = () => new TypedRegistry().with(addBridgeRoutes); diff --git a/bridgeservice/apispec/src/routes/bridges.ts b/bridgeservice/apispec/src/routes/bridges.ts new file mode 100644 index 000000000..d3d0c735f --- /dev/null +++ b/bridgeservice/apispec/src/routes/bridges.ts @@ -0,0 +1,49 @@ +/** + * The one route this demo migrates: GET /bridge/v1/bridges. + * + * Kept in its own module so adding the remaining bridge operations is an + * append here (or a new `addRoutes` helper) rather than a rewrite — + * the same shape `apps-team-ts-template/packages/example-schemas` uses. + */ + +import type { RouteWithOpId, TypedRegistry } from '@polygonlabs/openapi-registry'; + +import { BridgesResult, ErrorResponse, GetBridgesQuery } from '../schemas.ts'; + +export const addBridgeRoutes = < + Ops extends Record, + Schemes extends Record +>( + r: TypedRegistry +) => + r.registerPath({ + operationId: 'getBridges', + method: 'get', + path: '/bridge/v1/bridges', + summary: 'Get bridges', + description: + 'Returns a paginated list of bridge events for the specified network.', + tags: ['bridges'], + request: { + query: GetBridgesQuery + }, + responses: { + 200: { + description: 'Paginated bridge events', + content: { 'application/json': { schema: BridgesResult } } + }, + 400: { + description: 'Invalid query parameters', + content: { 'application/json': { schema: ErrorResponse } } + }, + // Declared explicitly rather than left to the registry's automatic + // 5xx injection: that injection uses `@polygonlabs/express`'s canonical + // error schema, which registers under the same `ErrorResponse` name as + // aggkit's own and would emit an `allOf` describing a framework aggkit + // does not run. + 500: { + description: 'Internal server error', + content: { 'application/json': { schema: ErrorResponse } } + } + } + }); diff --git a/bridgeservice/apispec/src/schemas.ts b/bridgeservice/apispec/src/schemas.ts new file mode 100644 index 000000000..3a7c0edf2 --- /dev/null +++ b/bridgeservice/apispec/src/schemas.ts @@ -0,0 +1,118 @@ +import { z } from 'zod'; + +import { BigIntegerCodec } from '@polygonlabs/zod-codecs'; +import { extendZodAndCodecsWithOpenApi } from '@polygonlabs/zod-codecs/openapi'; + +// Patches both `ZodType.prototype` and `ZodCodec.prototype` with `.openapi()`. +// Called here, at the top of the only module that defines schemas, so any +// import of this file runs the side effect before a schema is constructed. +// The plain `extendZodWithOpenApi` from @asteasolutions/zod-to-openapi is not +// enough: in zod v4 `ZodCodec` is a sibling of `ZodType`, not a subclass, so +// the upstream patch never reaches an imported codec such as BigIntegerCodec +// and `.openapi(...)` on it throws at module load. +extendZodAndCodecsWithOpenApi(z); + +/** + * Big integers on this API do not fit in a double. `global_index` for an + * L1-origin bridge is `1 << 64 | depositCount` — 18446744073709551621 for + * deposit count 5 — and `amount` is a wei-denominated token amount. Emitting + * either as a bare JSON number silently corrupts it in every JSON.parse-based + * consumer, so the wire format has to be a quoted decimal string. + * + * `BigIntegerCodec` is the wire-string / runtime-bigint pair: it validates a + * digit string on the wire and hands the caller a real `bigint`. The + * `x-go-type` extension is what makes the Go side hold the same line — + * oapi-codegen substitutes aggkit's existing `types.BigIntString` wrapper for + * the generated field, which marshals as a quoted string and accepts a string + * or a number on unmarshal. + * + * `x-go-type: big.Int` would be the obvious-looking choice and is wrong: a raw + * `*big.Int` marshals as a bare number and rejects a quoted string on + * unmarshal, which is exactly the defect this demo exists to prevent. + * + * Declared as a function rather than a shared constant because `.openapi()` + * returns a new schema instance carrying that metadata; each field needs its + * own description. + */ +const aggkitBigInt = (description: string) => + BigIntegerCodec.openapi({ + description, + 'x-go-type': 'types.BigIntString', + 'x-go-type-import': { path: 'github.com/agglayer/aggkit/bridgeservice/types' } + }); + +// Export name === registry name throughout this file. The +// @polygonlabs/zod-to-openapi-heyapi plugin emits +// `import { } from '#schemas'` in the generated client and +// audits at codegen time that each name resolves to a Zod export of the same +// name, so renaming an export silently breaks client generation. + +/** + * Mirrors `bridgeservice/types.BridgeResponse`. Field-for-field, including the + * snake_case wire names and the optional `from_address` (which the Go struct + * marks `omitempty` and serves as a pointer). + * + * The `u32`/`u64` fields are modelled as `z.number().int()` because their real + * ranges — block heights, network ids, deposit counts, unix timestamps — stay + * inside the double-safe range. Only the two fields that genuinely exceed + * 2^53 get the codec treatment. + */ +export const BridgeResponse = z + .object({ + block_num: z.number().int().nonnegative(), + block_pos: z.number().int().nonnegative(), + from_address: z.string().optional(), + tx_hash: z.string(), + global_index: aggkitBigInt( + 'Global index of the bridge event (mainnet flag, rollup id and deposit count packed into a 72-bit integer). Exceeds 2^53 for every L1-origin bridge, so it is carried as a decimal string.' + ), + block_timestamp: z.number().int().nonnegative(), + leaf_type: z.number().int().min(0).max(255), + origin_network: z.number().int().nonnegative(), + origin_address: z.string(), + destination_network: z.number().int().nonnegative(), + destination_address: z.string(), + amount: aggkitBigInt('Amount of tokens bridged, in the smallest unit of the token.'), + metadata: z.string(), + deposit_count: z.number().int().nonnegative(), + bridge_hash: z.string(), + txn_sender: z.string(), + to_address: z.string() + }) + .openapi('BridgeResponse', { description: 'Detailed information about a bridge event' }); + +/** Mirrors `bridgeservice/types.BridgesResult`. */ +export const BridgesResult = z + .object({ + bridges: z.array(BridgeResponse), + count: z.number().int().nonnegative() + }) + .openapi('BridgesResult', { description: 'Paginated response of bridge events' }); + +/** + * Query slot for GET /bridge/v1/bridges. Names are the snake_case ones the + * existing gin handler reads via `c.Query(...)`, so the migrated route is + * wire-compatible with the one it replaces. + * + * Declared with plain `z.number()` rather than `z.coerce.number()`: the + * coercing variant has an `unknown` input type in zod v4, which accepts + * `undefined` and therefore lands in the spec as an optional, nullable + * parameter — `network_id` would stop being required. String-to-number + * coercion is the generated server's job here, not the contract's. + */ +export const GetBridgesQuery = z + .object({ + network_id: z.number().int().nonnegative(), + page_number: z.number().int().positive().optional(), + page_size: z.number().int().positive().max(1000).optional(), + from_address: z.string().optional(), + deposit_count: z.number().int().nonnegative().optional() + }) + .openapi('GetBridgesQuery'); + +/** Mirrors `bridgeservice/types.ErrorResponse`. */ +export const ErrorResponse = z + .object({ + error: z.string() + }) + .openapi('ErrorResponse', { description: 'Generic error response structure' }); diff --git a/bridgeservice/apispec/test/constants.ts b/bridgeservice/apispec/test/constants.ts new file mode 100644 index 000000000..9590d5140 --- /dev/null +++ b/bridgeservice/apispec/test/constants.ts @@ -0,0 +1,2 @@ +/** Mirrors demo.SpecFirstPrefix in bridgeservice/oapi/demo/router.go. */ +export const SpecFirstPrefix = '/specfirst'; diff --git a/bridgeservice/apispec/test/go-server.setup.ts b/bridgeservice/apispec/test/go-server.setup.ts new file mode 100644 index 000000000..e60d9eaa7 --- /dev/null +++ b/bridgeservice/apispec/test/go-server.setup.ts @@ -0,0 +1,93 @@ +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { createServer } from 'node:net'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import type { TestProject } from 'vitest/node'; + +declare module 'vitest' { + export interface ProvidedContext { + demoBaseUrl: string; + } +} + +const repoRoot = resolve(import.meta.dirname, '..', '..', '..'); + +/** + * Compile the demo to a binary rather than running `go run`. `go run` starts a + * second process for the compiled program, so killing it at teardown leaves the + * server holding the port. + */ +const buildDemoBinary = (outDir: string): string => { + const binary = join(outDir, 'bridge-specfirst-demo'); + const built = spawnSync('go', ['build', '-o', binary, './bridgeservice/oapi/demo/cmd'], { + cwd: repoRoot, + encoding: 'utf8' + }); + if (built.status !== 0) { + throw new Error(`go build failed:\n${built.stderr || built.stdout}`); + } + return binary; +}; + +/** Ask the OS for a free port instead of hard-coding one, so parallel runs and + * a developer's already-running demo server never collide. */ +const freePort = async (): Promise => + new Promise((resolvePort, reject) => { + const probe = createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + if (address === null || typeof address === 'string') { + probe.close(); + reject(new Error('could not determine a free port')); + return; + } + const { port } = address; + probe.close(() => resolvePort(port)); + }); + }); + +const waitForReady = async (baseUrl: string, child: ChildProcess): Promise => { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`demo server exited early with code ${child.exitCode}`); + } + try { + const response = await fetch(`${baseUrl}/`); + if (response.ok) return; + } catch { + // connection refused while the listener is still coming up + } + await sleep(100); + } + throw new Error(`demo server did not become ready at ${baseUrl}`); +}; + +export default async function setup({ provide }: TestProject) { + const outDir = mkdtempSync(join(tmpdir(), 'bridge-specfirst-demo-')); + const binary = buildDemoBinary(outDir); + const port = await freePort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const child = spawn(binary, ['-port', String(port)], { stdio: ['ignore', 'pipe', 'pipe'] }); + child.stderr.on('data', (chunk: Buffer) => process.stderr.write(chunk)); + + try { + await waitForReady(baseUrl, child); + } catch (err) { + child.kill('SIGKILL'); + rmSync(outDir, { recursive: true, force: true }); + throw err; + } + + provide('demoBaseUrl', baseUrl); + + return async () => { + child.kill('SIGTERM'); + rmSync(outDir, { recursive: true, force: true }); + }; +} diff --git a/bridgeservice/apispec/test/wire-format.test.ts b/bridgeservice/apispec/test/wire-format.test.ts new file mode 100644 index 000000000..684cc82a0 --- /dev/null +++ b/bridgeservice/apispec/test/wire-format.test.ts @@ -0,0 +1,93 @@ +import { beforeAll, describe, expect, inject, it } from 'vitest'; +import type { ZodIssue } from 'zod'; + +import { SpecFirstPrefix } from './constants.ts'; + +import { getBridges, isResponseValidationError } from '../generated/client/index.js'; + +/** + * The generated client is the consumer half of the pipeline. It validates every + * response against the same Zod schemas that produced openapi.yaml, so these + * two tests are the demonstration in its sharpest form: + * + * - pointed at the endpoint the bridge service serves today, the client + * refuses the response, naming global_index; + * - pointed at the endpoint generated from the contract, the client accepts + * it and hands back exact bigints. + * + * Nothing here inspects the raw bytes. That is the point: a consumer written + * against the published contract cannot tell the difference between "the + * server is wrong" and "my parser silently rounded" unless something validates. + */ + +let baseUrl!: string; + +beforeAll(() => { + baseUrl = inject('demoBaseUrl'); +}); + +/** 2^64 + 5 -- the global index of the first canned row. */ +const L1_ORIGIN_GLOBAL_INDEX = 18446744073709551621n; + +/** 10^18 -- the amount on that row, also past the 2^53 double-safe range. */ +const L1_ORIGIN_AMOUNT = 1000000000000000000n; + +describe('the endpoint the service serves today', () => { + it('is rejected by the generated client, at global_index', async () => { + const { data, error } = await getBridges({ baseUrl, query: { network_id: 0 } }); + + expect(data).equal(undefined); + // zod-to-openapi-heyapi >= 2.0.4 classifies a 2xx body that fails response + // validation as a ResponseValidationError (earlier versions misreported it + // as a TransportError, which this very demo helped surface). The guard is + // a type predicate, so `cause` (the ZodError) and `body` (the offending + // post-JSON.parse payload) need no casts. + if (!isResponseValidationError(error)) { + throw new Error(`expected ResponseValidationError, got ${String(error)}`); + } + + const issues: ZodIssue[] = error.cause.issues; + const globalIndexIssue = issues.find((issue) => issue.path.at(-1) === 'global_index'); + + expect(globalIndexIssue).property('code', 'invalid_type'); + expect(globalIndexIssue?.message).contains('expected string, received number'); + + // Every row is rejected, not just the first: the encoding is systematic. + expect(issues.filter((issue) => issue.path.at(-1) === 'global_index')).lengthOf(3); + + // amount is not among the complaints. Its sibling field already uses the + // string wrapper, which is what makes global_index an oversight rather + // than a design choice. + expect(issues.some((issue) => issue.path.at(-1) === 'amount')).equal(false); + + // The rejected body rides on the error for diagnosis -- and it carries the + // silently-rounded double, which is exactly the corruption being refused. + const body = error.body as { bridges: Array<{ global_index: unknown }> }; + expect(typeof body.bridges[0]?.global_index).equal('number'); + }); +}); + +describe('the endpoint generated from the contract', () => { + it('round-trips both big integers as exact bigints', async () => { + const { data, error } = await getBridges({ + baseUrl: `${baseUrl}${SpecFirstPrefix}`, + query: { network_id: 0 } + }); + + expect(error).equal(undefined); + expect(data).property('count', 3); + + const [first] = data?.bridges ?? []; + + expect(first?.global_index).equal(L1_ORIGIN_GLOBAL_INDEX); + expect(typeof first?.global_index).equal('bigint'); + expect(first?.amount).equal(L1_ORIGIN_AMOUNT); + expect(typeof first?.amount).equal('bigint'); + + // What the round trip is worth: the nearest double to this value is a + // different number, so a client that read it as a JSON number would be + // holding the wrong bridge. + expect(Number(L1_ORIGIN_GLOBAL_INDEX)).not.equal(L1_ORIGIN_GLOBAL_INDEX); + expect(BigInt(Number(L1_ORIGIN_GLOBAL_INDEX))).equal(18446744073709551616n); + }); +}); diff --git a/bridgeservice/apispec/tsconfig.json b/bridgeservice/apispec/tsconfig.json new file mode 100644 index 000000000..870000958 --- /dev/null +++ b/bridgeservice/apispec/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "lib": ["ES2024", "DOM"], + "target": "ES2024", + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "test/**/*.ts", "*.ts"] +} diff --git a/bridgeservice/apispec/vitest.config.ts b/bridgeservice/apispec/vitest.config.ts new file mode 100644 index 000000000..05bf755a7 --- /dev/null +++ b/bridgeservice/apispec/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + // The suite is only meaningful against a running server, so the setup owns + // that server's lifecycle: it builds and starts the Go demo binary before + // any test runs and kills it afterwards. A clean checkout needs nothing + // more than `pnpm install && pnpm run generate && pnpm run demo`. + globalSetup: ['./test/go-server.setup.ts'], + testTimeout: 30_000, + // Generous, because the first run compiles the Go binary from cold. + hookTimeout: 300_000 + } +}); diff --git a/bridgeservice/oapi/demo/cmd/main.go b/bridgeservice/oapi/demo/cmd/main.go new file mode 100644 index 000000000..aac4e12f4 --- /dev/null +++ b/bridgeservice/oapi/demo/cmd/main.go @@ -0,0 +1,54 @@ +// Command demo serves the current bridge service and the spec-first generated +// server side by side over one set of canned bridge rows. +// +// go run ./bridgeservice/oapi/demo/cmd +// curl -s 'http://127.0.0.1:8099/bridge/v1/bridges?network_id=0' +// curl -s 'http://127.0.0.1:8099/specfirst/bridge/v1/bridges?network_id=0' +// +// The first response carries global_index as a bare JSON number above 2^53; the +// second carries it as a quoted decimal string. Same rows, same computation, +// different wire format. +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + "time" + + "github.com/agglayer/aggkit/bridgeservice/oapi/demo" +) + +const ( + defaultPort = "8099" + serverReadTimeout = 15 * time.Second +) + +func main() { + port := flag.String("port", envOr("PORT", defaultPort), "port to listen on (127.0.0.1 only)") + flag.Parse() + + addr := fmt.Sprintf("127.0.0.1:%s", *port) + server := &http.Server{ + Addr: addr, + Handler: demo.NewRouter(demo.CannedBridges()), + ReadHeaderTimeout: serverReadTimeout, + } + + fmt.Printf("listening on http://%s\n", addr) + fmt.Printf(" current: http://%s/bridge/v1/bridges?network_id=0\n", addr) + fmt.Printf(" spec-first: http://%s%s/bridge/v1/bridges?network_id=0\n", addr, demo.SpecFirstPrefix) + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + fmt.Fprintf(os.Stderr, "server stopped: %v\n", err) + os.Exit(1) + } +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/bridgeservice/oapi/demo/demo_test.go b/bridgeservice/oapi/demo/demo_test.go new file mode 100644 index 000000000..20937cfa5 --- /dev/null +++ b/bridgeservice/oapi/demo/demo_test.go @@ -0,0 +1,107 @@ +package demo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "github.com/agglayer/aggkit/bridgeservice/oapi" + "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/stretchr/testify/require" +) + +// l1OriginGlobalIndex is 2^64+5 -- the global index of the first canned row. +// Rounded to the nearest float64 it becomes 18446744073709551616, so any +// consumer that parses the response with a JSON parser backed by doubles +// (every JavaScript runtime, by default) reads back a different bridge. +const l1OriginGlobalIndex = "18446744073709551621" + +// canned amount for the first row, 10^18. Also past 2^53. +const l1OriginAmount = "1000000000000000000" + +// The assertions below deliberately run against the raw response bytes. +// Unmarshalling first would defeat the purpose: Go's encoding/json happily +// decodes 18446744073709551621 into a *big.Int with no loss, so a decoded +// comparison would report both endpoints as correct and hide the defect that +// only exists in the serialised form. + +func TestCurrentServiceEmitsGlobalIndexAsABareNumber(t *testing.T) { + body := get(t, "/bridge/v1/bridges?network_id=0") + + require.Regexp(t, regexp.MustCompile(`"global_index":`+l1OriginGlobalIndex+`\b`), string(body), + "the shipped BridgeResponse types the field as *big.Int, which encoding/json writes as a bare number") + require.NotContains(t, string(body), `"global_index":"`+l1OriginGlobalIndex+`"`) + + // amount is the control: its sibling field already uses the + // types.BigIntString wrapper and is therefore quoted on the same response. + require.Contains(t, string(body), `"amount":"`+l1OriginAmount+`"`, + "amount uses types.BigIntString, so only global_index is inconsistent") +} + +func TestSpecFirstServerEmitsGlobalIndexAsAQuotedString(t *testing.T) { + body := get(t, SpecFirstPrefix+"/bridge/v1/bridges?network_id=0") + + require.Contains(t, string(body), `"global_index":"`+l1OriginGlobalIndex+`"`) + require.Contains(t, string(body), `"amount":"`+l1OriginAmount+`"`) + require.NotRegexp(t, regexp.MustCompile(`"global_index":`+l1OriginGlobalIndex+`\b`), string(body)) +} + +// TestBothEndpointsAgreeOnTheValue guards the demonstration itself: if the two +// mounted servers ever served different bridges, the wire-format comparison +// above would be meaningless. +func TestBothEndpointsAgreeOnTheValue(t *testing.T) { + var current struct { + Bridges []struct { + GlobalIndex json.Number `json:"global_index"` + DepositCount uint32 `json:"deposit_count"` + } `json:"bridges"` + } + require.NoError(t, json.Unmarshal(get(t, "/bridge/v1/bridges?network_id=0"), ¤t)) + + var specFirst oapi.BridgesResult + require.NoError(t, json.Unmarshal(get(t, SpecFirstPrefix+"/bridge/v1/bridges?network_id=0"), &specFirst)) + + require.Len(t, current.Bridges, len(CannedBridges())) + require.Len(t, specFirst.Bridges, len(CannedBridges())) + for i := range current.Bridges { + require.Equal(t, current.Bridges[i].GlobalIndex.String(), string(specFirst.Bridges[i].GlobalIndex)) + require.Equal(t, current.Bridges[i].DepositCount, uint32(specFirst.Bridges[i].DepositCount)) //nolint:gosec // fixture + } +} + +// TestGeneratedTypesRoundTripTheQuotedForm covers the other half of the +// contract. x-go-type: big.Int would have produced a field that serialises as a +// number AND rejects a quoted string on the way back in, so a client sending +// the documented format would get a 400. types.BigIntString accepts it. +func TestGeneratedTypesRoundTripTheQuotedForm(t *testing.T) { + const wire = `{"bridges":[{"block_num":1234,"block_pos":1,"tx_hash":"0x01",` + + `"global_index":"` + l1OriginGlobalIndex + `","block_timestamp":1684500000,"leaf_type":0,` + + `"origin_network":0,"origin_address":"0x02","destination_network":10,"destination_address":"0x03",` + + `"amount":"` + l1OriginAmount + `","metadata":"0x","deposit_count":5,"bridge_hash":"0x04",` + + `"txn_sender":"0x05","to_address":"0x06"}],"count":1}` + + var decoded oapi.BridgesResult + require.NoError(t, json.Unmarshal([]byte(wire), &decoded)) + + require.Len(t, decoded.Bridges, 1) + require.Equal(t, types.BigIntString(l1OriginGlobalIndex), decoded.Bridges[0].GlobalIndex) + require.Equal(t, l1OriginGlobalIndex, decoded.Bridges[0].GlobalIndex.ToBigInt().String(), + "the wrapper keeps full precision -- ToBigInt parses the decimal string") + + reencoded, err := json.Marshal(decoded) + require.NoError(t, err) + require.Contains(t, string(reencoded), `"global_index":"`+l1OriginGlobalIndex+`"`, + "encode(decode(x)) == x for the documented wire format") +} + +func get(t *testing.T, path string) []byte { + t.Helper() + + recorder := httptest.NewRecorder() + NewRouter(CannedBridges()).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + + return recorder.Body.Bytes() +} diff --git a/bridgeservice/oapi/demo/fixtures.go b/bridgeservice/oapi/demo/fixtures.go new file mode 100644 index 000000000..c5eafcdb2 --- /dev/null +++ b/bridgeservice/oapi/demo/fixtures.go @@ -0,0 +1,97 @@ +// Package demo mounts the current bridge service and the spec-first generated +// server side by side over one set of canned bridge rows, so the two wire +// formats for the same data can be compared directly. +// +// It exists to demonstrate a wire-format defect and the pipeline that prevents +// it. Nothing here is production code: the syncer dependencies are mocks and +// the data is fixed. +package demo + +import ( + "math/big" + + "github.com/agglayer/aggkit/bridgesync" + "github.com/ethereum/go-ethereum/common" +) + +// MainnetNetworkID is the network id the demo queries with. Bridges that +// originate on L1 encode a mainnet flag into bit 64 of their global index, +// which is what pushes the value past the range JavaScript numbers can hold. +const MainnetNetworkID uint32 = 0 + +// EtrogUpgradeBlock of zero disables the pre-Etrog legacy global-index +// encoding, so every canned row gets the modern packed encoding. The real +// service reads this from the agglayer manager contract. +const EtrogUpgradeBlock uint64 = 0 + +// wei is 10^18 -- one whole token. Chosen for the canned amounts because it is +// comfortably above 2^53 and therefore also unrepresentable as a JSON number, +// making `amount` a second witness to the same defect as `global_index`. +func wei(whole int64) *big.Int { + return new(big.Int).Mul(big.NewInt(whole), new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)) +} + +// CannedBridges is the single source of data for both mounted servers. The +// first row is the interesting one: deposit count 5 on an L1-origin bridge +// encodes to global index 2^64+5 = 18446744073709551621, which loses precision +// the moment it is parsed as a double. +func CannedBridges() []*bridgesync.Bridge { + return []*bridgesync.Bridge{ + { + BlockNum: 1234, + BlockPos: 1, + FromAddress: addr("0xabc1234567890abcdef1234567890abcdef12340"), + TxHash: common.HexToHash("0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"), + BlockTimestamp: 1684500000, + LeafType: 0, + OriginNetwork: 0, + OriginAddress: common.HexToAddress("0x0000000000000000000000000000000000000000"), + DestinationNetwork: 10, + DestinationAddress: common.HexToAddress("0xdef4567890abcdef1234567890abcdef12345678"), + Amount: wei(1), + Metadata: []byte{}, + DepositCount: 5, + TxnSender: common.HexToAddress("0xabc1234567890abcdef1234567890abcdef12340"), + ToAddress: common.HexToAddress("0xF9D64d54D32EE2BDceAAbFA60C4C438E224427d0"), + }, + { + BlockNum: 1240, + BlockPos: 0, + FromAddress: addr("0x1111111111111111111111111111111111111111"), + TxHash: common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111"), + BlockTimestamp: 1684500120, + LeafType: 0, + OriginNetwork: 0, + OriginAddress: common.HexToAddress("0x2222222222222222222222222222222222222222"), + DestinationNetwork: 10, + DestinationAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + Amount: wei(42), + Metadata: []byte{0xde, 0xad, 0xbe, 0xef}, + DepositCount: 6, + TxnSender: common.HexToAddress("0x1111111111111111111111111111111111111111"), + ToAddress: common.HexToAddress("0x4444444444444444444444444444444444444444"), + }, + { + BlockNum: 1250, + BlockPos: 3, + FromAddress: nil, + TxHash: common.HexToHash("0x5555555555555555555555555555555555555555555555555555555555555555"), + BlockTimestamp: 1684500240, + LeafType: 1, + OriginNetwork: 0, + OriginAddress: common.HexToAddress("0x6666666666666666666666666666666666666666"), + DestinationNetwork: 10, + DestinationAddress: common.HexToAddress("0x7777777777777777777777777777777777777777"), + Amount: wei(7), + Metadata: []byte{}, + DepositCount: 7, + TxnSender: common.HexToAddress("0x6666666666666666666666666666666666666666"), + ToAddress: common.HexToAddress("0x8888888888888888888888888888888888888888"), + }, + } +} + +func addr(hex string) *common.Address { + a := common.HexToAddress(hex) + return &a +} diff --git a/bridgeservice/oapi/demo/router.go b/bridgeservice/oapi/demo/router.go new file mode 100644 index 000000000..f25156a1d --- /dev/null +++ b/bridgeservice/oapi/demo/router.go @@ -0,0 +1,79 @@ +package demo + +import ( + "time" + + "github.com/agglayer/aggkit/bridgeservice" + "github.com/agglayer/aggkit/bridgeservice/mocks" + "github.com/agglayer/aggkit/bridgeservice/oapi" + "github.com/agglayer/aggkit/bridgesync" + "github.com/agglayer/aggkit/log" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/mock" +) + +// SpecFirstPrefix is where the generated strict server is mounted. The +// generated routes already carry the /bridge/v1 prefix from the contract, so +// the full path is /bridge/v1/bridges -- deliberately a +// mirror of the live route one segment along, so the two can be curled back to +// back. +const SpecFirstPrefix = "/specfirst" + +// demoReadTimeout bounds the request context the real handler builds. The +// service's own tests pass zero here, which produces an already-expired +// context; harmless for handlers whose dependencies ignore it, but not +// something to copy into anything that runs. +const demoReadTimeout = 10 * time.Second + +// NewRouter builds one gin engine carrying both servers over the same rows: +// +// GET /bridge/v1/bridges -- the real BridgeService, unmodified +// GET /specfirst/bridge/v1/bridges -- the generated strict server +// +// The real service is instantiated exactly as bridgeservice's own test suite +// does it, with mocked syncers standing in for the databases. That matters for +// the demonstration: the left-hand endpoint is not a reimplementation or a +// simplification, it is the shipped handler, reached through the shipped +// routing, serialising with the shipped response types. +func NewRouter(bridges []*bridgesync.Bridge) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + engine := gin.New() + + registerRealBridgeService(engine, bridges) + + oapi.RegisterHandlers( + engine.Group(SpecFirstPrefix), + oapi.NewStrictHandler(NewSpecFirstServer(bridges), nil), + ) + + return engine +} + +// registerRealBridgeService wires the production BridgeService onto router with +// mocked syncers that return the canned rows. +func registerRealBridgeService(router gin.IRouter, bridges []*bridgesync.Bridge) { + bridgeL1 := &mocks.Bridger{} + bridgeL1.On("GetBridgesPaged", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(bridges, len(bridges), nil) + + upgradeQuerier := &mocks.AgglayerManagerUpgradeQuerier{} + upgradeQuerier.On("GetUpgradeBlock", mock.Anything, mock.Anything).Return(EtrogUpgradeBlock) + + service := bridgeservice.New( + &bridgeservice.Config{ + Logger: log.WithFields("module", "bridgeservice-specfirst-demo"), + ReadTimeout: demoReadTimeout, + WriteTimeout: demoReadTimeout, + NetworkID: MainnetNetworkID, + }, + upgradeQuerier, + &mocks.L1InfoTreeSyncer{}, + &mocks.L2GERSyncer{}, + bridgeL1, + &mocks.Claimer{}, + &mocks.Bridger{}, + &mocks.Claimer{}, + ) + + service.RegisterRoutes(router) +} diff --git a/bridgeservice/oapi/demo/specfirst.go b/bridgeservice/oapi/demo/specfirst.go new file mode 100644 index 000000000..272d28ba7 --- /dev/null +++ b/bridgeservice/oapi/demo/specfirst.go @@ -0,0 +1,81 @@ +package demo + +import ( + "context" + "encoding/hex" + "fmt" + + "github.com/agglayer/aggkit/bridgeservice/oapi" + "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgesync" +) + +// SpecFirstServer implements the generated oapi.StrictServerInterface over the +// canned rows. +// +// The reason to look at this file is what it does not contain: no JSON tags, no +// marshalling decisions, no choice about how a big integer reaches the wire. +// Those live in the contract, and oapi-codegen rendered them into +// oapi.BridgeResponse. The compiler rejects a handler that returns anything +// else, which is the property the current hand-written service lacks. +type SpecFirstServer struct { + bridges []*bridgesync.Bridge +} + +// NewSpecFirstServer returns a strict server serving the supplied rows. +func NewSpecFirstServer(bridges []*bridgesync.Bridge) *SpecFirstServer { + return &SpecFirstServer{bridges: bridges} +} + +// GetBridges implements the generated strict handler for GET /bridge/v1/bridges. +func (s *SpecFirstServer) GetBridges( + _ context.Context, request oapi.GetBridgesRequestObject, +) (oapi.GetBridgesResponseObject, error) { + networkID := uint32(request.Params.NetworkId) //nolint:gosec // demo fixture; ids are small + + responses := make([]oapi.BridgeResponse, 0, len(s.bridges)) + for _, bridge := range s.bridges { + responses = append(responses, toSpecFirstResponse(bridge, networkID)) + } + + return oapi.GetBridges200JSONResponse{ + Bridges: responses, + Count: len(responses), + }, nil +} + +// toSpecFirstResponse maps a synced bridge onto the generated response type. It +// mirrors bridgeservice.NewBridgeResponse field for field, and computes the +// global index with the same bridgesync helper, so any difference between the +// two mounted endpoints comes from the wire format alone and not from the data. +func toSpecFirstResponse(bridge *bridgesync.Bridge, networkID uint32) oapi.BridgeResponse { + globalIndex, _ := bridgesync.GlobalIndexForBridge( + bridge.DestinationNetwork, bridge.BlockNum, bridge.DepositCount, networkID, EtrogUpgradeBlock) + + var fromAddress *string + if bridge.FromAddress != nil { + hexAddr := bridge.FromAddress.Hex() + fromAddress = &hexAddr + } + + return oapi.BridgeResponse{ + BlockNum: int(bridge.BlockNum), //nolint:gosec // demo fixture; block numbers are small + BlockPos: int(bridge.BlockPos), //nolint:gosec // demo fixture + FromAddress: fromAddress, + TxHash: bridge.TxHash.Hex(), + GlobalIndex: types.BigIntString(globalIndex.String()), + BlockTimestamp: int(bridge.BlockTimestamp), //nolint:gosec // demo fixture + LeafType: int(bridge.LeafType), + OriginNetwork: int(bridge.OriginNetwork), + OriginAddress: bridge.OriginAddress.Hex(), + + DestinationNetwork: int(bridge.DestinationNetwork), + DestinationAddress: bridge.DestinationAddress.Hex(), + Amount: types.BigIntString(bridge.Amount.String()), + Metadata: fmt.Sprintf("0x%s", hex.EncodeToString(bridge.Metadata)), + DepositCount: int(bridge.DepositCount), + BridgeHash: bridge.Hash().Hex(), + TxnSender: bridge.TxnSender.Hex(), + ToAddress: bridge.ToAddress.Hex(), + } +} diff --git a/bridgeservice/oapi/generate.go b/bridgeservice/oapi/generate.go new file mode 100644 index 000000000..108a4f8d6 --- /dev/null +++ b/bridgeservice/oapi/generate.go @@ -0,0 +1,8 @@ +// Package oapi holds the server types and strict gin bindings generated from +// the OpenAPI document in bridgeservice/apispec/generated/openapi.yaml. +// +// The document is not hand-written: it is emitted by the Zod registry in +// bridgeservice/apispec. Regenerate this package after any change there. +package oapi + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.8.0 -config oapi-codegen.yaml ../apispec/generated/openapi.yaml diff --git a/bridgeservice/oapi/oapi-codegen.yaml b/bridgeservice/oapi/oapi-codegen.yaml new file mode 100644 index 000000000..c98333996 --- /dev/null +++ b/bridgeservice/oapi/oapi-codegen.yaml @@ -0,0 +1,17 @@ +# oapi-codegen v2.8.0 configuration for the spec-first demo slice. +# +# Input is bridgeservice/apispec/generated/openapi.yaml, which is itself +# generated from the Zod registry. Nothing in this file describes the API -- +# it only says how the contract should be rendered into Go. +# +# strict-server is the point of the exercise: it generates an interface whose +# method signatures carry the typed request and response objects, so a handler +# that returns the wrong shape fails to compile. The non-strict server hands +# you a *gin.Context and trusts you to write the right JSON, which is exactly +# the freedom that let global_index drift away from its documented type. +package: oapi +output: oapi.gen.go +generate: + models: true + gin-server: true + strict-server: true diff --git a/bridgeservice/oapi/oapi.gen.go b/bridgeservice/oapi/oapi.gen.go new file mode 100644 index 000000000..c1b41062d --- /dev/null +++ b/bridgeservice/oapi/oapi.gen.go @@ -0,0 +1,305 @@ +// Package oapi provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT. +package oapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/gin-gonic/gin" + "github.com/oapi-codegen/runtime" +) + +// BridgeResponse Detailed information about a bridge event +type BridgeResponse struct { + // Amount Amount of tokens bridged, in the smallest unit of the token. + Amount types.BigIntString `json:"amount"` + BlockNum int `json:"block_num"` + BlockPos int `json:"block_pos"` + BlockTimestamp int `json:"block_timestamp"` + BridgeHash string `json:"bridge_hash"` + DepositCount int `json:"deposit_count"` + DestinationAddress string `json:"destination_address"` + DestinationNetwork int `json:"destination_network"` + FromAddress *string `json:"from_address,omitempty"` + + // GlobalIndex Global index of the bridge event (mainnet flag, rollup id and deposit count packed into a 72-bit integer). Exceeds 2^53 for every L1-origin bridge, so it is carried as a decimal string. + GlobalIndex types.BigIntString `json:"global_index"` + LeafType int `json:"leaf_type"` + Metadata string `json:"metadata"` + OriginAddress string `json:"origin_address"` + OriginNetwork int `json:"origin_network"` + ToAddress string `json:"to_address"` + TxHash string `json:"tx_hash"` + TxnSender string `json:"txn_sender"` +} + +// BridgesResult Paginated response of bridge events +type BridgesResult struct { + Bridges []BridgeResponse `json:"bridges"` + Count int `json:"count"` +} + +// ErrorResponse Generic error response structure +type ErrorResponse struct { + Error string `json:"error"` +} + +// GetBridgesParams defines parameters for GetBridges. +type GetBridgesParams struct { + NetworkId int `form:"network_id" json:"network_id"` + PageNumber *int `form:"page_number,omitempty" json:"page_number,omitempty"` + PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"` + FromAddress *string `form:"from_address,omitempty" json:"from_address,omitempty"` + DepositCount *int `form:"deposit_count,omitempty" json:"deposit_count,omitempty"` +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // GetBridges Get bridges + // (GET /bridge/v1/bridges) + GetBridges(c *gin.Context, params GetBridgesParams) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// GetBridges operation middleware +func (siw *ServerInterfaceWrapper) GetBridges(c *gin.Context) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params GetBridgesParams + + // ------------- Required query parameter "network_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, true, "network_id", c.Request.URL.Query(), ¶ms.NetworkId, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter network_id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "page_number" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "page_number", c.Request.URL.Query(), ¶ms.PageNumber, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter page_number: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "page_size" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "page_size", c.Request.URL.Query(), ¶ms.PageSize, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter page_size: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "from_address" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "from_address", c.Request.URL.Query(), ¶ms.FromAddress, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter from_address: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "deposit_count" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "deposit_count", c.Request.URL.Query(), ¶ms.DepositCount, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter deposit_count: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetBridges(c, params) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.GET(options.BaseURL+"/bridge/v1/bridges", wrapper.GetBridges) +} + +type GetBridgesRequestObject struct { + Params GetBridgesParams +} + +type GetBridgesResponseObject interface { + VisitGetBridgesResponse(w http.ResponseWriter) error +} + +type GetBridges200JSONResponse BridgesResult + +func (response GetBridges200JSONResponse) VisitGetBridgesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetBridges400JSONResponse ErrorResponse + +func (response GetBridges400JSONResponse) VisitGetBridgesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type GetBridges500JSONResponse ErrorResponse + +func (response GetBridges500JSONResponse) VisitGetBridgesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // GetBridges Get bridges + // (GET /bridge/v1/bridges) + GetBridges(ctx context.Context, request GetBridgesRequestObject) (GetBridgesResponseObject, error) +} + +type StrictHandlerFunc func(ctx *gin.Context, request any) (any, error) +type StrictMiddlewareFunc func(f StrictHandlerFunc, operationID string) StrictHandlerFunc + +type StrictGinServerOptions struct { + // RequestErrorHandlerFunc is called when a request cannot be parsed or + // decoded. It is invoked for JSON bind failures, form parse/bind errors, + // multipart reader errors, media type parse errors, missing multipart + // boundaries, and request body read errors. The default returns 400. + RequestErrorHandlerFunc func(ctx *gin.Context, err error) + // HandlerErrorFunc is called when the application handler (or any + // middleware wrapping it) returns a non-nil error. The default returns 500. + HandlerErrorFunc func(ctx *gin.Context, err error) + // ResponseErrorHandlerFunc is called when the response object fails to + // serialize (Visit*Response returns an error) or when the handler returns + // an unexpected response type. The default returns 500. + ResponseErrorHandlerFunc func(ctx *gin.Context, err error) +} + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: StrictGinServerOptions{ + RequestErrorHandlerFunc: func(ctx *gin.Context, err error) { + ctx.JSON(http.StatusBadRequest, gin.H{"msg": err.Error()}) + }, + HandlerErrorFunc: func(ctx *gin.Context, err error) { + ctx.JSON(http.StatusInternalServerError, gin.H{"msg": err.Error()}) + }, + ResponseErrorHandlerFunc: func(ctx *gin.Context, err error) { + ctx.JSON(http.StatusInternalServerError, gin.H{"msg": err.Error()}) + }, + }} +} + +func NewStrictHandlerWithOptions(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc, options StrictGinServerOptions) ServerInterface { + if options.RequestErrorHandlerFunc == nil { + options.RequestErrorHandlerFunc = func(ctx *gin.Context, err error) { + ctx.JSON(http.StatusBadRequest, gin.H{"msg": err.Error()}) + } + } + if options.HandlerErrorFunc == nil { + options.HandlerErrorFunc = func(ctx *gin.Context, err error) { + ctx.JSON(http.StatusInternalServerError, gin.H{"msg": err.Error()}) + } + } + if options.ResponseErrorHandlerFunc == nil { + options.ResponseErrorHandlerFunc = func(ctx *gin.Context, err error) { + ctx.JSON(http.StatusInternalServerError, gin.H{"msg": err.Error()}) + } + } + return &strictHandler{ssi: ssi, middlewares: middlewares, options: options} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc + options StrictGinServerOptions +} + +// GetBridges operation middleware +func (sh *strictHandler) GetBridges(ctx *gin.Context, params GetBridgesParams) { + var request GetBridgesRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetBridges(ctx, request.(GetBridgesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetBridges") + } + + response, err := handler(ctx, request) + + if err != nil { + sh.options.HandlerErrorFunc(ctx, err) + } else if validResponse, ok := response.(GetBridgesResponseObject); ok { + if err := validResponse.VisitGetBridgesResponse(ctx.Writer); err != nil { + sh.options.ResponseErrorHandlerFunc(ctx, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(ctx, fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/go.mod b/go.mod index 2f32a7dff..e8c718a8f 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/knadh/koanf/v2 v2.3.5 github.com/mattn/go-sqlite3 v1.14.48 github.com/mitchellh/mapstructure v1.5.0 + github.com/oapi-codegen/runtime v1.7.0 github.com/pelletier/go-toml/v2 v2.4.3 github.com/prometheus/client_golang v1.24.0 github.com/prometheus/client_model v0.6.2 @@ -62,6 +63,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect github.com/VictoriaMetrics/fastcache v1.13.0 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.32.8 // indirect github.com/aws/aws-sdk-go-v2/config v1.28.11 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.52 // indirect @@ -154,7 +156,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/mailru/easyjson v0.9.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/sha256-simd v1.0.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect @@ -216,7 +218,7 @@ require ( golang.org/x/sys v0.47.0 // indirect golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect golang.org/x/text v0.39.0 // indirect - golang.org/x/time v0.12.0 // indirect + golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.47.0 // indirect golang.org/x/tools/go/expect v0.1.1-deprecated // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect diff --git a/go.sum b/go.sum index ba152f069..3fb6be975 100644 --- a/go.sum +++ b/go.sum @@ -44,12 +44,15 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/agglayer/go_signer v0.0.7 h1:V+4wFWjGKdL1GgXSzx2RLgglJWyce95Dy/4+9xx52hs= github.com/agglayer/go_signer v0.0.7/go.mod h1:PiDQugvxAgTYDD9bbWcqBMl/LuOOG/B9Hx1N1lIPq0s= github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc= github.com/allegro/bigcache v1.2.1/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.32.8 h1:cZV+NUS/eGxKXMtmyhtYPJ7Z4YLoI/V8bkTdRZfYhGo= github.com/aws/aws-sdk-go-v2 v1.32.8/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= github.com/aws/aws-sdk-go-v2/config v1.28.11 h1:7Ekru0IkRHRnSRWGQLnLN6i0o1Jncd0rHo2T130+tEQ= @@ -84,6 +87,7 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -312,6 +316,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= @@ -349,9 +354,8 @@ github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczG github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= @@ -382,6 +386,10 @@ github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k= +github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= @@ -466,6 +474,7 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -623,7 +632,6 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -644,8 +652,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=