From 617e51a6d9a2ac7d71aa3b79590fff8139fe6d15 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:42:37 +0000 Subject: [PATCH 1/6] fix: remove openapi-fetch dependency --- README.md | 2 +- packages/app/eslint.config.mts | 36 +- .../app/eslint.effect-ts-check.config.mjs | 4 + packages/app/package.json | 1 - packages/app/src/index.ts | 6 +- .../app/src/openapi-fetch/create-client.ts | 401 ++++++++++++++++++ packages/app/src/openapi-fetch/index.ts | 31 ++ packages/app/src/openapi-fetch/path-based.ts | 52 +++ packages/app/src/openapi-fetch/serialize.ts | 308 ++++++++++++++ packages/app/src/openapi-fetch/types.ts | 276 ++++++++++++ .../shell/api-client/create-client-types.ts | 2 +- pnpm-lock.yaml | 15 - 12 files changed, 1110 insertions(+), 24 deletions(-) create mode 100644 packages/app/src/openapi-fetch/create-client.ts create mode 100644 packages/app/src/openapi-fetch/index.ts create mode 100644 packages/app/src/openapi-fetch/path-based.ts create mode 100644 packages/app/src/openapi-fetch/serialize.ts create mode 100644 packages/app/src/openapi-fetch/types.ts diff --git a/README.md b/README.md index b770542..3fc3fff 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ pnpm add @prover-coder-ai/openapi-effect ## Usage (Promise API) -This package re-exports `openapi-fetch`, so most code can be migrated by changing only the import. +This package implements an `openapi-fetch` compatible API, so most code can be migrated by changing only the import. ```ts import createClient from "@prover-coder-ai/openapi-effect" diff --git a/packages/app/eslint.config.mts b/packages/app/eslint.config.mts index 301f43f..43455fc 100644 --- a/packages/app/eslint.config.mts +++ b/packages/app/eslint.config.mts @@ -341,7 +341,37 @@ export default defineConfig( }, }, - // 5) Generated OpenAPI schema uses canonical lowercase names from openapi-typescript + // 5) openapi-fetch compatibility layer: Promise-based types and pragmatic "any" are expected. + // It is intentionally treated as a boundary/interop module. + { + files: ["src/openapi-fetch/**/*.ts"], + rules: { + "no-restricted-syntax": "off", + "@typescript-eslint/no-restricted-types": "off", + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-unnecessary-type-parameters": "off", + "@typescript-eslint/no-invalid-void-type": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unnecessary-condition": "off", + "@typescript-eslint/no-base-to-string": "off", + "sonarjs/pseudo-random": "off", + "sonarjs/cognitive-complexity": "off", + "sonarjs/different-types-comparison": "off", + "unicorn/consistent-function-scoping": "off", + "unicorn/prefer-string-slice": "off", + complexity: "off", + "max-lines": "off", + "max-lines-per-function": "off", + "max-params": "off", + "max-depth": "off", + }, + }, + + // 6) Generated OpenAPI schema uses canonical lowercase names from openapi-typescript { files: ["src/core/api/openapi.d.ts"], rules: { @@ -352,12 +382,12 @@ export default defineConfig( }, }, - // 6) Для JS-файлов отключим типо-зависимые проверки + // 7) Для JS-файлов отключим типо-зависимые проверки { files: ['**/*.{js,cjs,mjs}'], extends: [tseslint.configs.disableTypeChecked], }, - // 6) Глобальные игноры + // 8) Глобальные игноры { ignores: ['dist/**', 'build/**', 'coverage/**', '**/dist/**'] }, ); diff --git a/packages/app/eslint.effect-ts-check.config.mjs b/packages/app/eslint.effect-ts-check.config.mjs index a08b380..8a14f0f 100644 --- a/packages/app/eslint.effect-ts-check.config.mjs +++ b/packages/app/eslint.effect-ts-check.config.mjs @@ -244,5 +244,9 @@ export default tseslint.config( rule.selector !== "TSAsExpression" && rule.selector !== "TSTypeAssertion" )] } + }, + { + name: "effect-ts-compliance-ignores", + ignores: ["src/openapi-fetch/**"] } ) diff --git a/packages/app/package.json b/packages/app/package.json index d00645a..b8814d7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -63,7 +63,6 @@ "@effect/typeclass": "^0.38.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.16", - "openapi-fetch": "^0.15.2", "openapi-typescript-helpers": "^0.1.0", "ts-morph": "^27.0.2" }, diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index cec08a4..19e86be 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -6,9 +6,9 @@ // COMPLEXITY: O(1) // Promise-based client (openapi-fetch compatible) -export { default } from "openapi-fetch" -export { default as createClient } from "openapi-fetch" -export * from "openapi-fetch" +export { default } from "./openapi-fetch/index.js" +export { createClient } from "./openapi-fetch/index.js" +export * from "./openapi-fetch/index.js" // Effect-based client (opt-in) export * as FetchHttpClient from "@effect/platform/FetchHttpClient" diff --git a/packages/app/src/openapi-fetch/create-client.ts b/packages/app/src/openapi-fetch/create-client.ts new file mode 100644 index 0000000..d9d25ca --- /dev/null +++ b/packages/app/src/openapi-fetch/create-client.ts @@ -0,0 +1,401 @@ +// CHANGE: Local openapi-fetch compatible createClient implemented via Effect internally +// WHY: Drop-in replacement for openapi-fetch without depending on it; keep Promise-based signature for consumers +// SOURCE: Behavior-compatible with openapi-fetch@0.15.x (MIT). Uses Effect for internal control-flow. +// PURITY: SHELL (performs HTTP requests) +// COMPLEXITY: O(1) + O(|body|) + +import { Effect, Either } from "effect" +import type { MediaType } from "openapi-typescript-helpers" + +import { + createFinalURL, + createQuerySerializer, + defaultBodySerializer, + mergeHeaders, + removeTrailingSlash +} from "./serialize.js" +import type { + Client, + ClientOptions, + FetchResponse, + HeadersOptions, + MergedOptions, + Middleware, + QuerySerializer, + QuerySerializerOptions, + RequestOptions +} from "./types.js" + +const supportsRequestInitExt = (): boolean => { + // Match openapi-fetch behavior: only enable in Node >= 18 with undici. + return ( + typeof process === "object" + && Number.parseInt(process?.versions?.node?.slice(0, 2)) >= 18 + && (process as any).versions?.undici + ) +} + +const randomID = (): string => Math.random().toString(36).slice(2, 11) + +const isPromiseLike = (value: unknown): value is PromiseLike => + typeof value === "object" && value !== null && "then" in value && typeof (value as any).then === "function" + +const fromMaybePromise = (thunk: () => A | PromiseLike): Effect.Effect => + Effect.try({ + try: () => thunk(), + catch: (error) => error + }).pipe( + Effect.flatMap((value) => + isPromiseLike(value) + ? Effect.tryPromise({ + try: () => value as any as Promise, + catch: (error) => error + }) + : Effect.succeed(value) + ) + ) + +function assertMiddlewareShape(m: unknown): asserts m is Middleware { + if (!m) return + if ( + typeof m !== "object" + || !("onRequest" in (m as any) || "onResponse" in (m as any) || "onError" in (m as any)) + ) { + throw new Error("Middleware must be an object with one of `onRequest()`, `onResponse() or `onError()`") + } +} + +export const createClient = ( + clientOptions?: ClientOptions +): Client => { + const clientOptions_ = ({ ...clientOptions } as any) satisfies Record + + let baseUrl: string = clientOptions_.baseUrl ?? "" + const CustomRequest: typeof Request = clientOptions_.Request ?? globalThis.Request + const baseFetch: any = clientOptions_.fetch ?? globalThis.fetch + const globalQuerySerializer: unknown = clientOptions_.querySerializer + const globalBodySerializer: unknown = clientOptions_.bodySerializer + const baseHeaders: HeadersOptions | undefined = clientOptions_.headers as HeadersOptions | undefined + let requestInitExt: Record | undefined = clientOptions_.requestInitExt + + // Capture all other RequestInit fields (credentials, mode, cache, ...). + const baseOptions: Record = { ...clientOptions_ } + delete (baseOptions as any).baseUrl + delete (baseOptions as any).Request + delete (baseOptions as any).fetch + delete (baseOptions as any).querySerializer + delete (baseOptions as any).bodySerializer + delete (baseOptions as any).headers + delete (baseOptions as any).requestInitExt + + requestInitExt = supportsRequestInitExt() ? requestInitExt : undefined + baseUrl = removeTrailingSlash(baseUrl) + const globalMiddlewares: Array = [] + + const coreFetchEffect = ( + schemaPath: string, + fetchOptions?: RequestOptions & Omit + ): Effect.Effect, unknown> => + Effect.gen(function*() { + const fetchOptions_ = ({ ...fetchOptions } as any) satisfies Record + + const localBaseUrl: string | undefined = fetchOptions_.baseUrl + const fetch: any = fetchOptions_.fetch ?? baseFetch + const RequestCtor: typeof Request = fetchOptions_.Request ?? CustomRequest + const headers: HeadersOptions | undefined = fetchOptions_.headers as HeadersOptions | undefined + const params: any = fetchOptions_.params ?? {} + const parseAs: any = fetchOptions_.parseAs ?? "json" + const requestQuerySerializer: unknown = fetchOptions_.querySerializer + const bodySerializer: any = fetchOptions_.bodySerializer ?? (globalBodySerializer ?? defaultBodySerializer) + const body: unknown = fetchOptions_.body + const requestMiddlewares: Array = fetchOptions_.middleware ?? [] + + const init: Record = { ...fetchOptions_ } + delete (init as any).baseUrl + delete (init as any).fetch + delete (init as any).Request + delete (init as any).headers + delete (init as any).params + delete (init as any).parseAs + delete (init as any).querySerializer + delete (init as any).bodySerializer + delete (init as any).body + delete (init as any).middleware + + let finalBaseUrl = baseUrl + if (localBaseUrl) { + finalBaseUrl = removeTrailingSlash(localBaseUrl) ?? baseUrl + } + + let querySerializer: QuerySerializer = typeof globalQuerySerializer === "function" + ? (globalQuerySerializer as any) + : createQuerySerializer(globalQuerySerializer as QuerySerializerOptions | undefined) + + if (requestQuerySerializer) { + querySerializer = typeof requestQuerySerializer === "function" + ? (requestQuerySerializer as any) + : createQuerySerializer({ + ...(typeof globalQuerySerializer === "object" ? (globalQuerySerializer as any) : {}), + ...(requestQuerySerializer as any) + }) + } + + const serializedBody = body === undefined + ? undefined + : bodySerializer(body, mergeHeaders(baseHeaders, headers, params.header)) + + const finalHeaders = mergeHeaders( + serializedBody === undefined || serializedBody instanceof FormData + ? {} + : { "Content-Type": "application/json" }, + baseHeaders, + headers, + params.header + ) + + const finalMiddlewares = [...globalMiddlewares, ...requestMiddlewares] + const requestInit: RequestInit = { + redirect: "follow", + ...baseOptions, + ...init, + body: serializedBody, + headers: finalHeaders + } + + let id: string | undefined + let options: MergedOptions | undefined + let request: Request = new (RequestCtor as any)( + createFinalURL(schemaPath, { baseUrl: finalBaseUrl, params, querySerializer }), + requestInit + ) + + let response: Response | undefined + + // Copy init extension keys onto Request for middleware access (matches openapi-fetch behavior). + for (const key in init) { + if (!(key in request)) { + ;(request as any)[key] = (init as any)[key] + } + } + + if (finalMiddlewares.length > 0) { + id = randomID() + options = Object.freeze({ + baseUrl: finalBaseUrl, + fetch, + parseAs, + querySerializer, + bodySerializer + }) + + for (const m of finalMiddlewares) { + if (m && typeof m === "object" && typeof (m as any).onRequest === "function") { + const result = yield* fromMaybePromise(() => + (m as any).onRequest({ + request, + schemaPath, + params, + options, + id + }) + ) + + if (result) { + if (result instanceof Request) { + request = result + } else if (result instanceof Response) { + response = result + break + } else { + throw new TypeError("onRequest: must return new Request() or Response() when modifying the request") + } + } + } + } + } + + if (!response) { + const fetched = yield* Effect.either( + Effect.tryPromise({ + try: () => fetch(request, requestInitExt), + catch: (error) => error + }) + ) + + if (Either.isLeft(fetched)) { + let errorAfterMiddleware: unknown = fetched.left + + if (finalMiddlewares.length > 0) { + for (let i = finalMiddlewares.length - 1; i >= 0; i--) { + const m = finalMiddlewares[i] + if (m && typeof m === "object" && typeof (m as any).onError === "function") { + const result = yield* fromMaybePromise(() => + (m as any).onError({ + request, + error: errorAfterMiddleware, + schemaPath, + params, + options, + id + }) + ) + + if (result) { + if (result instanceof Response) { + errorAfterMiddleware = undefined + response = result + break + } + if (result instanceof Error) { + errorAfterMiddleware = result + continue + } + throw new Error("onError: must return new Response() or instance of Error") + } + } + } + } + + if (errorAfterMiddleware) { + // Re-throw as failure (Promise rejection on the outside). + return yield* Effect.fail(errorAfterMiddleware) + } + } else { + response = fetched.right as Response + } + + if (finalMiddlewares.length > 0 && response) { + for (let i = finalMiddlewares.length - 1; i >= 0; i--) { + const m = finalMiddlewares[i] + if (m && typeof m === "object" && typeof (m as any).onResponse === "function") { + const result = yield* fromMaybePromise(() => + (m as any).onResponse({ + request, + response, + schemaPath, + params, + options, + id + }) + ) + + if (result) { + if (!(result instanceof Response)) { + throw new TypeError("onResponse: must return new Response() when modifying the response") + } + response = result + } + } + } + } + } + + // If middleware short-circuited with a Response, openapi-fetch does NOT run onResponse hooks. + // We already replicated that by running onResponse only inside the fetch path above. + if (!response) { + // Defensive: should never happen, but keeps types happy. + return { error: undefined, response: new Response(null) } as any + } + + const contentLength = response.headers.get("Content-Length") + + if (response.status === 204 || request.method === "HEAD" || contentLength === "0") { + return response.ok ? ({ data: undefined, response } as any) : ({ error: undefined, response } as any) + } + + if (response.ok) { + if (parseAs === "stream") { + return { data: response.body, response } as any + } + + if (parseAs === "json" && !contentLength) { + const raw = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (error) => error + }) + if (raw === "") { + return { data: undefined, response } as any + } + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw), + catch: (error) => error + }) + return { data: parsed, response } as any + } + + const data = yield* Effect.tryPromise({ + try: () => (response as any)[parseAs](), + catch: (error) => error + }) + return { data, response } as any + } + + const errorText = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (error) => error + }) + + const error = yield* Effect.catchAll( + Effect.try({ + try: () => JSON.parse(errorText), + catch: () => errorText + }), + () => Effect.succeed(errorText) + ) + return { error, response } as any + }) + + const coreFetch = (schemaPath: string, fetchOptions: any): Promise => + Effect.runPromise(coreFetchEffect(schemaPath, fetchOptions)) + + return { + request(method: any, url: any, init: any) { + return coreFetch(url, { ...init, method: String(method).toUpperCase() }) + }, + GET(url: any, init: any) { + return coreFetch(url, { ...init, method: "GET" }) + }, + PUT(url: any, init: any) { + return coreFetch(url, { ...init, method: "PUT" }) + }, + POST(url: any, init: any) { + return coreFetch(url, { ...init, method: "POST" }) + }, + DELETE(url: any, init: any) { + return coreFetch(url, { ...init, method: "DELETE" }) + }, + OPTIONS(url: any, init: any) { + return coreFetch(url, { ...init, method: "OPTIONS" }) + }, + HEAD(url: any, init: any) { + return coreFetch(url, { ...init, method: "HEAD" }) + }, + PATCH(url: any, init: any) { + return coreFetch(url, { ...init, method: "PATCH" }) + }, + TRACE(url: any, init: any) { + return coreFetch(url, { ...init, method: "TRACE" }) + }, + /** Register middleware */ + use(...middleware: Array) { + for (const m of middleware) { + if (!m) { + continue + } + assertMiddlewareShape(m) + globalMiddlewares.push(m) + } + }, + /** Unregister middleware */ + eject(...middleware: Array) { + for (const m of middleware) { + const i = globalMiddlewares.indexOf(m) + if (i !== -1) { + globalMiddlewares.splice(i, 1) + } + } + } + } as any +} + +export default createClient diff --git a/packages/app/src/openapi-fetch/index.ts b/packages/app/src/openapi-fetch/index.ts new file mode 100644 index 0000000..cfc4f05 --- /dev/null +++ b/packages/app/src/openapi-fetch/index.ts @@ -0,0 +1,31 @@ +// CHANGE: openapi-fetch compatible surface exported from openapi-effect +// WHY: Consumers must be able to swap `openapi-fetch` -> `openapi-effect` with near-zero code changes +// NOTE: Promise-based API is intentional (drop-in). Effect is used internally and via opt-in APIs. + +import type { MediaType } from "openapi-typescript-helpers" +import { createClient } from "./create-client.js" +import { wrapAsPathBasedClient } from "./path-based.js" +import type { ClientOptions, PathBasedClient } from "./types.js" + +export { createClient, default } from "./create-client.js" +export type * from "./types.js" + +export { + createFinalURL, + createQuerySerializer, + defaultBodySerializer, + defaultPathSerializer, + mergeHeaders, + removeTrailingSlash, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam +} from "./serialize.js" + +export { wrapAsPathBasedClient } from "./path-based.js" + +export const createPathBasedClient = ( + clientOptions?: ClientOptions +): PathBasedClient => { + return wrapAsPathBasedClient(createClient(clientOptions)) as any +} diff --git a/packages/app/src/openapi-fetch/path-based.ts b/packages/app/src/openapi-fetch/path-based.ts new file mode 100644 index 0000000..4de4be5 --- /dev/null +++ b/packages/app/src/openapi-fetch/path-based.ts @@ -0,0 +1,52 @@ +// CHANGE: Local openapi-fetch compatible path-based client proxy +// WHY: Preserve openapi-fetch API (`createPathBasedClient`, `wrapAsPathBasedClient`) without dependency +// SOURCE: Behavior-compatible with openapi-fetch@0.15.x (MIT) +// PURITY: SHELL (runtime) +// COMPLEXITY: O(1) per path access + +import type { MediaType } from "openapi-typescript-helpers" + +import type { Client, ClientForPath, PathBasedClient } from "./types.js" + +class PathCallForwarder>, Media extends MediaType> { + constructor( + private readonly client: Client, + private readonly url: string + ) {} + + GET = (init: any) => this.client.GET(this.url as any, init) + PUT = (init: any) => this.client.PUT(this.url as any, init) + POST = (init: any) => this.client.POST(this.url as any, init) + DELETE = (init: any) => this.client.DELETE(this.url as any, init) + OPTIONS = (init: any) => this.client.OPTIONS(this.url as any, init) + HEAD = (init: any) => this.client.HEAD(this.url as any, init) + PATCH = (init: any) => this.client.PATCH(this.url as any, init) + TRACE = (init: any) => this.client.TRACE(this.url as any, init) +} + +class PathClientProxyHandler>, Media extends MediaType> + implements ProxyHandler> +{ + client: any = null + + // Assume the property is an URL. + get(coreClient: any, url: string): any { + const forwarder = new PathCallForwarder(coreClient, url) + this.client[url] = forwarder + return forwarder + } +} + +export const wrapAsPathBasedClient = ( + client: Client +): PathBasedClient => { + const handler = new PathClientProxyHandler>, Media>() + const proxy = new Proxy(client as any, handler) + + function ClientProxy(): void {} + ClientProxy.prototype = proxy + + const pathClient = new (ClientProxy as any)() + handler.client = pathClient + return pathClient +} diff --git a/packages/app/src/openapi-fetch/serialize.ts b/packages/app/src/openapi-fetch/serialize.ts new file mode 100644 index 0000000..9bf5577 --- /dev/null +++ b/packages/app/src/openapi-fetch/serialize.ts @@ -0,0 +1,308 @@ +// CHANGE: Local openapi-fetch compatible URL/body/header serialization helpers +// WHY: openapi-effect must ship openapi-fetch API without depending on openapi-fetch package +// SOURCE: Behavior-compatible with openapi-fetch@0.15.x (MIT). Re-implemented with minimal changes. +// PURITY: CORE (pure helpers) +// COMPLEXITY: O(n) + +import type { HeadersOptions, QuerySerializer, QuerySerializerOptions } from "./types.js" + +const PATH_PARAM_RE = /\{[^{}]+\}/g + +/** Serialize primitive params to string */ +export const serializePrimitiveParam = ( + name: string, + value: string, + options?: { allowReserved?: boolean } +): string => { + if (value === undefined || value === null) { + return "" + } + + // Disallow deep objects here (users can provide custom querySerializer) + if (typeof value === "object") { + throw new TypeError( + "Deeply-nested arrays/objects aren't supported. Provide your own `querySerializer()` to handle these." + ) + } + + return `${name}=${options?.allowReserved === true ? value : encodeURIComponent(value)}` +} + +/** Serialize object param to string */ +export const serializeObjectParam = ( + name: string, + value: Record, + options: { + style: "simple" | "label" | "matrix" | "form" | "deepObject" + explode: boolean + allowReserved?: boolean + } +): string => { + if (!value || typeof value !== "object") { + return "" + } + + const values: Array = [] + const joiner = ({ simple: ",", label: ".", matrix: ";" } as Record)[options.style] ?? "&" + + if (options.style !== "deepObject" && !options.explode) { + for (const k in value) { + values.push(k, options.allowReserved === true ? String(value[k]) : encodeURIComponent(String(value[k]))) + } + const final2 = values.join(",") + switch (options.style) { + case "form": { + return `${name}=${final2}` + } + case "label": { + return `.${final2}` + } + case "matrix": { + return `;${name}=${final2}` + } + default: { + return final2 + } + } + } + + for (const k in value) { + const finalName = options.style === "deepObject" ? `${name}[${k}]` : k + values.push(serializePrimitiveParam(finalName, String(value[k]), options)) + } + + const final = values.join(joiner) + return options.style === "label" || options.style === "matrix" ? `${joiner}${final}` : final +} + +/** Serialize array param to string */ +export const serializeArrayParam = ( + name: string, + value: Array, + options: { + style: "simple" | "label" | "matrix" | "form" | "spaceDelimited" | "pipeDelimited" + explode: boolean + allowReserved?: boolean + } +): string => { + if (!Array.isArray(value)) { + return "" + } + + if (!options.explode) { + const joiner2 = + ({ form: ",", spaceDelimited: "%20", pipeDelimited: "|" } as Record)[options.style] ?? "," + const final = (options.allowReserved === true ? value : value.map((v) => encodeURIComponent(String(v)))).join( + joiner2 + ) + + switch (options.style) { + case "simple": { + return final + } + case "label": { + return `.${final}` + } + case "matrix": { + return `;${name}=${final}` + } + default: { + return `${name}=${final}` + } + } + } + + const joiner = ({ simple: ",", label: ".", matrix: ";" } as Record)[options.style] ?? "&" + const values: Array = [] + + for (const v of value) { + if (options.style === "simple" || options.style === "label") { + values.push(options.allowReserved === true ? String(v) : encodeURIComponent(String(v))) + } else { + values.push(serializePrimitiveParam(name, String(v), options)) + } + } + + const joined = values.join(joiner) + return options.style === "label" || options.style === "matrix" ? `${joiner}${joined}` : joined +} + +/** Serialize query params to string */ +export const createQuerySerializer = ( + options?: QuerySerializerOptions +): (queryParams: T) => string => { + return function querySerializer(queryParams: T): string { + const search: Array = [] + + if (queryParams && typeof queryParams === "object") { + for (const name in queryParams as any) { + const value = (queryParams as any)[name] + if (value === undefined || value === null) { + continue + } + + if (Array.isArray(value)) { + if (value.length === 0) { + continue + } + search.push( + serializeArrayParam(name, value, { + style: "form", + explode: true, + ...options?.array, + allowReserved: options?.allowReserved || false + }) + ) + continue + } + + if (typeof value === "object") { + search.push( + serializeObjectParam(name, value, { + style: "deepObject", + explode: true, + ...options?.object, + allowReserved: options?.allowReserved || false + }) + ) + continue + } + + search.push(serializePrimitiveParam(name, String(value), options)) + } + } + + return search.join("&") + } +} + +/** + * Handle OpenAPI 3.x serialization styles for path params + * @see https://swagger.io/docs/specification/serialization/#path + */ +export const defaultPathSerializer = (pathname: string, pathParams: Record): string => { + let nextURL = pathname + + for (const match of pathname.match(PATH_PARAM_RE) ?? []) { + let name = match.substring(1, match.length - 1) + let explode = false + let style: "simple" | "label" | "matrix" = "simple" + + if (name.endsWith("*")) { + explode = true + name = name.slice(0, Math.max(0, name.length - 1)) + } + + if (name.startsWith(".")) { + style = "label" + name = name.slice(1) + } else if (name.startsWith(";")) { + style = "matrix" + name = name.slice(1) + } + + if (!pathParams || pathParams[name] === undefined || pathParams[name] === null) { + continue + } + + const value = pathParams[name] + if (Array.isArray(value)) { + nextURL = nextURL.replace(match, serializeArrayParam(name, value, { style, explode })) + continue + } + + if (typeof value === "object") { + nextURL = nextURL.replace(match, serializeObjectParam(name, value as any, { style, explode })) + continue + } + + if (style === "matrix") { + nextURL = nextURL.replace(match, `;${serializePrimitiveParam(name, String(value))}`) + continue + } + + nextURL = nextURL.replace( + match, + style === "label" ? `.${encodeURIComponent(String(value))}` : encodeURIComponent(String(value)) + ) + } + + return nextURL +} + +/** Serialize body object to string */ +export const defaultBodySerializer = (body: T, headers?: Headers | Record): any => { + if (body instanceof FormData) { + return body + } + + if (headers) { + const contentType = typeof (headers as any).get === "function" + ? ((headers as any).get("Content-Type") ?? (headers as any).get("content-type")) + : (headers as any)["Content-Type"] ?? (headers as any)["content-type"] + + if (contentType === "application/x-www-form-urlencoded") { + return new URLSearchParams(body as any).toString() + } + } + + return JSON.stringify(body) +} + +/** Construct URL string from baseUrl and handle path and query params */ +export const createFinalURL = ( + pathname: string, + options: { + baseUrl: string + params: { query?: Record; path?: Record } + querySerializer: QuerySerializer + } +): string => { + let finalURL = `${options.baseUrl}${pathname}` + + if (options.params?.path) { + finalURL = defaultPathSerializer(finalURL, options.params.path) + } + + let search = options.querySerializer((options.params.query ?? {}) as any) + if (search.startsWith("?")) { + search = search.slice(1) + } + + if (search) { + finalURL += `?${search}` + } + + return finalURL +} + +/** Merge headers a and b, with b taking priority */ +export const mergeHeaders = (...allHeaders: Array): Headers => { + const finalHeaders = new Headers() + + for (const h of allHeaders) { + if (!h || typeof h !== "object") { + continue + } + + const iterator = h instanceof Headers ? h.entries() : Object.entries(h) + for (const [k, v] of iterator) { + if (v === null) { + finalHeaders.delete(k) + } else if (Array.isArray(v)) { + for (const v2 of v) { + finalHeaders.append(k, String(v2)) + } + } else if (v !== undefined) { + finalHeaders.set(k, String(v)) + } + } + } + + return finalHeaders +} + +/** Remove trailing slash from url */ +export const removeTrailingSlash = (url: string): string => { + return url.endsWith("/") ? url.slice(0, Math.max(0, url.length - 1)) : url +} diff --git a/packages/app/src/openapi-fetch/types.ts b/packages/app/src/openapi-fetch/types.ts new file mode 100644 index 0000000..159b9a7 --- /dev/null +++ b/packages/app/src/openapi-fetch/types.ts @@ -0,0 +1,276 @@ +// CHANGE: Local openapi-fetch compatible types (no dependency on openapi-fetch package) +// WHY: openapi-effect must be a drop-in replacement for openapi-fetch without pulling it as a dependency +// SOURCE: API surface is compatible with openapi-fetch@0.15.x (MIT) +// PURITY: CORE (types only) +// COMPLEXITY: O(1) + +import type { + ErrorResponse, + FilterKeys, + HttpMethod, + IsOperationRequestBodyOptional, + MediaType, + OperationRequestBodyContent, + PathsWithMethod, + RequiredKeysOf, + ResponseObjectMap, + SuccessResponse +} from "openapi-typescript-helpers" + +/** Options for each client instance */ +export interface ClientOptions extends Omit { + /** set the common root URL for all API requests */ + baseUrl?: string + /** custom fetch (defaults to globalThis.fetch) */ + fetch?: (input: Request) => Promise + /** custom Request (defaults to globalThis.Request) */ + Request?: typeof Request + /** global querySerializer */ + querySerializer?: QuerySerializer | QuerySerializerOptions + /** global bodySerializer */ + bodySerializer?: BodySerializer + headers?: HeadersOptions + /** RequestInit extension object to pass as 2nd argument to fetch when supported (defaults to undefined) */ + requestInitExt?: Record +} + +export type HeadersOptions = + | Required["headers"] + | Record< + string, + | string + | number + | boolean + | ReadonlyArray + | null + | undefined + > + +export type QuerySerializer = ( + query: T extends { parameters: any } ? NonNullable : Record +) => string + +/** @see https://swagger.io/docs/specification/serialization/#query */ +export type QuerySerializerOptions = { + /** Set serialization for arrays. @see https://swagger.io/docs/specification/serialization/#query */ + array?: { + /** default: "form" */ + style: "form" | "spaceDelimited" | "pipeDelimited" + /** default: true */ + explode: boolean + } + /** Set serialization for objects. @see https://swagger.io/docs/specification/serialization/#query */ + object?: { + /** default: "deepObject" */ + style: "form" | "deepObject" + /** default: true */ + explode: boolean + } + /** + * The `allowReserved` keyword specifies whether the reserved characters + * `:/?#[]@!$&'()*+,;=` in parameter values are allowed to be sent as they + * are, or should be percent-encoded. By default, allowReserved is `false`, + * and reserved characters are percent-encoded. + * @see https://swagger.io/docs/specification/serialization/#query + */ + allowReserved?: boolean +} + +export type BodySerializer = (body: OperationRequestBodyContent) => any + +type BodyType = { + json: T + text: Awaited> + blob: Awaited> + arrayBuffer: Awaited> + stream: Response["body"] +} + +export type ParseAs = keyof BodyType + +export type ParseAsResponse = Options extends { parseAs: ParseAs } ? BodyType[Options["parseAs"]] + : T + +export interface DefaultParamsOption { + params?: { + query?: Record + } +} + +export type ParamsOption = T extends { parameters: any } + ? RequiredKeysOf extends never ? { params?: T["parameters"] } + : { params: T["parameters"] } + : DefaultParamsOption + +export type RequestBodyOption = OperationRequestBodyContent extends never ? { body?: never } + : IsOperationRequestBodyOptional extends true ? { body?: OperationRequestBodyContent } + : { body: OperationRequestBodyContent } + +export type RequestOptions = + & ParamsOption + & RequestBodyOption + & { + baseUrl?: string + querySerializer?: QuerySerializer | QuerySerializerOptions + bodySerializer?: BodySerializer + parseAs?: ParseAs + fetch?: ClientOptions["fetch"] + headers?: HeadersOptions + middleware?: Array + } + +export type FetchOptions = RequestOptions & Omit + +export type FetchResponse< + T extends Record, + Options, + Media extends MediaType +> = + | { + data: ParseAsResponse, Media>, Options> + error?: never + response: Response + } + | { + data?: never + error: ErrorResponse, Media> + response: Response + } + +export type MergedOptions = { + baseUrl: string + parseAs: ParseAs + querySerializer: QuerySerializer + bodySerializer: BodySerializer + fetch: typeof globalThis.fetch +} + +export interface MiddlewareCallbackParams { + /** Current Request object */ + request: Request + /** The original OpenAPI schema path (including curly braces) */ + readonly schemaPath: string + /** OpenAPI parameters as provided from openapi-fetch */ + readonly params: { + query?: Record + header?: Record + path?: Record + cookie?: Record + } + /** Unique ID for this request */ + readonly id: string + /** createClient options (read-only) */ + readonly options: MergedOptions +} + +export type MiddlewareOnRequest = ( + options: MiddlewareCallbackParams +) => void | Request | Response | undefined | Promise + +export type MiddlewareOnResponse = ( + options: MiddlewareCallbackParams & { response: Response } +) => void | Response | undefined | Promise + +export type MiddlewareOnError = ( + options: MiddlewareCallbackParams & { error: unknown } +) => void | Response | Error | Promise + +export type Middleware = + | { + onRequest: MiddlewareOnRequest + onResponse?: MiddlewareOnResponse + onError?: MiddlewareOnError + } + | { + onRequest?: MiddlewareOnRequest + onResponse: MiddlewareOnResponse + onError?: MiddlewareOnError + } + | { + onRequest?: MiddlewareOnRequest + onResponse?: MiddlewareOnResponse + onError: MiddlewareOnError + } + +/** This type helper makes the 2nd function param required if params/requestBody are required; otherwise, optional */ +export type MaybeOptionalInit = + RequiredKeysOf>> extends never + ? FetchOptions> | undefined + : FetchOptions> + +// The final init param to accept. +// - Determines if the param is optional or not. +// - Performs arbitrary [key: string] addition. +// Note: the addition MUST happen after all the inference happens (otherwise TS can't infer if init is required or not). +export type InitParam = RequiredKeysOf extends never ? [(Init & { [key: string]: unknown })?] + : [Init & { [key: string]: unknown }] + +export type ClientMethod< + Paths extends Record>, + Method extends HttpMethod, + Media extends MediaType +> = , Init extends MaybeOptionalInit>( + url: Path, + ...init: InitParam +) => Promise> + +export type ClientRequestMethod< + Paths extends Record>, + Media extends MediaType +> = < + Method extends HttpMethod, + Path extends PathsWithMethod, + Init extends MaybeOptionalInit +>( + method: Method, + url: Path, + ...init: InitParam +) => Promise> + +export type ClientForPath, Media extends MediaType> = { + [Method in keyof PathInfo as Uppercase]: >( + ...init: InitParam + ) => Promise> +} + +export interface Client { + request: ClientRequestMethod + /** Call a GET endpoint */ + GET: ClientMethod + /** Call a PUT endpoint */ + PUT: ClientMethod + /** Call a POST endpoint */ + POST: ClientMethod + /** Call a DELETE endpoint */ + DELETE: ClientMethod + /** Call a OPTIONS endpoint */ + OPTIONS: ClientMethod + /** Call a HEAD endpoint */ + HEAD: ClientMethod + /** Call a PATCH endpoint */ + PATCH: ClientMethod + /** Call a TRACE endpoint */ + TRACE: ClientMethod + /** Register middleware */ + use(...middleware: Array): void + /** Unregister middleware */ + eject(...middleware: Array): void +} + +export type ClientPathsWithMethod< + CreatedClient extends Client, + Method extends HttpMethod +> = CreatedClient extends Client ? PathsWithMethod : never + +export type MethodResponse< + CreatedClient extends Client, + Method extends HttpMethod, + Path extends ClientPathsWithMethod, + Options = {} +> = CreatedClient extends Client + ? NonNullable["data"]> + : never + +export type PathBasedClient = { + [Path in keyof Paths]: ClientForPath, Media> +} diff --git a/packages/app/src/shell/api-client/create-client-types.ts b/packages/app/src/shell/api-client/create-client-types.ts index 34970e7..ee55eba 100644 --- a/packages/app/src/shell/api-client/create-client-types.ts +++ b/packages/app/src/shell/api-client/create-client-types.ts @@ -10,7 +10,6 @@ import type * as HttpClient from "@effect/platform/HttpClient" import type { Effect } from "effect" -import type { ClientOptions as OpenapiFetchClientOptions } from "openapi-fetch" import type { HttpMethod } from "openapi-typescript-helpers" import type { @@ -23,6 +22,7 @@ import type { ResponsesFor } from "../../core/api-client/strict-types.js" import type { Dispatcher } from "../../core/axioms.js" +import type { ClientOptions as OpenapiFetchClientOptions } from "../../openapi-fetch/types.js" /** * Client configuration options diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1ecbb7..238f79d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,9 +61,6 @@ importers: effect: specifier: ^3.19.16 version: 3.19.16 - openapi-fetch: - specifier: ^0.15.2 - version: 0.15.2 openapi-typescript-helpers: specifier: ^0.1.0 version: 0.1.0 @@ -2732,12 +2729,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - openapi-fetch@0.15.2: - resolution: {integrity: sha512-rdYTzUmSsJevmNqg7fwUVGuKc2Gfb9h6ph74EVPkPfIGJaZTfqdIbJahtbJ3qg1LKinln30hqZniLnKpH0RJBg==} - - openapi-typescript-helpers@0.0.15: - resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} - openapi-typescript-helpers@0.1.0: resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} @@ -6293,12 +6284,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - openapi-fetch@0.15.2: - dependencies: - openapi-typescript-helpers: 0.0.15 - - openapi-typescript-helpers@0.0.15: {} - openapi-typescript-helpers@0.1.0: {} optionator@0.9.4: From a09c60d5600e659cc4509165d4d565d9a7d5b68f Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:38:22 +0000 Subject: [PATCH 2/6] refactor(shell): move openapi-fetch compat to api-client boundary --- packages/app/eslint.config.mts | 2 +- packages/app/eslint.effect-ts-check.config.mjs | 2 +- packages/app/src/index.ts | 6 +++--- packages/app/src/shell/api-client/create-client-types.ts | 2 +- .../api-client/openapi-fetch-compat}/create-client.ts | 0 .../api-client/openapi-fetch-compat}/index.ts | 0 .../api-client/openapi-fetch-compat}/path-based.ts | 0 .../api-client/openapi-fetch-compat}/serialize.ts | 0 .../api-client/openapi-fetch-compat}/types.ts | 0 9 files changed, 6 insertions(+), 6 deletions(-) rename packages/app/src/{openapi-fetch => shell/api-client/openapi-fetch-compat}/create-client.ts (100%) rename packages/app/src/{openapi-fetch => shell/api-client/openapi-fetch-compat}/index.ts (100%) rename packages/app/src/{openapi-fetch => shell/api-client/openapi-fetch-compat}/path-based.ts (100%) rename packages/app/src/{openapi-fetch => shell/api-client/openapi-fetch-compat}/serialize.ts (100%) rename packages/app/src/{openapi-fetch => shell/api-client/openapi-fetch-compat}/types.ts (100%) diff --git a/packages/app/eslint.config.mts b/packages/app/eslint.config.mts index 43455fc..5876bce 100644 --- a/packages/app/eslint.config.mts +++ b/packages/app/eslint.config.mts @@ -344,7 +344,7 @@ export default defineConfig( // 5) openapi-fetch compatibility layer: Promise-based types and pragmatic "any" are expected. // It is intentionally treated as a boundary/interop module. { - files: ["src/openapi-fetch/**/*.ts"], + files: ["src/shell/api-client/openapi-fetch-compat/**/*.ts"], rules: { "no-restricted-syntax": "off", "@typescript-eslint/no-restricted-types": "off", diff --git a/packages/app/eslint.effect-ts-check.config.mjs b/packages/app/eslint.effect-ts-check.config.mjs index 8a14f0f..100686c 100644 --- a/packages/app/eslint.effect-ts-check.config.mjs +++ b/packages/app/eslint.effect-ts-check.config.mjs @@ -247,6 +247,6 @@ export default tseslint.config( }, { name: "effect-ts-compliance-ignores", - ignores: ["src/openapi-fetch/**"] + ignores: ["src/shell/api-client/openapi-fetch-compat/**"] } ) diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 19e86be..ffb16c1 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -6,9 +6,9 @@ // COMPLEXITY: O(1) // Promise-based client (openapi-fetch compatible) -export { default } from "./openapi-fetch/index.js" -export { createClient } from "./openapi-fetch/index.js" -export * from "./openapi-fetch/index.js" +export { default } from "./shell/api-client/openapi-fetch-compat/index.js" +export { createClient } from "./shell/api-client/openapi-fetch-compat/index.js" +export * from "./shell/api-client/openapi-fetch-compat/index.js" // Effect-based client (opt-in) export * as FetchHttpClient from "@effect/platform/FetchHttpClient" diff --git a/packages/app/src/shell/api-client/create-client-types.ts b/packages/app/src/shell/api-client/create-client-types.ts index ee55eba..c992b17 100644 --- a/packages/app/src/shell/api-client/create-client-types.ts +++ b/packages/app/src/shell/api-client/create-client-types.ts @@ -22,7 +22,7 @@ import type { ResponsesFor } from "../../core/api-client/strict-types.js" import type { Dispatcher } from "../../core/axioms.js" -import type { ClientOptions as OpenapiFetchClientOptions } from "../../openapi-fetch/types.js" +import type { ClientOptions as OpenapiFetchClientOptions } from "./openapi-fetch-compat/types.js" /** * Client configuration options diff --git a/packages/app/src/openapi-fetch/create-client.ts b/packages/app/src/shell/api-client/openapi-fetch-compat/create-client.ts similarity index 100% rename from packages/app/src/openapi-fetch/create-client.ts rename to packages/app/src/shell/api-client/openapi-fetch-compat/create-client.ts diff --git a/packages/app/src/openapi-fetch/index.ts b/packages/app/src/shell/api-client/openapi-fetch-compat/index.ts similarity index 100% rename from packages/app/src/openapi-fetch/index.ts rename to packages/app/src/shell/api-client/openapi-fetch-compat/index.ts diff --git a/packages/app/src/openapi-fetch/path-based.ts b/packages/app/src/shell/api-client/openapi-fetch-compat/path-based.ts similarity index 100% rename from packages/app/src/openapi-fetch/path-based.ts rename to packages/app/src/shell/api-client/openapi-fetch-compat/path-based.ts diff --git a/packages/app/src/openapi-fetch/serialize.ts b/packages/app/src/shell/api-client/openapi-fetch-compat/serialize.ts similarity index 100% rename from packages/app/src/openapi-fetch/serialize.ts rename to packages/app/src/shell/api-client/openapi-fetch-compat/serialize.ts diff --git a/packages/app/src/openapi-fetch/types.ts b/packages/app/src/shell/api-client/openapi-fetch-compat/types.ts similarity index 100% rename from packages/app/src/openapi-fetch/types.ts rename to packages/app/src/shell/api-client/openapi-fetch-compat/types.ts From 65282972f3f4e79dd898f4bb37e9b339bc029d45 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:18:57 +0000 Subject: [PATCH 3/6] refactor(app): remove openapi-fetch compat folder and lint-clean promise client --- packages/app/eslint.config.mts | 36 +- .../app/eslint.effect-ts-check.config.mjs | 4 - packages/app/src/index.ts | 6 +- .../shell/api-client/create-client-types.ts | 2 +- .../openapi-fetch-compat/create-client.ts | 401 ------------------ .../openapi-fetch-compat/path-based.ts | 52 --- .../openapi-fetch-compat/serialize.ts | 308 -------------- .../promise-client/client-kernel.ts | 294 +++++++++++++ .../promise-client/client-middleware.ts | 167 ++++++++ .../promise-client/client-request.ts | 104 +++++ .../promise-client/client-response.ts | 101 +++++ .../promise-client/create-client.ts | 226 ++++++++++ .../index.ts | 17 +- .../api-client/promise-client/path-based.ts | 68 +++ .../promise-client/serialize-core.ts | 130 ++++++ .../promise-client/serialize-params.ts | 304 +++++++++++++ .../promise-client/serialize-path.ts | 98 +++++ .../promise-client/serialize-shared.ts | 24 ++ .../api-client/promise-client/serialize.ts | 16 + .../types.ts | 113 +++-- 20 files changed, 1614 insertions(+), 857 deletions(-) delete mode 100644 packages/app/src/shell/api-client/openapi-fetch-compat/create-client.ts delete mode 100644 packages/app/src/shell/api-client/openapi-fetch-compat/path-based.ts delete mode 100644 packages/app/src/shell/api-client/openapi-fetch-compat/serialize.ts create mode 100644 packages/app/src/shell/api-client/promise-client/client-kernel.ts create mode 100644 packages/app/src/shell/api-client/promise-client/client-middleware.ts create mode 100644 packages/app/src/shell/api-client/promise-client/client-request.ts create mode 100644 packages/app/src/shell/api-client/promise-client/client-response.ts create mode 100644 packages/app/src/shell/api-client/promise-client/create-client.ts rename packages/app/src/shell/api-client/{openapi-fetch-compat => promise-client}/index.ts (61%) create mode 100644 packages/app/src/shell/api-client/promise-client/path-based.ts create mode 100644 packages/app/src/shell/api-client/promise-client/serialize-core.ts create mode 100644 packages/app/src/shell/api-client/promise-client/serialize-params.ts create mode 100644 packages/app/src/shell/api-client/promise-client/serialize-path.ts create mode 100644 packages/app/src/shell/api-client/promise-client/serialize-shared.ts create mode 100644 packages/app/src/shell/api-client/promise-client/serialize.ts rename packages/app/src/shell/api-client/{openapi-fetch-compat => promise-client}/types.ts (67%) diff --git a/packages/app/eslint.config.mts b/packages/app/eslint.config.mts index 5876bce..88d5cc4 100644 --- a/packages/app/eslint.config.mts +++ b/packages/app/eslint.config.mts @@ -341,37 +341,7 @@ export default defineConfig( }, }, - // 5) openapi-fetch compatibility layer: Promise-based types and pragmatic "any" are expected. - // It is intentionally treated as a boundary/interop module. - { - files: ["src/shell/api-client/openapi-fetch-compat/**/*.ts"], - rules: { - "no-restricted-syntax": "off", - "@typescript-eslint/no-restricted-types": "off", - "@typescript-eslint/no-empty-object-type": "off", - "@typescript-eslint/no-unnecessary-type-parameters": "off", - "@typescript-eslint/no-invalid-void-type": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-return": "off", - "@typescript-eslint/no-unnecessary-condition": "off", - "@typescript-eslint/no-base-to-string": "off", - "sonarjs/pseudo-random": "off", - "sonarjs/cognitive-complexity": "off", - "sonarjs/different-types-comparison": "off", - "unicorn/consistent-function-scoping": "off", - "unicorn/prefer-string-slice": "off", - complexity: "off", - "max-lines": "off", - "max-lines-per-function": "off", - "max-params": "off", - "max-depth": "off", - }, - }, - - // 6) Generated OpenAPI schema uses canonical lowercase names from openapi-typescript + // 5) Generated OpenAPI schema uses canonical lowercase names from openapi-typescript { files: ["src/core/api/openapi.d.ts"], rules: { @@ -382,12 +352,12 @@ export default defineConfig( }, }, - // 7) Для JS-файлов отключим типо-зависимые проверки + // 6) Для JS-файлов отключим типо-зависимые проверки { files: ['**/*.{js,cjs,mjs}'], extends: [tseslint.configs.disableTypeChecked], }, - // 8) Глобальные игноры + // 7) Глобальные игноры { ignores: ['dist/**', 'build/**', 'coverage/**', '**/dist/**'] }, ); diff --git a/packages/app/eslint.effect-ts-check.config.mjs b/packages/app/eslint.effect-ts-check.config.mjs index 100686c..a08b380 100644 --- a/packages/app/eslint.effect-ts-check.config.mjs +++ b/packages/app/eslint.effect-ts-check.config.mjs @@ -244,9 +244,5 @@ export default tseslint.config( rule.selector !== "TSAsExpression" && rule.selector !== "TSTypeAssertion" )] } - }, - { - name: "effect-ts-compliance-ignores", - ignores: ["src/shell/api-client/openapi-fetch-compat/**"] } ) diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index ffb16c1..8cb69ed 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -6,9 +6,9 @@ // COMPLEXITY: O(1) // Promise-based client (openapi-fetch compatible) -export { default } from "./shell/api-client/openapi-fetch-compat/index.js" -export { createClient } from "./shell/api-client/openapi-fetch-compat/index.js" -export * from "./shell/api-client/openapi-fetch-compat/index.js" +export { default } from "./shell/api-client/promise-client/index.js" +export { createClient } from "./shell/api-client/promise-client/index.js" +export * from "./shell/api-client/promise-client/index.js" // Effect-based client (opt-in) export * as FetchHttpClient from "@effect/platform/FetchHttpClient" diff --git a/packages/app/src/shell/api-client/create-client-types.ts b/packages/app/src/shell/api-client/create-client-types.ts index c992b17..3a8e464 100644 --- a/packages/app/src/shell/api-client/create-client-types.ts +++ b/packages/app/src/shell/api-client/create-client-types.ts @@ -22,7 +22,7 @@ import type { ResponsesFor } from "../../core/api-client/strict-types.js" import type { Dispatcher } from "../../core/axioms.js" -import type { ClientOptions as OpenapiFetchClientOptions } from "./openapi-fetch-compat/types.js" +import type { ClientOptions as OpenapiFetchClientOptions } from "./promise-client/types.js" /** * Client configuration options diff --git a/packages/app/src/shell/api-client/openapi-fetch-compat/create-client.ts b/packages/app/src/shell/api-client/openapi-fetch-compat/create-client.ts deleted file mode 100644 index d9d25ca..0000000 --- a/packages/app/src/shell/api-client/openapi-fetch-compat/create-client.ts +++ /dev/null @@ -1,401 +0,0 @@ -// CHANGE: Local openapi-fetch compatible createClient implemented via Effect internally -// WHY: Drop-in replacement for openapi-fetch without depending on it; keep Promise-based signature for consumers -// SOURCE: Behavior-compatible with openapi-fetch@0.15.x (MIT). Uses Effect for internal control-flow. -// PURITY: SHELL (performs HTTP requests) -// COMPLEXITY: O(1) + O(|body|) - -import { Effect, Either } from "effect" -import type { MediaType } from "openapi-typescript-helpers" - -import { - createFinalURL, - createQuerySerializer, - defaultBodySerializer, - mergeHeaders, - removeTrailingSlash -} from "./serialize.js" -import type { - Client, - ClientOptions, - FetchResponse, - HeadersOptions, - MergedOptions, - Middleware, - QuerySerializer, - QuerySerializerOptions, - RequestOptions -} from "./types.js" - -const supportsRequestInitExt = (): boolean => { - // Match openapi-fetch behavior: only enable in Node >= 18 with undici. - return ( - typeof process === "object" - && Number.parseInt(process?.versions?.node?.slice(0, 2)) >= 18 - && (process as any).versions?.undici - ) -} - -const randomID = (): string => Math.random().toString(36).slice(2, 11) - -const isPromiseLike = (value: unknown): value is PromiseLike => - typeof value === "object" && value !== null && "then" in value && typeof (value as any).then === "function" - -const fromMaybePromise = (thunk: () => A | PromiseLike): Effect.Effect => - Effect.try({ - try: () => thunk(), - catch: (error) => error - }).pipe( - Effect.flatMap((value) => - isPromiseLike(value) - ? Effect.tryPromise({ - try: () => value as any as Promise, - catch: (error) => error - }) - : Effect.succeed(value) - ) - ) - -function assertMiddlewareShape(m: unknown): asserts m is Middleware { - if (!m) return - if ( - typeof m !== "object" - || !("onRequest" in (m as any) || "onResponse" in (m as any) || "onError" in (m as any)) - ) { - throw new Error("Middleware must be an object with one of `onRequest()`, `onResponse() or `onError()`") - } -} - -export const createClient = ( - clientOptions?: ClientOptions -): Client => { - const clientOptions_ = ({ ...clientOptions } as any) satisfies Record - - let baseUrl: string = clientOptions_.baseUrl ?? "" - const CustomRequest: typeof Request = clientOptions_.Request ?? globalThis.Request - const baseFetch: any = clientOptions_.fetch ?? globalThis.fetch - const globalQuerySerializer: unknown = clientOptions_.querySerializer - const globalBodySerializer: unknown = clientOptions_.bodySerializer - const baseHeaders: HeadersOptions | undefined = clientOptions_.headers as HeadersOptions | undefined - let requestInitExt: Record | undefined = clientOptions_.requestInitExt - - // Capture all other RequestInit fields (credentials, mode, cache, ...). - const baseOptions: Record = { ...clientOptions_ } - delete (baseOptions as any).baseUrl - delete (baseOptions as any).Request - delete (baseOptions as any).fetch - delete (baseOptions as any).querySerializer - delete (baseOptions as any).bodySerializer - delete (baseOptions as any).headers - delete (baseOptions as any).requestInitExt - - requestInitExt = supportsRequestInitExt() ? requestInitExt : undefined - baseUrl = removeTrailingSlash(baseUrl) - const globalMiddlewares: Array = [] - - const coreFetchEffect = ( - schemaPath: string, - fetchOptions?: RequestOptions & Omit - ): Effect.Effect, unknown> => - Effect.gen(function*() { - const fetchOptions_ = ({ ...fetchOptions } as any) satisfies Record - - const localBaseUrl: string | undefined = fetchOptions_.baseUrl - const fetch: any = fetchOptions_.fetch ?? baseFetch - const RequestCtor: typeof Request = fetchOptions_.Request ?? CustomRequest - const headers: HeadersOptions | undefined = fetchOptions_.headers as HeadersOptions | undefined - const params: any = fetchOptions_.params ?? {} - const parseAs: any = fetchOptions_.parseAs ?? "json" - const requestQuerySerializer: unknown = fetchOptions_.querySerializer - const bodySerializer: any = fetchOptions_.bodySerializer ?? (globalBodySerializer ?? defaultBodySerializer) - const body: unknown = fetchOptions_.body - const requestMiddlewares: Array = fetchOptions_.middleware ?? [] - - const init: Record = { ...fetchOptions_ } - delete (init as any).baseUrl - delete (init as any).fetch - delete (init as any).Request - delete (init as any).headers - delete (init as any).params - delete (init as any).parseAs - delete (init as any).querySerializer - delete (init as any).bodySerializer - delete (init as any).body - delete (init as any).middleware - - let finalBaseUrl = baseUrl - if (localBaseUrl) { - finalBaseUrl = removeTrailingSlash(localBaseUrl) ?? baseUrl - } - - let querySerializer: QuerySerializer = typeof globalQuerySerializer === "function" - ? (globalQuerySerializer as any) - : createQuerySerializer(globalQuerySerializer as QuerySerializerOptions | undefined) - - if (requestQuerySerializer) { - querySerializer = typeof requestQuerySerializer === "function" - ? (requestQuerySerializer as any) - : createQuerySerializer({ - ...(typeof globalQuerySerializer === "object" ? (globalQuerySerializer as any) : {}), - ...(requestQuerySerializer as any) - }) - } - - const serializedBody = body === undefined - ? undefined - : bodySerializer(body, mergeHeaders(baseHeaders, headers, params.header)) - - const finalHeaders = mergeHeaders( - serializedBody === undefined || serializedBody instanceof FormData - ? {} - : { "Content-Type": "application/json" }, - baseHeaders, - headers, - params.header - ) - - const finalMiddlewares = [...globalMiddlewares, ...requestMiddlewares] - const requestInit: RequestInit = { - redirect: "follow", - ...baseOptions, - ...init, - body: serializedBody, - headers: finalHeaders - } - - let id: string | undefined - let options: MergedOptions | undefined - let request: Request = new (RequestCtor as any)( - createFinalURL(schemaPath, { baseUrl: finalBaseUrl, params, querySerializer }), - requestInit - ) - - let response: Response | undefined - - // Copy init extension keys onto Request for middleware access (matches openapi-fetch behavior). - for (const key in init) { - if (!(key in request)) { - ;(request as any)[key] = (init as any)[key] - } - } - - if (finalMiddlewares.length > 0) { - id = randomID() - options = Object.freeze({ - baseUrl: finalBaseUrl, - fetch, - parseAs, - querySerializer, - bodySerializer - }) - - for (const m of finalMiddlewares) { - if (m && typeof m === "object" && typeof (m as any).onRequest === "function") { - const result = yield* fromMaybePromise(() => - (m as any).onRequest({ - request, - schemaPath, - params, - options, - id - }) - ) - - if (result) { - if (result instanceof Request) { - request = result - } else if (result instanceof Response) { - response = result - break - } else { - throw new TypeError("onRequest: must return new Request() or Response() when modifying the request") - } - } - } - } - } - - if (!response) { - const fetched = yield* Effect.either( - Effect.tryPromise({ - try: () => fetch(request, requestInitExt), - catch: (error) => error - }) - ) - - if (Either.isLeft(fetched)) { - let errorAfterMiddleware: unknown = fetched.left - - if (finalMiddlewares.length > 0) { - for (let i = finalMiddlewares.length - 1; i >= 0; i--) { - const m = finalMiddlewares[i] - if (m && typeof m === "object" && typeof (m as any).onError === "function") { - const result = yield* fromMaybePromise(() => - (m as any).onError({ - request, - error: errorAfterMiddleware, - schemaPath, - params, - options, - id - }) - ) - - if (result) { - if (result instanceof Response) { - errorAfterMiddleware = undefined - response = result - break - } - if (result instanceof Error) { - errorAfterMiddleware = result - continue - } - throw new Error("onError: must return new Response() or instance of Error") - } - } - } - } - - if (errorAfterMiddleware) { - // Re-throw as failure (Promise rejection on the outside). - return yield* Effect.fail(errorAfterMiddleware) - } - } else { - response = fetched.right as Response - } - - if (finalMiddlewares.length > 0 && response) { - for (let i = finalMiddlewares.length - 1; i >= 0; i--) { - const m = finalMiddlewares[i] - if (m && typeof m === "object" && typeof (m as any).onResponse === "function") { - const result = yield* fromMaybePromise(() => - (m as any).onResponse({ - request, - response, - schemaPath, - params, - options, - id - }) - ) - - if (result) { - if (!(result instanceof Response)) { - throw new TypeError("onResponse: must return new Response() when modifying the response") - } - response = result - } - } - } - } - } - - // If middleware short-circuited with a Response, openapi-fetch does NOT run onResponse hooks. - // We already replicated that by running onResponse only inside the fetch path above. - if (!response) { - // Defensive: should never happen, but keeps types happy. - return { error: undefined, response: new Response(null) } as any - } - - const contentLength = response.headers.get("Content-Length") - - if (response.status === 204 || request.method === "HEAD" || contentLength === "0") { - return response.ok ? ({ data: undefined, response } as any) : ({ error: undefined, response } as any) - } - - if (response.ok) { - if (parseAs === "stream") { - return { data: response.body, response } as any - } - - if (parseAs === "json" && !contentLength) { - const raw = yield* Effect.tryPromise({ - try: () => response.text(), - catch: (error) => error - }) - if (raw === "") { - return { data: undefined, response } as any - } - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw), - catch: (error) => error - }) - return { data: parsed, response } as any - } - - const data = yield* Effect.tryPromise({ - try: () => (response as any)[parseAs](), - catch: (error) => error - }) - return { data, response } as any - } - - const errorText = yield* Effect.tryPromise({ - try: () => response.text(), - catch: (error) => error - }) - - const error = yield* Effect.catchAll( - Effect.try({ - try: () => JSON.parse(errorText), - catch: () => errorText - }), - () => Effect.succeed(errorText) - ) - return { error, response } as any - }) - - const coreFetch = (schemaPath: string, fetchOptions: any): Promise => - Effect.runPromise(coreFetchEffect(schemaPath, fetchOptions)) - - return { - request(method: any, url: any, init: any) { - return coreFetch(url, { ...init, method: String(method).toUpperCase() }) - }, - GET(url: any, init: any) { - return coreFetch(url, { ...init, method: "GET" }) - }, - PUT(url: any, init: any) { - return coreFetch(url, { ...init, method: "PUT" }) - }, - POST(url: any, init: any) { - return coreFetch(url, { ...init, method: "POST" }) - }, - DELETE(url: any, init: any) { - return coreFetch(url, { ...init, method: "DELETE" }) - }, - OPTIONS(url: any, init: any) { - return coreFetch(url, { ...init, method: "OPTIONS" }) - }, - HEAD(url: any, init: any) { - return coreFetch(url, { ...init, method: "HEAD" }) - }, - PATCH(url: any, init: any) { - return coreFetch(url, { ...init, method: "PATCH" }) - }, - TRACE(url: any, init: any) { - return coreFetch(url, { ...init, method: "TRACE" }) - }, - /** Register middleware */ - use(...middleware: Array) { - for (const m of middleware) { - if (!m) { - continue - } - assertMiddlewareShape(m) - globalMiddlewares.push(m) - } - }, - /** Unregister middleware */ - eject(...middleware: Array) { - for (const m of middleware) { - const i = globalMiddlewares.indexOf(m) - if (i !== -1) { - globalMiddlewares.splice(i, 1) - } - } - } - } as any -} - -export default createClient diff --git a/packages/app/src/shell/api-client/openapi-fetch-compat/path-based.ts b/packages/app/src/shell/api-client/openapi-fetch-compat/path-based.ts deleted file mode 100644 index 4de4be5..0000000 --- a/packages/app/src/shell/api-client/openapi-fetch-compat/path-based.ts +++ /dev/null @@ -1,52 +0,0 @@ -// CHANGE: Local openapi-fetch compatible path-based client proxy -// WHY: Preserve openapi-fetch API (`createPathBasedClient`, `wrapAsPathBasedClient`) without dependency -// SOURCE: Behavior-compatible with openapi-fetch@0.15.x (MIT) -// PURITY: SHELL (runtime) -// COMPLEXITY: O(1) per path access - -import type { MediaType } from "openapi-typescript-helpers" - -import type { Client, ClientForPath, PathBasedClient } from "./types.js" - -class PathCallForwarder>, Media extends MediaType> { - constructor( - private readonly client: Client, - private readonly url: string - ) {} - - GET = (init: any) => this.client.GET(this.url as any, init) - PUT = (init: any) => this.client.PUT(this.url as any, init) - POST = (init: any) => this.client.POST(this.url as any, init) - DELETE = (init: any) => this.client.DELETE(this.url as any, init) - OPTIONS = (init: any) => this.client.OPTIONS(this.url as any, init) - HEAD = (init: any) => this.client.HEAD(this.url as any, init) - PATCH = (init: any) => this.client.PATCH(this.url as any, init) - TRACE = (init: any) => this.client.TRACE(this.url as any, init) -} - -class PathClientProxyHandler>, Media extends MediaType> - implements ProxyHandler> -{ - client: any = null - - // Assume the property is an URL. - get(coreClient: any, url: string): any { - const forwarder = new PathCallForwarder(coreClient, url) - this.client[url] = forwarder - return forwarder - } -} - -export const wrapAsPathBasedClient = ( - client: Client -): PathBasedClient => { - const handler = new PathClientProxyHandler>, Media>() - const proxy = new Proxy(client as any, handler) - - function ClientProxy(): void {} - ClientProxy.prototype = proxy - - const pathClient = new (ClientProxy as any)() - handler.client = pathClient - return pathClient -} diff --git a/packages/app/src/shell/api-client/openapi-fetch-compat/serialize.ts b/packages/app/src/shell/api-client/openapi-fetch-compat/serialize.ts deleted file mode 100644 index 9bf5577..0000000 --- a/packages/app/src/shell/api-client/openapi-fetch-compat/serialize.ts +++ /dev/null @@ -1,308 +0,0 @@ -// CHANGE: Local openapi-fetch compatible URL/body/header serialization helpers -// WHY: openapi-effect must ship openapi-fetch API without depending on openapi-fetch package -// SOURCE: Behavior-compatible with openapi-fetch@0.15.x (MIT). Re-implemented with minimal changes. -// PURITY: CORE (pure helpers) -// COMPLEXITY: O(n) - -import type { HeadersOptions, QuerySerializer, QuerySerializerOptions } from "./types.js" - -const PATH_PARAM_RE = /\{[^{}]+\}/g - -/** Serialize primitive params to string */ -export const serializePrimitiveParam = ( - name: string, - value: string, - options?: { allowReserved?: boolean } -): string => { - if (value === undefined || value === null) { - return "" - } - - // Disallow deep objects here (users can provide custom querySerializer) - if (typeof value === "object") { - throw new TypeError( - "Deeply-nested arrays/objects aren't supported. Provide your own `querySerializer()` to handle these." - ) - } - - return `${name}=${options?.allowReserved === true ? value : encodeURIComponent(value)}` -} - -/** Serialize object param to string */ -export const serializeObjectParam = ( - name: string, - value: Record, - options: { - style: "simple" | "label" | "matrix" | "form" | "deepObject" - explode: boolean - allowReserved?: boolean - } -): string => { - if (!value || typeof value !== "object") { - return "" - } - - const values: Array = [] - const joiner = ({ simple: ",", label: ".", matrix: ";" } as Record)[options.style] ?? "&" - - if (options.style !== "deepObject" && !options.explode) { - for (const k in value) { - values.push(k, options.allowReserved === true ? String(value[k]) : encodeURIComponent(String(value[k]))) - } - const final2 = values.join(",") - switch (options.style) { - case "form": { - return `${name}=${final2}` - } - case "label": { - return `.${final2}` - } - case "matrix": { - return `;${name}=${final2}` - } - default: { - return final2 - } - } - } - - for (const k in value) { - const finalName = options.style === "deepObject" ? `${name}[${k}]` : k - values.push(serializePrimitiveParam(finalName, String(value[k]), options)) - } - - const final = values.join(joiner) - return options.style === "label" || options.style === "matrix" ? `${joiner}${final}` : final -} - -/** Serialize array param to string */ -export const serializeArrayParam = ( - name: string, - value: Array, - options: { - style: "simple" | "label" | "matrix" | "form" | "spaceDelimited" | "pipeDelimited" - explode: boolean - allowReserved?: boolean - } -): string => { - if (!Array.isArray(value)) { - return "" - } - - if (!options.explode) { - const joiner2 = - ({ form: ",", spaceDelimited: "%20", pipeDelimited: "|" } as Record)[options.style] ?? "," - const final = (options.allowReserved === true ? value : value.map((v) => encodeURIComponent(String(v)))).join( - joiner2 - ) - - switch (options.style) { - case "simple": { - return final - } - case "label": { - return `.${final}` - } - case "matrix": { - return `;${name}=${final}` - } - default: { - return `${name}=${final}` - } - } - } - - const joiner = ({ simple: ",", label: ".", matrix: ";" } as Record)[options.style] ?? "&" - const values: Array = [] - - for (const v of value) { - if (options.style === "simple" || options.style === "label") { - values.push(options.allowReserved === true ? String(v) : encodeURIComponent(String(v))) - } else { - values.push(serializePrimitiveParam(name, String(v), options)) - } - } - - const joined = values.join(joiner) - return options.style === "label" || options.style === "matrix" ? `${joiner}${joined}` : joined -} - -/** Serialize query params to string */ -export const createQuerySerializer = ( - options?: QuerySerializerOptions -): (queryParams: T) => string => { - return function querySerializer(queryParams: T): string { - const search: Array = [] - - if (queryParams && typeof queryParams === "object") { - for (const name in queryParams as any) { - const value = (queryParams as any)[name] - if (value === undefined || value === null) { - continue - } - - if (Array.isArray(value)) { - if (value.length === 0) { - continue - } - search.push( - serializeArrayParam(name, value, { - style: "form", - explode: true, - ...options?.array, - allowReserved: options?.allowReserved || false - }) - ) - continue - } - - if (typeof value === "object") { - search.push( - serializeObjectParam(name, value, { - style: "deepObject", - explode: true, - ...options?.object, - allowReserved: options?.allowReserved || false - }) - ) - continue - } - - search.push(serializePrimitiveParam(name, String(value), options)) - } - } - - return search.join("&") - } -} - -/** - * Handle OpenAPI 3.x serialization styles for path params - * @see https://swagger.io/docs/specification/serialization/#path - */ -export const defaultPathSerializer = (pathname: string, pathParams: Record): string => { - let nextURL = pathname - - for (const match of pathname.match(PATH_PARAM_RE) ?? []) { - let name = match.substring(1, match.length - 1) - let explode = false - let style: "simple" | "label" | "matrix" = "simple" - - if (name.endsWith("*")) { - explode = true - name = name.slice(0, Math.max(0, name.length - 1)) - } - - if (name.startsWith(".")) { - style = "label" - name = name.slice(1) - } else if (name.startsWith(";")) { - style = "matrix" - name = name.slice(1) - } - - if (!pathParams || pathParams[name] === undefined || pathParams[name] === null) { - continue - } - - const value = pathParams[name] - if (Array.isArray(value)) { - nextURL = nextURL.replace(match, serializeArrayParam(name, value, { style, explode })) - continue - } - - if (typeof value === "object") { - nextURL = nextURL.replace(match, serializeObjectParam(name, value as any, { style, explode })) - continue - } - - if (style === "matrix") { - nextURL = nextURL.replace(match, `;${serializePrimitiveParam(name, String(value))}`) - continue - } - - nextURL = nextURL.replace( - match, - style === "label" ? `.${encodeURIComponent(String(value))}` : encodeURIComponent(String(value)) - ) - } - - return nextURL -} - -/** Serialize body object to string */ -export const defaultBodySerializer = (body: T, headers?: Headers | Record): any => { - if (body instanceof FormData) { - return body - } - - if (headers) { - const contentType = typeof (headers as any).get === "function" - ? ((headers as any).get("Content-Type") ?? (headers as any).get("content-type")) - : (headers as any)["Content-Type"] ?? (headers as any)["content-type"] - - if (contentType === "application/x-www-form-urlencoded") { - return new URLSearchParams(body as any).toString() - } - } - - return JSON.stringify(body) -} - -/** Construct URL string from baseUrl and handle path and query params */ -export const createFinalURL = ( - pathname: string, - options: { - baseUrl: string - params: { query?: Record; path?: Record } - querySerializer: QuerySerializer - } -): string => { - let finalURL = `${options.baseUrl}${pathname}` - - if (options.params?.path) { - finalURL = defaultPathSerializer(finalURL, options.params.path) - } - - let search = options.querySerializer((options.params.query ?? {}) as any) - if (search.startsWith("?")) { - search = search.slice(1) - } - - if (search) { - finalURL += `?${search}` - } - - return finalURL -} - -/** Merge headers a and b, with b taking priority */ -export const mergeHeaders = (...allHeaders: Array): Headers => { - const finalHeaders = new Headers() - - for (const h of allHeaders) { - if (!h || typeof h !== "object") { - continue - } - - const iterator = h instanceof Headers ? h.entries() : Object.entries(h) - for (const [k, v] of iterator) { - if (v === null) { - finalHeaders.delete(k) - } else if (Array.isArray(v)) { - for (const v2 of v) { - finalHeaders.append(k, String(v2)) - } - } else if (v !== undefined) { - finalHeaders.set(k, String(v)) - } - } - } - - return finalHeaders -} - -/** Remove trailing slash from url */ -export const removeTrailingSlash = (url: string): string => { - return url.endsWith("/") ? url.slice(0, Math.max(0, url.length - 1)) : url -} diff --git a/packages/app/src/shell/api-client/promise-client/client-kernel.ts b/packages/app/src/shell/api-client/promise-client/client-kernel.ts new file mode 100644 index 0000000..2b0b97e --- /dev/null +++ b/packages/app/src/shell/api-client/promise-client/client-kernel.ts @@ -0,0 +1,294 @@ +// CHANGE: Promise-client option resolution helpers +// WHY: Keep create-client small and lint-clean while preserving openapi-fetch-compatible behavior +// SOURCE: n/a +// PURITY: SHELL +// COMPLEXITY: O(n) + +import { createQuerySerializer, defaultBodySerializer, removeTrailingSlash } from "./serialize.js" +import type { + BodySerializer, + ClientOptions, + HeadersOptions, + MergedOptions, + Middleware, + MiddlewareCallbackParams, + ParseAs, + QuerySerializer, + QuerySerializerOptions, + RequestOptions +} from "./types.js" + +export type FetchLike = ( + request: Request, + requestInitExt?: Record +) => globalThis.Promise + +export type CoreFetchOptions = RequestOptions & Omit + +type GenericQuerySerializer = QuerySerializer> + +type RequestParams = MiddlewareCallbackParams["params"] + +export type ResolvedClientOptions = { + baseUrl: string + RequestCtor: typeof Request + baseFetch: FetchLike + globalQuerySerializer: GenericQuerySerializer | QuerySerializerOptions | undefined + globalBodySerializer: BodySerializer | undefined + baseHeaders: HeadersOptions | undefined + requestInitExt: Record | undefined + baseInit: RequestInit +} + +export type ResolvedFetchOptions = { + baseUrl: string + RequestCtor: typeof Request + fetch: FetchLike + params: RequestParams + parseAs: ParseAs + querySerializer: GenericQuerySerializer + bodySerializer: BodySerializer + body: unknown + middleware: ReadonlyArray + headers: HeadersOptions | undefined + requestInit: RequestInit + passthroughInit: Record +} + +export type MiddlewareContext = { + schemaPath: string + params: RequestParams + options: MergedOptions> + id: string +} + +const clientOptionKeys = new Set([ + "baseUrl", + "Request", + "fetch", + "querySerializer", + "bodySerializer", + "headers", + "requestInitExt" +]) + +const fetchOptionKeys = new Set([ + "baseUrl", + "fetch", + "Request", + "headers", + "params", + "parseAs", + "querySerializer", + "bodySerializer", + "body", + "middleware" +]) + +const toRecord = (value: object | undefined): Record => { + return value === undefined ? {} : (value as Record) +} + +const omitKeys = ( + source: Record, + keysToDrop: ReadonlySet +): Record => { + const result: Record = {} + + for (const [key, value] of Object.entries(source)) { + if (!keysToDrop.has(key)) { + result[key] = value + } + } + + return result +} + +const supportsRequestInitExt = (): boolean => { + if (typeof process !== "object") { + return false + } + + const majorPart = process.versions.node.split(".")[0] ?? "0" + const major = Number.parseInt(majorPart, 10) + return major >= 18 && typeof process.versions["undici"] === "string" +} + +const adaptQuerySerializer = (serializer: QuerySerializer): GenericQuerySerializer => { + const narrowed = serializer as QuerySerializer> + return (query) => narrowed(query) +} + +const isQuerySerializerFn = ( + serializer: QuerySerializer | QuerySerializerOptions | undefined +): serializer is QuerySerializer => { + return typeof serializer === "function" +} + +const isQuerySerializerOptions = ( + serializer: GenericQuerySerializer | QuerySerializerOptions | undefined +): serializer is QuerySerializerOptions => { + return typeof serializer === "object" +} + +const resolveQuerySerializer = ( + globalSerializer: GenericQuerySerializer | QuerySerializerOptions | undefined, + localSerializer: QuerySerializer | QuerySerializerOptions | undefined +): GenericQuerySerializer => { + if (isQuerySerializerFn(localSerializer)) { + return adaptQuerySerializer(localSerializer) + } + + if (localSerializer !== undefined) { + const globalOptions = isQuerySerializerOptions(globalSerializer) ? globalSerializer : undefined + return createQuerySerializer({ ...globalOptions, ...localSerializer }) + } + + if (isQuerySerializerFn(globalSerializer)) { + return globalSerializer + } + + return createQuerySerializer(globalSerializer) +} + +const resolveBodySerializer = ( + globalSerializer: BodySerializer | undefined, + localSerializer: BodySerializer | undefined +): BodySerializer => { + if (localSerializer !== undefined) { + return localSerializer + } + + if (globalSerializer !== undefined) { + return globalSerializer + } + + return defaultBodySerializer +} + +const defaultFetch: FetchLike = (request, ext) => { + return globalThis.fetch(request, ext as RequestInit | undefined) +} + +const resolveRequestFetch = ( + baseFetch: FetchLike, + fetchOverride: ClientOptions["fetch"] | undefined +): FetchLike => { + if (fetchOverride !== undefined) { + return (request) => fetchOverride(request) + } + + return baseFetch +} + +const resolveBaseUrl = ( + globalBaseUrl: string, + localBaseUrl: string | undefined +): string => { + return localBaseUrl === undefined ? globalBaseUrl : removeTrailingSlash(localBaseUrl) +} + +const resolveRequestInit = ( + baseInit: RequestInit, + requestInitRecord: Record +): RequestInit => { + return { + redirect: "follow", + ...baseInit, + ...(requestInitRecord as RequestInit) + } +} + +const resolveClientFetch = (fetchOverride: ClientOptions["fetch"] | undefined): FetchLike => { + return resolveRequestFetch(defaultFetch, fetchOverride) +} + +const resolveParams = (value: unknown): RequestParams => { + return typeof value === "object" && value !== null ? (value as RequestParams) : {} +} + +type NormalizedFetchOptions = { + baseUrl: string | undefined + fetch: ClientOptions["fetch"] | undefined + params: unknown + parseAs: ParseAs + querySerializer: QuerySerializer | QuerySerializerOptions | undefined + bodySerializer: BodySerializer | undefined + body: unknown + middleware: ReadonlyArray + headers: HeadersOptions | undefined +} + +const normalizeFetchOptions = ( + fetchOptions?: CoreFetchOptions +): NormalizedFetchOptions => { + const options = fetchOptions ?? {} + + return { + baseUrl: options.baseUrl, + fetch: options.fetch, + params: options.params, + parseAs: options.parseAs ?? "json", + querySerializer: options.querySerializer, + bodySerializer: options.bodySerializer, + body: options.body, + middleware: options.middleware ?? [], + headers: options.headers + } +} + +export const resolveClientOptions = (clientOptions?: ClientOptions): ResolvedClientOptions => { + const options = clientOptions ?? {} + + return { + baseUrl: removeTrailingSlash(options.baseUrl ?? ""), + RequestCtor: options.Request ?? globalThis.Request, + baseFetch: resolveClientFetch(options.fetch), + globalQuerySerializer: options.querySerializer, + globalBodySerializer: options.bodySerializer, + baseHeaders: options.headers, + requestInitExt: supportsRequestInitExt() ? options.requestInitExt : undefined, + baseInit: omitKeys(toRecord(options), clientOptionKeys) as RequestInit + } +} + +export const resolveFetchOptions = ( + client: ResolvedClientOptions, + fetchOptions?: CoreFetchOptions +): ResolvedFetchOptions => { + const normalized = normalizeFetchOptions(fetchOptions) + const requestInitRecord = omitKeys(toRecord(fetchOptions), fetchOptionKeys) + + return { + baseUrl: resolveBaseUrl(client.baseUrl, normalized.baseUrl), + RequestCtor: client.RequestCtor, + fetch: resolveRequestFetch(client.baseFetch, normalized.fetch), + params: resolveParams(normalized.params), + parseAs: normalized.parseAs, + querySerializer: resolveQuerySerializer(client.globalQuerySerializer, normalized.querySerializer), + bodySerializer: resolveBodySerializer(client.globalBodySerializer, normalized.bodySerializer), + body: normalized.body, + middleware: normalized.middleware, + headers: normalized.headers, + requestInit: resolveRequestInit(client.baseInit, requestInitRecord), + passthroughInit: requestInitRecord + } +} + +export const buildMergedOptions = (resolved: ResolvedFetchOptions): MergedOptions> => { + return Object.freeze({ + baseUrl: resolved.baseUrl, + fetch: globalThis.fetch, + parseAs: resolved.parseAs, + querySerializer: resolved.querySerializer, + bodySerializer: resolved.bodySerializer + }) +} + +export const randomID = (): string => { + if (typeof crypto === "object" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID().slice(0, 9) + } + + return Date.now().toString(36) +} diff --git a/packages/app/src/shell/api-client/promise-client/client-middleware.ts b/packages/app/src/shell/api-client/promise-client/client-middleware.ts new file mode 100644 index 0000000..4990395 --- /dev/null +++ b/packages/app/src/shell/api-client/promise-client/client-middleware.ts @@ -0,0 +1,167 @@ +// CHANGE: Middleware execution pipeline for promise-compatible client +// WHY: Centralize request/response/error middleware behavior with typed Effect composition +// SOURCE: n/a +// PURITY: SHELL +// COMPLEXITY: O(n) + +import { Effect } from "effect" + +import type { MiddlewareContext } from "./client-kernel.js" +import type { Middleware } from "./types.js" + +const identity = (value: A): A => value + +const fromMaybePromise = (thunk: () => A | PromiseLike): Effect.Effect => { + return Effect.tryPromise({ + try: () => globalThis.Promise.resolve(thunk()), + catch: identity + }) +} + +const hasOnRequest = (middleware: Middleware): middleware is Middleware & { + onRequest: NonNullable +} => { + return "onRequest" in middleware && typeof middleware.onRequest === "function" +} + +const hasOnResponse = (middleware: Middleware): middleware is Middleware & { + onResponse: NonNullable +} => { + return "onResponse" in middleware && typeof middleware.onResponse === "function" +} + +const hasOnError = (middleware: Middleware): middleware is Middleware & { + onError: NonNullable +} => { + return "onError" in middleware && typeof middleware.onError === "function" +} + +export type RequestMiddlewareResult = { + request: Request + response: Response | undefined +} + +export type ErrorMiddlewareResult = + | { response: Response } + | { error: unknown } + +const reversedIndexSequence = (size: number): Array => { + const indexes: Array = [] + for (let index = size - 1; index >= 0; index -= 1) { + indexes.push(index) + } + return indexes +} + +const reverseMiddlewares = (middlewares: ReadonlyArray): Array => { + const reversed: Array = [] + + for (const index of reversedIndexSequence(middlewares.length)) { + const middleware = middlewares[index] + if (middleware !== undefined) { + reversed.push(middleware) + } + } + + return reversed +} + +const buildMiddlewareParams = (context: MiddlewareContext, request: Request) => { + return { + request, + schemaPath: context.schemaPath, + params: context.params, + options: context.options, + id: context.id + } +} + +export const runOnRequestMiddlewares = ( + middlewares: ReadonlyArray, + context: MiddlewareContext, + initialRequest: Request +): Effect.Effect => + Effect.gen(function*() { + let request = initialRequest + + for (const middleware of middlewares) { + if (!hasOnRequest(middleware)) { + continue + } + + const result = yield* fromMaybePromise(() => middleware.onRequest(buildMiddlewareParams(context, request))) + + if (result instanceof Request) { + request = result + continue + } + + if (result instanceof Response) { + return { request, response: result } + } + } + + return { request, response: undefined } + }) + +export const runOnErrorMiddlewares = ( + middlewares: ReadonlyArray, + context: MiddlewareContext, + request: Request, + error: unknown +): Effect.Effect => + Effect.gen(function*() { + let currentError = error + + for (const middleware of reverseMiddlewares(middlewares)) { + if (!hasOnError(middleware)) { + continue + } + + const result = yield* fromMaybePromise(() => + middleware.onError({ + ...buildMiddlewareParams(context, request), + error: currentError + }) + ) + + if (result instanceof Response) { + return { response: result } + } + + if (result instanceof Error) { + currentError = result + } + } + + return { error: currentError } + }) + +export const runOnResponseMiddlewares = ( + middlewares: ReadonlyArray, + context: MiddlewareContext, + request: Request, + initialResponse: Response +): Effect.Effect => + Effect.gen(function*() { + let response = initialResponse + + for (const middleware of reverseMiddlewares(middlewares)) { + if (!hasOnResponse(middleware)) { + continue + } + + const result = yield* fromMaybePromise(() => + middleware.onResponse({ + ...buildMiddlewareParams(context, request), + response + }) + ) + + if (result instanceof Response) { + response = result + } + } + + return response + }) diff --git a/packages/app/src/shell/api-client/promise-client/client-request.ts b/packages/app/src/shell/api-client/promise-client/client-request.ts new file mode 100644 index 0000000..aca5fab --- /dev/null +++ b/packages/app/src/shell/api-client/promise-client/client-request.ts @@ -0,0 +1,104 @@ +// CHANGE: Request construction helpers for promise-compatible client +// WHY: Isolate RequestInit/header/body assembly from create-client orchestration +// SOURCE: n/a +// PURITY: SHELL +// COMPLEXITY: O(n) + +import type { ResolvedClientOptions, ResolvedFetchOptions } from "./client-kernel.js" +import { createFinalURL, mergeHeaders } from "./serialize.js" + +type HeaderScalar = string | number | boolean + +type HeaderValue = HeaderScalar | ReadonlyArray | null | undefined + +type HeaderRecord = Record + +const isHeaderScalar = (value: unknown): value is HeaderScalar => { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" +} + +const isHeaderArray = (value: unknown): value is ReadonlyArray => { + return Array.isArray(value) && value.every((item) => isHeaderScalar(item)) +} + +const normalizeHeaderParams = (headers: Record | undefined): HeaderRecord | undefined => { + if (headers === undefined) { + return undefined + } + + const result: HeaderRecord = {} + + for (const [key, value] of Object.entries(headers)) { + if (value === undefined || value === null) { + result[key] = value + continue + } + + if (isHeaderScalar(value) || isHeaderArray(value)) { + result[key] = value + } + } + + return result +} + +const createRequestInit = ( + baseInit: RequestInit, + headers: Headers, + serializedBody: unknown +): RequestInit => { + const init: RequestInit = { + ...baseInit, + headers + } + + if (serializedBody === undefined) { + return init + } + + return { + ...init, + body: serializedBody as BodyInit | null + } +} + +const applyPassthroughInit = (request: Request, passthroughInit: Record): void => { + const target = request as unknown as Record + + for (const [key, value] of Object.entries(passthroughInit)) { + if (!(key in request)) { + target[key] = value + } + } +} + +export const buildRequest = ( + client: ResolvedClientOptions, + resolved: ResolvedFetchOptions, + schemaPath: string +): Request => { + const headerParams = normalizeHeaderParams(resolved.params.header) + const serializerHeaders = mergeHeaders(client.baseHeaders, resolved.headers, headerParams) + + const serializedBody = resolved.body === undefined + ? undefined + : resolved.bodySerializer(resolved.body as never, serializerHeaders) + + const autoContentType = serializedBody === undefined || serializedBody instanceof FormData + ? undefined + : { "Content-Type": "application/json" } + + const finalHeaders = mergeHeaders(autoContentType, client.baseHeaders, resolved.headers, headerParams) + + const request = new resolved.RequestCtor( + createFinalURL(schemaPath, { + baseUrl: resolved.baseUrl, + params: resolved.params, + querySerializer: resolved.querySerializer + }), + createRequestInit(resolved.requestInit, finalHeaders, serializedBody) + ) + + applyPassthroughInit(request, resolved.passthroughInit) + return request +} diff --git a/packages/app/src/shell/api-client/promise-client/client-response.ts b/packages/app/src/shell/api-client/promise-client/client-response.ts new file mode 100644 index 0000000..c683903 --- /dev/null +++ b/packages/app/src/shell/api-client/promise-client/client-response.ts @@ -0,0 +1,101 @@ +// CHANGE: Response parsing and FetchResponse shaping for promise-compatible client +// WHY: Isolate body parsing and no-content handling from create-client orchestration +// SOURCE: n/a +// PURITY: SHELL +// COMPLEXITY: O(|body|) + +import { Effect } from "effect" +import type { MediaType } from "openapi-typescript-helpers" + +import type { FetchResponse, ParseAs } from "./types.js" + +const identity = (value: A): A => value + +const readText = (response: Response): Effect.Effect => { + return Effect.tryPromise({ try: () => response.text(), catch: identity }) +} + +const parseJsonText = (raw: string): Effect.Effect => { + if (raw === "") { + return Effect.void + } + + return Effect.try({ + try: (): unknown => JSON.parse(raw), + catch: identity + }) +} + +const parseAsJson = (response: Response): Effect.Effect => { + return readText(response).pipe(Effect.flatMap((raw) => parseJsonText(raw))) +} + +const parseByMode = (response: Response, parseAs: ParseAs): Effect.Effect => { + if (parseAs === "stream") { + return Effect.succeed(response.body) + } + + if (parseAs === "json") { + return parseAsJson(response) + } + + if (parseAs === "text") { + return readText(response) + } + + if (parseAs === "blob") { + return Effect.tryPromise({ try: () => response.blob(), catch: identity }) + } + + return Effect.tryPromise({ try: () => response.arrayBuffer(), catch: identity }) +} + +const isNoContentResponse = (response: Response, request: Request): boolean => { + const contentLength = response.headers.get("Content-Length") + return response.status === 204 || request.method === "HEAD" || contentLength === "0" +} + +const toFetchResponse = < + Responses extends Record, + Options, + Media extends MediaType +>(value: { data?: unknown; error?: unknown; response: Response }): FetchResponse => { + return value as FetchResponse +} + +export const parseErrorBody = (response: Response): Effect.Effect => { + return readText(response).pipe( + Effect.flatMap((raw) => + Effect.try({ + try: (): unknown => JSON.parse(raw), + catch: () => raw + }) + ) + ) +} + +export const toParsedFetchResponse = < + Responses extends Record, + Options, + Media extends MediaType +>( + response: Response, + request: Request, + parseAs: ParseAs +): Effect.Effect, unknown> => { + if (isNoContentResponse(response, request)) { + return response.ok + ? Effect.succeed(toFetchResponse({ data: undefined, response })) + : Effect.succeed(toFetchResponse({ error: undefined, response })) + } + + if (response.ok) { + return parseByMode(response, parseAs).pipe( + Effect.map((data) => toFetchResponse({ data, response })) + ) + } + + return parseErrorBody(response).pipe( + Effect.map((error) => toFetchResponse({ error, response })) + ) +} diff --git a/packages/app/src/shell/api-client/promise-client/create-client.ts b/packages/app/src/shell/api-client/promise-client/create-client.ts new file mode 100644 index 0000000..faaad30 --- /dev/null +++ b/packages/app/src/shell/api-client/promise-client/create-client.ts @@ -0,0 +1,226 @@ +// CHANGE: Promise-compatible client implemented via Effect internals +// WHY: Preserve openapi-fetch API shape while keeping all effect handling explicit +// SOURCE: behavior-compatible with openapi-fetch@0.15.x (MIT) +// PURITY: SHELL +// COMPLEXITY: O(1) setup + O(n) middleware per request + +import { Effect } from "effect" +import type { HttpMethod, MediaType } from "openapi-typescript-helpers" + +import { + buildMergedOptions, + type CoreFetchOptions, + type MiddlewareContext, + randomID, + resolveClientOptions, + type ResolvedClientOptions, + type ResolvedFetchOptions, + resolveFetchOptions +} from "./client-kernel.js" +import { runOnErrorMiddlewares, runOnRequestMiddlewares, runOnResponseMiddlewares } from "./client-middleware.js" +import { buildRequest } from "./client-request.js" +import { toParsedFetchResponse } from "./client-response.js" +import type { Client, ClientMethod, ClientOptions, ClientRequestMethod, FetchResponse, Middleware } from "./types.js" + +const identity = (value: A): A => value + +const toObjectRecord = (value: unknown): Record => { + return typeof value === "object" && value !== null ? (value as Record) : {} +} + +const mergeMiddlewares = ( + globalMiddlewares: ReadonlyArray, + localMiddlewares: ReadonlyArray +): Array => { + return [...globalMiddlewares, ...localMiddlewares] +} + +type ErrorRecoveryInput = { + middlewares: ReadonlyArray + context: MiddlewareContext + request: Request + error: unknown +} + +const recoverFromFetchError = (input: ErrorRecoveryInput): Effect.Effect => { + return runOnErrorMiddlewares(input.middlewares, input.context, input.request, input.error).pipe( + Effect.flatMap((handled) => { + return "response" in handled ? Effect.succeed(handled.response) : Effect.fail(handled.error) + }) + ) +} + +type FetchExecutionInput = { + middlewares: ReadonlyArray + context: MiddlewareContext + resolvedFetch: ResolvedFetchOptions + request: Request + shortCircuitResponse: Response | undefined + requestInitExt: Record | undefined +} + +const resolveFetchResponse = (input: FetchExecutionInput): Effect.Effect => { + if (input.shortCircuitResponse !== undefined) { + return Effect.succeed(input.shortCircuitResponse) + } + + return Effect.matchEffect( + Effect.tryPromise({ + try: () => input.resolvedFetch.fetch(input.request, input.requestInitExt), + catch: identity + }), + { + onFailure: (error) => + recoverFromFetchError({ + middlewares: input.middlewares, + context: input.context, + request: input.request, + error + }), + onSuccess: Effect.succeed + } + ) +} + +const resolveFinalResponse = ( + middlewares: ReadonlyArray, + context: MiddlewareContext, + request: Request, + fetchResponse: Response, + shortCircuitResponse: Response | undefined +): Effect.Effect => { + return shortCircuitResponse === undefined + ? runOnResponseMiddlewares(middlewares, context, request, fetchResponse) + : Effect.succeed(fetchResponse) +} + +const createCoreFetchEffect = ( + resolvedClient: ResolvedClientOptions, + globalMiddlewares: ReadonlyArray +) => +, Options>( + schemaPath: string, + fetchOptions?: CoreFetchOptions +): Effect.Effect, unknown> => + Effect.gen(function*() { + const resolvedFetch = resolveFetchOptions(resolvedClient, fetchOptions) + const context: MiddlewareContext = { + schemaPath, + params: resolvedFetch.params, + options: buildMergedOptions(resolvedFetch), + id: randomID() + } + + const middlewares = mergeMiddlewares(globalMiddlewares, resolvedFetch.middleware) + const request = buildRequest(resolvedClient, resolvedFetch, schemaPath) + const onRequest = yield* runOnRequestMiddlewares(middlewares, context, request) + + const fetchResponse = yield* resolveFetchResponse({ + middlewares, + context, + resolvedFetch, + request: onRequest.request, + shortCircuitResponse: onRequest.response, + requestInitExt: resolvedClient.requestInitExt + }) + + const finalResponse = yield* resolveFinalResponse( + middlewares, + context, + onRequest.request, + fetchResponse, + onRequest.response + ) + + return yield* toParsedFetchResponse( + finalResponse, + onRequest.request, + resolvedFetch.parseAs + ) + }) + +type GenericFetchResponse = FetchResponse, unknown, Media> + +const createMethodCaller = ( + callMethod: ( + method: HttpMethod, + path: keyof Paths & string, + init: ReadonlyArray + ) => globalThis.Promise>, + method: Method +): ClientMethod => { + return ((url, ...init) => { + return callMethod(method, url as keyof Paths & string, init as ReadonlyArray) + }) as ClientMethod +} + +const createCallMethod = ( + coreFetchEffect: , Options>( + schemaPath: string, + fetchOptions?: CoreFetchOptions + ) => Effect.Effect, unknown> +) => +( + method: HttpMethod, + path: string, + init: ReadonlyArray +): globalThis.Promise> => { + const fetchOptions = { + ...toObjectRecord(init[0]), + method: method.toUpperCase() + } + + return Effect.runPromise( + coreFetchEffect, unknown>( + path, + fetchOptions as CoreFetchOptions + ) + ) +} + +const createMiddlewareControls = (globalMiddlewares: Array): Pick, "use" | "eject"> => { + const use = (...middleware: ReadonlyArray): void => { + globalMiddlewares.push(...middleware) + } + + const eject = (...middleware: ReadonlyArray): void => { + for (const item of middleware) { + const index = globalMiddlewares.indexOf(item) + if (index !== -1) { + globalMiddlewares.splice(index, 1) + } + } + } + + return { use, eject } +} + +export const createClient = ( + clientOptions?: ClientOptions +): Client => { + const resolvedClient = resolveClientOptions(clientOptions) + const globalMiddlewares: Array = [] + const coreFetchEffect = createCoreFetchEffect(resolvedClient, globalMiddlewares) + const callMethod = createCallMethod(coreFetchEffect) + const controls = createMiddlewareControls(globalMiddlewares) + + const request = ((method, path, ...init) => { + return callMethod(method, path as keyof Paths & string, init as ReadonlyArray) + }) as ClientRequestMethod + + return { + request, + GET: createMethodCaller(callMethod, "get"), + PUT: createMethodCaller(callMethod, "put"), + POST: createMethodCaller(callMethod, "post"), + DELETE: createMethodCaller(callMethod, "delete"), + OPTIONS: createMethodCaller(callMethod, "options"), + HEAD: createMethodCaller(callMethod, "head"), + PATCH: createMethodCaller(callMethod, "patch"), + TRACE: createMethodCaller(callMethod, "trace"), + use: controls.use, + eject: controls.eject + } +} + +export default createClient diff --git a/packages/app/src/shell/api-client/openapi-fetch-compat/index.ts b/packages/app/src/shell/api-client/promise-client/index.ts similarity index 61% rename from packages/app/src/shell/api-client/openapi-fetch-compat/index.ts rename to packages/app/src/shell/api-client/promise-client/index.ts index cfc4f05..506bd7d 100644 --- a/packages/app/src/shell/api-client/openapi-fetch-compat/index.ts +++ b/packages/app/src/shell/api-client/promise-client/index.ts @@ -1,15 +1,15 @@ -// CHANGE: openapi-fetch compatible surface exported from openapi-effect -// WHY: Consumers must be able to swap `openapi-fetch` -> `openapi-effect` with near-zero code changes -// NOTE: Promise-based API is intentional (drop-in). Effect is used internally and via opt-in APIs. +// CHANGE: Promise-client compatibility entrypoint +// WHY: Keep openapi-fetch-compatible public API exported from openapi-effect +// PURITY: SHELL re-export module +// COMPLEXITY: O(1) import type { MediaType } from "openapi-typescript-helpers" + import { createClient } from "./create-client.js" import { wrapAsPathBasedClient } from "./path-based.js" import type { ClientOptions, PathBasedClient } from "./types.js" -export { createClient, default } from "./create-client.js" -export type * from "./types.js" - +export { createClient, createClient as default } from "./create-client.js" export { createFinalURL, createQuerySerializer, @@ -23,9 +23,10 @@ export { } from "./serialize.js" export { wrapAsPathBasedClient } from "./path-based.js" +export type * from "./types.js" -export const createPathBasedClient = ( +export const createPathBasedClient = ( clientOptions?: ClientOptions ): PathBasedClient => { - return wrapAsPathBasedClient(createClient(clientOptions)) as any + return wrapAsPathBasedClient(createClient(clientOptions)) } diff --git a/packages/app/src/shell/api-client/promise-client/path-based.ts b/packages/app/src/shell/api-client/promise-client/path-based.ts new file mode 100644 index 0000000..788c970 --- /dev/null +++ b/packages/app/src/shell/api-client/promise-client/path-based.ts @@ -0,0 +1,68 @@ +// CHANGE: Path-based Promise client proxy +// WHY: Preserve openapi-fetch-compatible API (`wrapAsPathBasedClient` / `createPathBasedClient`) +// SOURCE: behavior aligned with openapi-fetch@0.15.x +// PURITY: SHELL +// COMPLEXITY: O(1) per property access + +import type { MediaType } from "openapi-typescript-helpers" + +import type { Client, PathBasedClient } from "./types.js" + +type Forwarder = { + GET: (...init: ReadonlyArray) => unknown + PUT: (...init: ReadonlyArray) => unknown + POST: (...init: ReadonlyArray) => unknown + DELETE: (...init: ReadonlyArray) => unknown + OPTIONS: (...init: ReadonlyArray) => unknown + HEAD: (...init: ReadonlyArray) => unknown + PATCH: (...init: ReadonlyArray) => unknown + TRACE: (...init: ReadonlyArray) => unknown +} + +const createForwarder = ( + client: Client, + url: string +): Forwarder => { + const path = url as never + + return { + GET: (...init) => client.GET(path, ...(init as never)), + PUT: (...init) => client.PUT(path, ...(init as never)), + POST: (...init) => client.POST(path, ...(init as never)), + DELETE: (...init) => client.DELETE(path, ...(init as never)), + OPTIONS: (...init) => client.OPTIONS(path, ...(init as never)), + HEAD: (...init) => client.HEAD(path, ...(init as never)), + PATCH: (...init) => client.PATCH(path, ...(init as never)), + TRACE: (...init) => client.TRACE(path, ...(init as never)) + } +} + +const createProxyHandler = ( + client: Client, + cache: Map +): ProxyHandler> => { + return { + get: (_target, property): unknown => { + if (typeof property !== "string") { + return undefined + } + + const cached = cache.get(property) + if (cached !== undefined) { + return cached + } + + const forwarder = createForwarder(client, property) + cache.set(property, forwarder) + return forwarder + } + } +} + +export const wrapAsPathBasedClient = ( + client: Client +): PathBasedClient => { + const cache = new Map() + const proxy = new Proxy>({}, createProxyHandler(client, cache)) + return proxy as unknown as PathBasedClient +} diff --git a/packages/app/src/shell/api-client/promise-client/serialize-core.ts b/packages/app/src/shell/api-client/promise-client/serialize-core.ts new file mode 100644 index 0000000..cae36a3 --- /dev/null +++ b/packages/app/src/shell/api-client/promise-client/serialize-core.ts @@ -0,0 +1,130 @@ +// CHANGE: Request URL/body/header serialization helpers for promise-compatible client +// WHY: Keep openapi-fetch-compatible behavior while keeping functions small and testable +// SOURCE: behavior aligned with openapi-fetch@0.15.x (MIT) +// PURITY: CORE helpers +// COMPLEXITY: O(n) + +import { defaultPathSerializer } from "./serialize-path.js" +import type { HeadersOptions, QuerySerializer } from "./types.js" + +const readHeader = (headers: Headers | Record, name: string): string | undefined => { + if (headers instanceof Headers) { + return headers.get(name) ?? headers.get(name.toLowerCase()) ?? undefined + } + + return headers[name] ?? headers[name.toLowerCase()] +} + +const isUrlEncodedBody = (body: unknown): body is Record => { + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return false + } + + return Object.values(body).every((value) => typeof value === "string") +} + +const toHeaderEntries = ( + headers: HeadersOptions +): Array<[string, string | number | boolean | ReadonlyArray | null | undefined]> => { + if (headers instanceof Headers) { + return [...headers.entries()] + } + + if (Array.isArray(headers)) { + return headers + } + + return Object.entries(headers) +} + +const appendHeaderValue = ( + finalHeaders: Headers, + key: string, + rawValue: string | number | boolean | ReadonlyArray | null | undefined +): void => { + if (rawValue === null) { + finalHeaders.delete(key) + return + } + + if (rawValue === undefined) { + return + } + + if (Array.isArray(rawValue)) { + for (const value of rawValue) { + finalHeaders.append(key, String(value)) + } + return + } + + finalHeaders.set(key, String(rawValue)) +} + +/** Serialize body object to string */ +export const defaultBodySerializer = ( + body: unknown, + headers?: Headers | Record +): unknown => { + let serialized: unknown + + if (body instanceof FormData) { + serialized = body + } else if ( + headers !== undefined && + readHeader(headers, "Content-Type") === "application/x-www-form-urlencoded" && + isUrlEncodedBody(body) + ) { + serialized = new URLSearchParams(body).toString() + } else { + serialized = JSON.stringify(body ?? null) + } + + return serialized +} + +/** Construct URL string from baseUrl and handle path and query params */ +export const createFinalURL = ( + pathname: string, + options: { + baseUrl: string + params: { query?: Record; path?: Record } + querySerializer: QuerySerializer> + } +): string => { + const pathURL = options.params.path === undefined + ? `${options.baseUrl}${pathname}` + : defaultPathSerializer(`${options.baseUrl}${pathname}`, options.params.path) + + let search = options.querySerializer(options.params.query ?? {}) + if (search.startsWith("?")) { + search = search.slice(1) + } + + return search.length > 0 ? `${pathURL}?${search}` : pathURL +} + +/** Merge headers a and b, with b taking priority */ +export const mergeHeaders = (...allHeaders: ReadonlyArray): Headers => { + const finalHeaders = new Headers() + + for (const headerInput of allHeaders) { + if (headerInput === undefined) { + continue + } + + for (const [key, rawValue] of toHeaderEntries(headerInput)) { + appendHeaderValue(finalHeaders, key, rawValue) + } + } + + return finalHeaders +} + +/** Remove trailing slash from url */ +export const removeTrailingSlash = (url: string): string => { + return url.endsWith("/") ? url.slice(0, -1) : url +} + +export { createQuerySerializer } from "./serialize-params.js" +export { defaultPathSerializer } from "./serialize-path.js" diff --git a/packages/app/src/shell/api-client/promise-client/serialize-params.ts b/packages/app/src/shell/api-client/promise-client/serialize-params.ts new file mode 100644 index 0000000..b695e22 --- /dev/null +++ b/packages/app/src/shell/api-client/promise-client/serialize-params.ts @@ -0,0 +1,304 @@ +// CHANGE: Query/path serialization helpers for promise-compatible client +// WHY: Keep openapi-fetch-compatible URL behavior with lint-clean small functions +// SOURCE: behavior aligned with openapi-fetch@0.15.x (MIT) +// PURITY: CORE helpers +// COMPLEXITY: O(n) + +import { + isPrimitive, + isPrimitiveArray, + isPrimitiveRecord, + type Primitive, + type PrimitiveRecord +} from "./serialize-shared.js" +import type { QuerySerializer, QuerySerializerOptions } from "./types.js" + +type PathStyle = "simple" | "label" | "matrix" +type ObjectStyle = PathStyle | "form" | "deepObject" +type ArrayStyle = PathStyle | "form" | "spaceDelimited" | "pipeDelimited" +type Encoder = (value: string) => string + +type ObjectSerializeOptions = { + style: ObjectStyle + explode: boolean + allowReserved?: boolean +} + +type ArraySerializeOptions = { + style: ArrayStyle + explode: boolean + allowReserved?: boolean +} + +const passthroughEncoder: Encoder = (value) => value +const urlEncoder: Encoder = (value) => encodeURIComponent(value) + +const objectJoiners: Record = { + simple: ",", + label: ".", + matrix: ";", + form: "&", + deepObject: "&" +} + +const collapsedArrayJoiners: Record = { + simple: ",", + label: ",", + matrix: ",", + form: ",", + spaceDelimited: "%20", + pipeDelimited: "|" +} + +const explodedArrayJoiners: Record = { + simple: ",", + label: ".", + matrix: ";", + form: "&", + spaceDelimited: "&", + pipeDelimited: "&" +} + +const resolveEncoder = (options: { allowReserved?: boolean } | undefined): Encoder => { + return options?.allowReserved === true ? passthroughEncoder : urlEncoder +} + +function withAllowReserved( + options: Omit, + allowReserved: boolean | undefined +): ArraySerializeOptions +function withAllowReserved( + options: Omit, + allowReserved: boolean | undefined +): ObjectSerializeOptions +function withAllowReserved( + options: Omit | Omit, + allowReserved: boolean | undefined +): ArraySerializeOptions | ObjectSerializeOptions { + if (allowReserved === undefined) { + return options + } + + return { ...options, allowReserved } +} + +const renderDottedOrMatrix = (name: string, style: PathStyle | ArrayStyle | ObjectStyle, packed: string): string => { + if (style === "label") { + return `.${packed}` + } + + if (style === "matrix") { + return `;${name}=${packed}` + } + + return `${name}=${packed}` +} + +const renderCollapsedObject = (name: string, style: ObjectStyle, packed: string): string => { + if (style === "form") { + return `${name}=${packed}` + } + + if (style === "simple" || style === "deepObject") { + return packed + } + + return renderDottedOrMatrix(name, style, packed) +} + +const renderCollapsedArray = (name: string, style: ArrayStyle, packed: string): string => { + if (style === "simple") { + return packed + } + + return renderDottedOrMatrix(name, style, packed) +} + +const serializePrimitiveWithEncoder = (name: string, value: Primitive, encoder: Encoder): string => { + return `${name}=${encoder(String(value))}` +} + +const serializeObjectCollapsed = ( + name: string, + value: PrimitiveRecord, + options: ObjectSerializeOptions, + encoder: Encoder +): string => { + const flattened = Object.entries(value).flatMap(([key, currentValue]) => [ + key, + encoder(String(currentValue)) + ]) + + return renderCollapsedObject(name, options.style, flattened.join(",")) +} + +const serializeObjectExploded = ( + name: string, + value: PrimitiveRecord, + options: ObjectSerializeOptions, + encoder: Encoder +): string => { + const entries = Object.entries(value).map(([key, currentValue]) => { + const finalName = options.style === "deepObject" ? `${name}[${key}]` : key + return serializePrimitiveWithEncoder(finalName, currentValue, encoder) + }) + + const joiner = objectJoiners[options.style] + const joined = entries.join(joiner) + return options.style === "label" || options.style === "matrix" ? `${joiner}${joined}` : joined +} + +const serializeArrayCollapsed = ( + name: string, + value: ReadonlyArray, + options: ArraySerializeOptions, + encoder: Encoder +): string => { + const packed = value.map((item) => encoder(String(item))).join(collapsedArrayJoiners[options.style]) + return renderCollapsedArray(name, options.style, packed) +} + +const serializeArrayExploded = ( + name: string, + value: ReadonlyArray, + options: ArraySerializeOptions, + encoder: Encoder +): string => { + const parts = value.map((item) => { + if (options.style === "simple" || options.style === "label") { + return encoder(String(item)) + } + + return serializePrimitiveWithEncoder(name, item, encoder) + }) + + const joiner = explodedArrayJoiners[options.style] + const joined = parts.join(joiner) + return options.style === "label" || options.style === "matrix" ? `${joiner}${joined}` : joined +} + +const serializePrimitiveQuery = ( + name: string, + rawValue: unknown, + encoder: Encoder +): string | undefined => { + return isPrimitive(rawValue) ? serializePrimitiveWithEncoder(name, rawValue, encoder) : undefined +} + +const serializeArrayQuery = ( + name: string, + rawValue: unknown, + options: QuerySerializerOptions | undefined +): string | undefined => { + if (!isPrimitiveArray(rawValue) || rawValue.length === 0) { + return undefined + } + + return serializeArrayParam( + name, + rawValue, + withAllowReserved( + { + style: "form", + explode: true, + ...options?.array + }, + options?.allowReserved + ) + ) +} + +const serializeObjectQuery = ( + name: string, + rawValue: unknown, + options: QuerySerializerOptions | undefined +): string | undefined => { + if (!isPrimitiveRecord(rawValue)) { + return undefined + } + + return serializeObjectParam( + name, + rawValue, + withAllowReserved( + { + style: "deepObject", + explode: true, + ...options?.object + }, + options?.allowReserved + ) + ) +} + +const serializeQueryValue = ( + name: string, + rawValue: unknown, + options: QuerySerializerOptions | undefined +): string | undefined => { + if (rawValue === undefined || rawValue === null) { + return undefined + } + + const encoder = resolveEncoder(options) + const primitive = serializePrimitiveQuery(name, rawValue, encoder) + if (primitive !== undefined) { + return primitive + } + + const serializedArray = serializeArrayQuery(name, rawValue, options) + if (serializedArray !== undefined) { + return serializedArray + } + + return serializeObjectQuery(name, rawValue, options) +} + +/** Serialize primitive params to string */ +export const serializePrimitiveParam = ( + name: string, + value: string, + options?: { allowReserved?: boolean } +): string => { + const encoder = resolveEncoder(options) + return `${name}=${encoder(value)}` +} + +/** Serialize object param to string */ +export const serializeObjectParam = ( + name: string, + value: PrimitiveRecord, + options: ObjectSerializeOptions +): string => { + const encoder = resolveEncoder(options) + + if (options.style === "deepObject" || options.explode) { + return serializeObjectExploded(name, value, options, encoder) + } + + return serializeObjectCollapsed(name, value, options, encoder) +} + +/** Serialize array param to string */ +export const serializeArrayParam = ( + name: string, + value: ReadonlyArray, + options: ArraySerializeOptions +): string => { + const encoder = resolveEncoder(options) + return options.explode + ? serializeArrayExploded(name, value, options, encoder) + : serializeArrayCollapsed(name, value, options, encoder) +} + +/** Serialize query params to string */ +export const createQuerySerializer = ( + options?: QuerySerializerOptions +): QuerySerializer> => { + return (queryParams) => { + return Object.entries(queryParams) + .map(([name, rawValue]) => serializeQueryValue(name, rawValue, options)) + .filter((value): value is string => value !== undefined) + .join("&") + } +} diff --git a/packages/app/src/shell/api-client/promise-client/serialize-path.ts b/packages/app/src/shell/api-client/promise-client/serialize-path.ts new file mode 100644 index 0000000..a715374 --- /dev/null +++ b/packages/app/src/shell/api-client/promise-client/serialize-path.ts @@ -0,0 +1,98 @@ +// CHANGE: OpenAPI path serializer extracted from query serializer module +// WHY: Keep files under lint line limits while preserving path-style behavior +// SOURCE: behavior aligned with openapi-fetch@0.15.x (MIT) +// PURITY: CORE helpers +// COMPLEXITY: O(n) + +import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "./serialize-params.js" +import { isPrimitive, isPrimitiveArray, isPrimitiveRecord, type Primitive } from "./serialize-shared.js" + +const PATH_PARAM_RE = /\{[^{}]+\}/g + +type PathStyle = "simple" | "label" | "matrix" + +type ParsedPath = { + name: string + explode: boolean + style: PathStyle +} + +const withStylePrefix = (style: PathStyle, value: string): string => { + if (style === "label") { + return `.${value}` + } + + if (style === "matrix") { + return `;${value}` + } + + return value +} + +const parsePathToken = (rawName: string): ParsedPath => { + const explode = rawName.endsWith("*") + const cleanName = explode ? rawName.slice(0, -1) : rawName + + if (cleanName.startsWith(".")) { + return { name: cleanName.slice(1), explode, style: "label" } + } + + if (cleanName.startsWith(";")) { + return { name: cleanName.slice(1), explode, style: "matrix" } + } + + return { name: cleanName, explode, style: "simple" } +} + +const serializePathPrimitive = (parsed: ParsedPath, value: Primitive): string => { + const encoded = encodeURIComponent(String(value)) + + if (parsed.style === "matrix") { + return withStylePrefix("matrix", serializePrimitiveParam(parsed.name, String(value))) + } + + if (parsed.style === "label") { + return withStylePrefix("label", encoded) + } + + return encoded +} + +const serializePathValue = (parsed: ParsedPath, value: unknown): string | undefined => { + if (isPrimitive(value)) { + return serializePathPrimitive(parsed, value) + } + + if (isPrimitiveArray(value)) { + return serializeArrayParam(parsed.name, value, { + style: parsed.style, + explode: parsed.explode + }) + } + + if (isPrimitiveRecord(value)) { + return serializeObjectParam(parsed.name, value, { + style: parsed.style, + explode: parsed.explode + }) + } + + return undefined +} + +/** Handle OpenAPI path serialization styles */ +export const defaultPathSerializer = (pathname: string, pathParams: Record): string => { + let nextURL = pathname + + for (const match of pathname.match(PATH_PARAM_RE) ?? []) { + const token = match.slice(1, -1) + const parsed = parsePathToken(token) + const replacement = serializePathValue(parsed, pathParams[parsed.name]) + + if (replacement !== undefined) { + nextURL = nextURL.replace(match, replacement) + } + } + + return nextURL +} diff --git a/packages/app/src/shell/api-client/promise-client/serialize-shared.ts b/packages/app/src/shell/api-client/promise-client/serialize-shared.ts new file mode 100644 index 0000000..edb2dcf --- /dev/null +++ b/packages/app/src/shell/api-client/promise-client/serialize-shared.ts @@ -0,0 +1,24 @@ +// CHANGE: Shared primitive guards for serializer modules +// WHY: Avoid duplicate guard logic across query/path serializers +// SOURCE: n/a +// PURITY: CORE helpers +// COMPLEXITY: O(n) + +export type Primitive = string | number | boolean +export type PrimitiveRecord = Record + +export const isPrimitive = (value: unknown): value is Primitive => { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" +} + +export const isPrimitiveArray = (value: unknown): value is ReadonlyArray => { + return Array.isArray(value) && value.every((item) => isPrimitive(item)) +} + +export const isPrimitiveRecord = (value: unknown): value is PrimitiveRecord => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false + } + + return Object.values(value).every((item) => isPrimitive(item)) +} diff --git a/packages/app/src/shell/api-client/promise-client/serialize.ts b/packages/app/src/shell/api-client/promise-client/serialize.ts new file mode 100644 index 0000000..b627530 --- /dev/null +++ b/packages/app/src/shell/api-client/promise-client/serialize.ts @@ -0,0 +1,16 @@ +// CHANGE: Serializer module split into focused helpers +// WHY: Keep openapi-fetch-compatible API while satisfying strict lint limits +// SOURCE: n/a +// PURITY: CORE (re-export) +// COMPLEXITY: O(1) + +export { + createFinalURL, + createQuerySerializer, + defaultBodySerializer, + defaultPathSerializer, + mergeHeaders, + removeTrailingSlash +} from "./serialize-core.js" + +export { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "./serialize-params.js" diff --git a/packages/app/src/shell/api-client/openapi-fetch-compat/types.ts b/packages/app/src/shell/api-client/promise-client/types.ts similarity index 67% rename from packages/app/src/shell/api-client/openapi-fetch-compat/types.ts rename to packages/app/src/shell/api-client/promise-client/types.ts index 159b9a7..a0d3d42 100644 --- a/packages/app/src/shell/api-client/openapi-fetch-compat/types.ts +++ b/packages/app/src/shell/api-client/promise-client/types.ts @@ -1,12 +1,11 @@ -// CHANGE: Local openapi-fetch compatible types (no dependency on openapi-fetch package) -// WHY: openapi-effect must be a drop-in replacement for openapi-fetch without pulling it as a dependency -// SOURCE: API surface is compatible with openapi-fetch@0.15.x (MIT) +// CHANGE: Promise-client compatibility types without dependency on openapi-fetch +// WHY: Keep drop-in public signatures while preserving repo lint constraints +// SOURCE: API shape is compatible with openapi-fetch@0.15.x (MIT) // PURITY: CORE (types only) // COMPLEXITY: O(1) import type { ErrorResponse, - FilterKeys, HttpMethod, IsOperationRequestBodyOptional, MediaType, @@ -22,7 +21,7 @@ export interface ClientOptions extends Omit { /** set the common root URL for all API requests */ baseUrl?: string /** custom fetch (defaults to globalThis.fetch) */ - fetch?: (input: Request) => Promise + fetch?: (input: Request) => globalThis.Promise /** custom Request (defaults to globalThis.Request) */ Request?: typeof Request /** global querySerializer */ @@ -47,7 +46,8 @@ export type HeadersOptions = > export type QuerySerializer = ( - query: T extends { parameters: any } ? NonNullable : Record + query: T extends { parameters: Record } ? NonNullable + : Record ) => string /** @see https://swagger.io/docs/specification/serialization/#query */ @@ -76,7 +76,10 @@ export type QuerySerializerOptions = { allowReserved?: boolean } -export type BodySerializer = (body: OperationRequestBodyContent) => any +export type BodySerializer = ( + body: OperationRequestBodyContent, + headers?: Headers | Record +) => unknown type BodyType = { json: T @@ -97,7 +100,7 @@ export interface DefaultParamsOption { } } -export type ParamsOption = T extends { parameters: any } +export type ParamsOption = T extends { parameters: Record } ? RequiredKeysOf extends never ? { params?: T["parameters"] } : { params: T["parameters"] } : DefaultParamsOption @@ -116,13 +119,13 @@ export type RequestOptions = parseAs?: ParseAs fetch?: ClientOptions["fetch"] headers?: HeadersOptions - middleware?: Array + middleware?: ReadonlyArray } export type FetchOptions = RequestOptions & Omit export type FetchResponse< - T extends Record, + T extends Record, Options, Media extends MediaType > = @@ -165,15 +168,26 @@ export interface MiddlewareCallbackParams { export type MiddlewareOnRequest = ( options: MiddlewareCallbackParams -) => void | Request | Response | undefined | Promise +) => + | Request + | Response + | undefined + | globalThis.Promise export type MiddlewareOnResponse = ( options: MiddlewareCallbackParams & { response: Response } -) => void | Response | undefined | Promise +) => + | Response + | undefined + | globalThis.Promise export type MiddlewareOnError = ( options: MiddlewareCallbackParams & { error: unknown } -) => void | Response | Error | Promise +) => + | Response + | Error + | undefined + | globalThis.Promise export type Middleware = | { @@ -192,32 +206,38 @@ export type Middleware = onError: MiddlewareOnError } -/** This type helper makes the 2nd function param required if params/requestBody are required; otherwise, optional */ -export type MaybeOptionalInit = - RequiredKeysOf>> extends never - ? FetchOptions> | undefined - : FetchOptions> +type OperationForLocation = Params extends Record + ? Operation + : never + +type OperationForPathMethod< + Paths extends object, + Path extends keyof Paths, + Method extends HttpMethod +> = OperationForLocation & Record + +/** 2nd param is required only when params/requestBody are required */ +export type MaybeOptionalInit = + RequiredKeysOf>> extends never + ? FetchOptions> | undefined + : FetchOptions> // The final init param to accept. // - Determines if the param is optional or not. // - Performs arbitrary [key: string] addition. -// Note: the addition MUST happen after all the inference happens (otherwise TS can't infer if init is required or not). -export type InitParam = RequiredKeysOf extends never ? [(Init & { [key: string]: unknown })?] - : [Init & { [key: string]: unknown }] +// Note: the addition MUST happen after all inference happens. +export type InitParam = RequiredKeysOf extends never ? [(Init & Record)?] + : [Init & Record] -export type ClientMethod< - Paths extends Record>, - Method extends HttpMethod, - Media extends MediaType -> = , Init extends MaybeOptionalInit>( +export type ClientMethod = < + Path extends PathsWithMethod, + Init extends MaybeOptionalInit +>( url: Path, ...init: InitParam -) => Promise> +) => globalThis.Promise, Init, Media>> -export type ClientRequestMethod< - Paths extends Record>, - Media extends MediaType -> = < +export type ClientRequestMethod = < Method extends HttpMethod, Path extends PathsWithMethod, Init extends MaybeOptionalInit @@ -225,15 +245,15 @@ export type ClientRequestMethod< method: Method, url: Path, ...init: InitParam -) => Promise> +) => globalThis.Promise, Init, Media>> -export type ClientForPath, Media extends MediaType> = { +export type ClientForPath, Media extends MediaType> = { [Method in keyof PathInfo as Uppercase]: >( ...init: InitParam - ) => Promise> + ) => globalThis.Promise, Init, Media>> } -export interface Client { +export interface Client { request: ClientRequestMethod /** Call a GET endpoint */ GET: ClientMethod @@ -252,25 +272,24 @@ export interface Client { /** Call a TRACE endpoint */ TRACE: ClientMethod /** Register middleware */ - use(...middleware: Array): void + use(...middleware: ReadonlyArray): void /** Unregister middleware */ - eject(...middleware: Array): void + eject(...middleware: ReadonlyArray): void } -export type ClientPathsWithMethod< - CreatedClient extends Client, - Method extends HttpMethod -> = CreatedClient extends Client ? PathsWithMethod : never +export type ClientPathsWithMethod, Method extends HttpMethod> = + CreatedClient extends Client ? PathsWithMethod + : never export type MethodResponse< - CreatedClient extends Client, + CreatedClient extends Client, Method extends HttpMethod, Path extends ClientPathsWithMethod, - Options = {} -> = CreatedClient extends Client - ? NonNullable["data"]> + Options = object +> = CreatedClient extends Client + ? NonNullable, Options, Media>["data"]> : never -export type PathBasedClient = { - [Path in keyof Paths]: ClientForPath, Media> +export type PathBasedClient = { + [Path in keyof Paths]: ClientForPath, Media> } From 17328ff636defff40ada2cca6af9e72e0e25559f Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:27:47 +0000 Subject: [PATCH 4/6] refactor(app): enforce effect-only analyzer and remove promise client --- packages/app/eslint.config.mts | 80 +++++ .../app/eslint.effect-ts-check.config.mjs | 25 ++ packages/app/src/index.ts | 17 +- .../shell/api-client/create-client-types.ts | 22 +- .../promise-client/client-kernel.ts | 294 ----------------- .../promise-client/client-middleware.ts | 167 ---------- .../promise-client/client-request.ts | 104 ------ .../promise-client/client-response.ts | 101 ------ .../promise-client/create-client.ts | 226 ------------- .../shell/api-client/promise-client/index.ts | 32 -- .../api-client/promise-client/path-based.ts | 68 ---- .../promise-client/serialize-core.ts | 130 -------- .../promise-client/serialize-params.ts | 304 ------------------ .../promise-client/serialize-path.ts | 98 ------ .../promise-client/serialize-shared.ts | 24 -- .../api-client/promise-client/serialize.ts | 16 - .../shell/api-client/promise-client/types.ts | 295 ----------------- 17 files changed, 128 insertions(+), 1875 deletions(-) delete mode 100644 packages/app/src/shell/api-client/promise-client/client-kernel.ts delete mode 100644 packages/app/src/shell/api-client/promise-client/client-middleware.ts delete mode 100644 packages/app/src/shell/api-client/promise-client/client-request.ts delete mode 100644 packages/app/src/shell/api-client/promise-client/client-response.ts delete mode 100644 packages/app/src/shell/api-client/promise-client/create-client.ts delete mode 100644 packages/app/src/shell/api-client/promise-client/index.ts delete mode 100644 packages/app/src/shell/api-client/promise-client/path-based.ts delete mode 100644 packages/app/src/shell/api-client/promise-client/serialize-core.ts delete mode 100644 packages/app/src/shell/api-client/promise-client/serialize-params.ts delete mode 100644 packages/app/src/shell/api-client/promise-client/serialize-path.ts delete mode 100644 packages/app/src/shell/api-client/promise-client/serialize-shared.ts delete mode 100644 packages/app/src/shell/api-client/promise-client/serialize.ts delete mode 100644 packages/app/src/shell/api-client/promise-client/types.ts diff --git a/packages/app/eslint.config.mts b/packages/app/eslint.config.mts index 88d5cc4..bf8875e 100644 --- a/packages/app/eslint.config.mts +++ b/packages/app/eslint.config.mts @@ -209,6 +209,26 @@ export default defineConfig( message: "Запрещены Promise.* — используй комбинаторы Effect (all, forEach, etc.).", }, + { + selector: "CallExpression[callee.object.object.name='globalThis'][callee.object.property.name='Promise']", + message: + "Запрещены globalThis.Promise.* — используй комбинаторы Effect.", + }, + { + selector: "NewExpression[callee.object.name='globalThis'][callee.property.name='Promise']", + message: + "Запрещён globalThis.Promise — используй Effect.async / Effect.tryPromise.", + }, + { + selector: "TSTypeReference[typeName.name='Promise'], TSTypeReference[typeName.name='PromiseLike']", + message: + "Запрещены Promise/PromiseLike в типах — используй Effect.Effect.", + }, + { + selector: "TSTypeReference[typeName.type='TSQualifiedName'][typeName.left.name='globalThis'][typeName.right.name='Promise']", + message: + "Запрещён globalThis.Promise в типах — используй Effect.Effect.", + }, { selector: "CallExpression[callee.property.name='push'] > SpreadElement.arguments", message: "Do not use spread arguments in Array.push", @@ -232,6 +252,18 @@ export default defineConfig( "Запрещён Promise — используй Effect.Effect.", suggest: ["Effect.Effect"], }, + PromiseLike: { + message: + "Запрещён PromiseLike — используй Effect.Effect.", + }, + "PromiseLike<*>": { + message: + "Запрещён PromiseLike — используй Effect.Effect.", + }, + "globalThis.Promise": { + message: + "Запрещён globalThis.Promise — используй Effect.Effect.", + }, }, }, ], @@ -312,6 +344,30 @@ export default defineConfig( selector: 'CallExpression[callee.name="require"]', message: "Avoid using require(). Use ES6 imports instead.", }, + { + selector: "NewExpression[callee.name='Promise']", + message: "Запрещён new Promise — используй Effect.async / Effect.tryPromise.", + }, + { + selector: "CallExpression[callee.object.name='Promise']", + message: "Запрещены Promise.* — используй комбинаторы Effect.", + }, + { + selector: "CallExpression[callee.object.object.name='globalThis'][callee.object.property.name='Promise']", + message: "Запрещены globalThis.Promise.* — используй комбинаторы Effect.", + }, + { + selector: "NewExpression[callee.object.name='globalThis'][callee.property.name='Promise']", + message: "Запрещён globalThis.Promise — используй Effect.async / Effect.tryPromise.", + }, + { + selector: "TSTypeReference[typeName.name='Promise'], TSTypeReference[typeName.name='PromiseLike']", + message: "Запрещены Promise/PromiseLike в типах — используй Effect.Effect.", + }, + { + selector: "TSTypeReference[typeName.type='TSQualifiedName'][typeName.left.name='globalThis'][typeName.right.name='Promise']", + message: "Запрещён globalThis.Promise в типах — используй Effect.Effect.", + }, ], '@typescript-eslint/no-restricted-types': 'off', // Axiom type casting functions intentionally use single-use type parameters @@ -336,6 +392,30 @@ export default defineConfig( selector: 'CallExpression[callee.name="require"]', message: "Avoid using require(). Use ES6 imports instead.", }, + { + selector: "NewExpression[callee.name='Promise']", + message: "Запрещён new Promise — используй Effect.async / Effect.tryPromise.", + }, + { + selector: "CallExpression[callee.object.name='Promise']", + message: "Запрещены Promise.* — используй комбинаторы Effect.", + }, + { + selector: "CallExpression[callee.object.object.name='globalThis'][callee.object.property.name='Promise']", + message: "Запрещены globalThis.Promise.* — используй комбинаторы Effect.", + }, + { + selector: "NewExpression[callee.object.name='globalThis'][callee.property.name='Promise']", + message: "Запрещён globalThis.Promise — используй Effect.async / Effect.tryPromise.", + }, + { + selector: "TSTypeReference[typeName.name='Promise'], TSTypeReference[typeName.name='PromiseLike']", + message: "Запрещены Promise/PromiseLike в типах — используй Effect.Effect.", + }, + { + selector: "TSTypeReference[typeName.type='TSQualifiedName'][typeName.left.name='globalThis'][typeName.right.name='Promise']", + message: "Запрещён globalThis.Promise в типах — используй Effect.Effect.", + }, ], '@typescript-eslint/no-restricted-types': 'off', }, diff --git a/packages/app/eslint.effect-ts-check.config.mjs b/packages/app/eslint.effect-ts-check.config.mjs index a08b380..373377c 100644 --- a/packages/app/eslint.effect-ts-check.config.mjs +++ b/packages/app/eslint.effect-ts-check.config.mjs @@ -79,6 +79,22 @@ const restrictedSyntaxBase = [ selector: "CallExpression[callee.object.name='Promise']", message: "Avoid Promise.*. Use Effect combinators." }, + { + selector: "CallExpression[callee.object.object.name='globalThis'][callee.object.property.name='Promise']", + message: "Avoid globalThis.Promise.*. Use Effect combinators." + }, + { + selector: "NewExpression[callee.object.name='globalThis'][callee.property.name='Promise']", + message: "Avoid globalThis.Promise. Use Effect.async / Effect.tryPromise." + }, + { + selector: "TSTypeReference[typeName.name='Promise'], TSTypeReference[typeName.name='PromiseLike']", + message: "Avoid Promise/PromiseLike types. Use Effect.Effect." + }, + { + selector: "TSTypeReference[typeName.type='TSQualifiedName'][typeName.left.name='globalThis'][typeName.right.name='Promise']", + message: "Avoid globalThis.Promise type. Use Effect.Effect." + }, { selector: "CallExpression[callee.name='require']", message: "Avoid require(). Use ES module imports." @@ -178,6 +194,15 @@ export default tseslint.config( }, "Promise<*>": { message: "Avoid Promise. Use Effect.Effect." + }, + PromiseLike: { + message: "Avoid PromiseLike. Use Effect.Effect." + }, + "PromiseLike<*>": { + message: "Avoid PromiseLike. Use Effect.Effect." + }, + "globalThis.Promise": { + message: "Avoid globalThis.Promise. Use Effect.Effect." } } }], diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 8cb69ed..ca17333 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,23 +1,16 @@ -// CHANGE: Make openapi-effect a drop-in replacement for openapi-fetch (Promise API), with an opt-in Effect API. -// WHY: Consumer projects must be able to swap openapi-fetch -> openapi-effect with near-zero code changes. -// QUOTE(ТЗ): "openapi-effect должен почти 1 в 1 заменяться с openapi-fetch" / "Просто добавлять effect поведение" +// CHANGE: Expose Effect-only public API +// WHY: Enforce Effect-first paradigm and remove Promise-based client surface // SOURCE: n/a // PURITY: SHELL (re-exports) // COMPLEXITY: O(1) -// Promise-based client (openapi-fetch compatible) -export { default } from "./shell/api-client/promise-client/index.js" -export { createClient } from "./shell/api-client/promise-client/index.js" -export * from "./shell/api-client/promise-client/index.js" - -// Effect-based client (opt-in) export * as FetchHttpClient from "@effect/platform/FetchHttpClient" -// Strict Effect client (advanced) export type * from "./core/api-client/index.js" export { assertNever } from "./core/api-client/index.js" export type { + ClientOptions, DispatchersFor, StrictApiClient, StrictApiClientWithDispatchers @@ -26,7 +19,7 @@ export type { export type { Decoder, Dispatcher, RawResponse, StrictClient, StrictRequestInit } from "./shell/api-client/index.js" export { - createClient as createClientStrict, + createClient, createClientEffect, createDispatcher, createStrictClient, @@ -38,5 +31,7 @@ export { unexpectedStatus } from "./shell/api-client/index.js" +export { createClientEffect as default } from "./shell/api-client/index.js" + // Generated dispatchers (auto-generated from OpenAPI schema) export * from "./generated/index.js" diff --git a/packages/app/src/shell/api-client/create-client-types.ts b/packages/app/src/shell/api-client/create-client-types.ts index 3a8e464..a141cbc 100644 --- a/packages/app/src/shell/api-client/create-client-types.ts +++ b/packages/app/src/shell/api-client/create-client-types.ts @@ -22,14 +22,26 @@ import type { ResponsesFor } from "../../core/api-client/strict-types.js" import type { Dispatcher } from "../../core/axioms.js" -import type { ClientOptions as OpenapiFetchClientOptions } from "./promise-client/types.js" + +export type HeadersOptions = + | Required["headers"] + | Record< + string, + | string + | number + | boolean + | ReadonlyArray + | null + | undefined + > /** - * Client configuration options - * - * @pure - immutable configuration + * Effect client configuration options */ -export type ClientOptions = OpenapiFetchClientOptions +export interface ClientOptions extends Omit { + readonly baseUrl?: string + readonly headers?: HeadersOptions +} // CHANGE: Add dispatcher map type for auto-dispatching clients // WHY: Enable creating clients that infer dispatcher from path+method without per-call parameter diff --git a/packages/app/src/shell/api-client/promise-client/client-kernel.ts b/packages/app/src/shell/api-client/promise-client/client-kernel.ts deleted file mode 100644 index 2b0b97e..0000000 --- a/packages/app/src/shell/api-client/promise-client/client-kernel.ts +++ /dev/null @@ -1,294 +0,0 @@ -// CHANGE: Promise-client option resolution helpers -// WHY: Keep create-client small and lint-clean while preserving openapi-fetch-compatible behavior -// SOURCE: n/a -// PURITY: SHELL -// COMPLEXITY: O(n) - -import { createQuerySerializer, defaultBodySerializer, removeTrailingSlash } from "./serialize.js" -import type { - BodySerializer, - ClientOptions, - HeadersOptions, - MergedOptions, - Middleware, - MiddlewareCallbackParams, - ParseAs, - QuerySerializer, - QuerySerializerOptions, - RequestOptions -} from "./types.js" - -export type FetchLike = ( - request: Request, - requestInitExt?: Record -) => globalThis.Promise - -export type CoreFetchOptions = RequestOptions & Omit - -type GenericQuerySerializer = QuerySerializer> - -type RequestParams = MiddlewareCallbackParams["params"] - -export type ResolvedClientOptions = { - baseUrl: string - RequestCtor: typeof Request - baseFetch: FetchLike - globalQuerySerializer: GenericQuerySerializer | QuerySerializerOptions | undefined - globalBodySerializer: BodySerializer | undefined - baseHeaders: HeadersOptions | undefined - requestInitExt: Record | undefined - baseInit: RequestInit -} - -export type ResolvedFetchOptions = { - baseUrl: string - RequestCtor: typeof Request - fetch: FetchLike - params: RequestParams - parseAs: ParseAs - querySerializer: GenericQuerySerializer - bodySerializer: BodySerializer - body: unknown - middleware: ReadonlyArray - headers: HeadersOptions | undefined - requestInit: RequestInit - passthroughInit: Record -} - -export type MiddlewareContext = { - schemaPath: string - params: RequestParams - options: MergedOptions> - id: string -} - -const clientOptionKeys = new Set([ - "baseUrl", - "Request", - "fetch", - "querySerializer", - "bodySerializer", - "headers", - "requestInitExt" -]) - -const fetchOptionKeys = new Set([ - "baseUrl", - "fetch", - "Request", - "headers", - "params", - "parseAs", - "querySerializer", - "bodySerializer", - "body", - "middleware" -]) - -const toRecord = (value: object | undefined): Record => { - return value === undefined ? {} : (value as Record) -} - -const omitKeys = ( - source: Record, - keysToDrop: ReadonlySet -): Record => { - const result: Record = {} - - for (const [key, value] of Object.entries(source)) { - if (!keysToDrop.has(key)) { - result[key] = value - } - } - - return result -} - -const supportsRequestInitExt = (): boolean => { - if (typeof process !== "object") { - return false - } - - const majorPart = process.versions.node.split(".")[0] ?? "0" - const major = Number.parseInt(majorPart, 10) - return major >= 18 && typeof process.versions["undici"] === "string" -} - -const adaptQuerySerializer = (serializer: QuerySerializer): GenericQuerySerializer => { - const narrowed = serializer as QuerySerializer> - return (query) => narrowed(query) -} - -const isQuerySerializerFn = ( - serializer: QuerySerializer | QuerySerializerOptions | undefined -): serializer is QuerySerializer => { - return typeof serializer === "function" -} - -const isQuerySerializerOptions = ( - serializer: GenericQuerySerializer | QuerySerializerOptions | undefined -): serializer is QuerySerializerOptions => { - return typeof serializer === "object" -} - -const resolveQuerySerializer = ( - globalSerializer: GenericQuerySerializer | QuerySerializerOptions | undefined, - localSerializer: QuerySerializer | QuerySerializerOptions | undefined -): GenericQuerySerializer => { - if (isQuerySerializerFn(localSerializer)) { - return adaptQuerySerializer(localSerializer) - } - - if (localSerializer !== undefined) { - const globalOptions = isQuerySerializerOptions(globalSerializer) ? globalSerializer : undefined - return createQuerySerializer({ ...globalOptions, ...localSerializer }) - } - - if (isQuerySerializerFn(globalSerializer)) { - return globalSerializer - } - - return createQuerySerializer(globalSerializer) -} - -const resolveBodySerializer = ( - globalSerializer: BodySerializer | undefined, - localSerializer: BodySerializer | undefined -): BodySerializer => { - if (localSerializer !== undefined) { - return localSerializer - } - - if (globalSerializer !== undefined) { - return globalSerializer - } - - return defaultBodySerializer -} - -const defaultFetch: FetchLike = (request, ext) => { - return globalThis.fetch(request, ext as RequestInit | undefined) -} - -const resolveRequestFetch = ( - baseFetch: FetchLike, - fetchOverride: ClientOptions["fetch"] | undefined -): FetchLike => { - if (fetchOverride !== undefined) { - return (request) => fetchOverride(request) - } - - return baseFetch -} - -const resolveBaseUrl = ( - globalBaseUrl: string, - localBaseUrl: string | undefined -): string => { - return localBaseUrl === undefined ? globalBaseUrl : removeTrailingSlash(localBaseUrl) -} - -const resolveRequestInit = ( - baseInit: RequestInit, - requestInitRecord: Record -): RequestInit => { - return { - redirect: "follow", - ...baseInit, - ...(requestInitRecord as RequestInit) - } -} - -const resolveClientFetch = (fetchOverride: ClientOptions["fetch"] | undefined): FetchLike => { - return resolveRequestFetch(defaultFetch, fetchOverride) -} - -const resolveParams = (value: unknown): RequestParams => { - return typeof value === "object" && value !== null ? (value as RequestParams) : {} -} - -type NormalizedFetchOptions = { - baseUrl: string | undefined - fetch: ClientOptions["fetch"] | undefined - params: unknown - parseAs: ParseAs - querySerializer: QuerySerializer | QuerySerializerOptions | undefined - bodySerializer: BodySerializer | undefined - body: unknown - middleware: ReadonlyArray - headers: HeadersOptions | undefined -} - -const normalizeFetchOptions = ( - fetchOptions?: CoreFetchOptions -): NormalizedFetchOptions => { - const options = fetchOptions ?? {} - - return { - baseUrl: options.baseUrl, - fetch: options.fetch, - params: options.params, - parseAs: options.parseAs ?? "json", - querySerializer: options.querySerializer, - bodySerializer: options.bodySerializer, - body: options.body, - middleware: options.middleware ?? [], - headers: options.headers - } -} - -export const resolveClientOptions = (clientOptions?: ClientOptions): ResolvedClientOptions => { - const options = clientOptions ?? {} - - return { - baseUrl: removeTrailingSlash(options.baseUrl ?? ""), - RequestCtor: options.Request ?? globalThis.Request, - baseFetch: resolveClientFetch(options.fetch), - globalQuerySerializer: options.querySerializer, - globalBodySerializer: options.bodySerializer, - baseHeaders: options.headers, - requestInitExt: supportsRequestInitExt() ? options.requestInitExt : undefined, - baseInit: omitKeys(toRecord(options), clientOptionKeys) as RequestInit - } -} - -export const resolveFetchOptions = ( - client: ResolvedClientOptions, - fetchOptions?: CoreFetchOptions -): ResolvedFetchOptions => { - const normalized = normalizeFetchOptions(fetchOptions) - const requestInitRecord = omitKeys(toRecord(fetchOptions), fetchOptionKeys) - - return { - baseUrl: resolveBaseUrl(client.baseUrl, normalized.baseUrl), - RequestCtor: client.RequestCtor, - fetch: resolveRequestFetch(client.baseFetch, normalized.fetch), - params: resolveParams(normalized.params), - parseAs: normalized.parseAs, - querySerializer: resolveQuerySerializer(client.globalQuerySerializer, normalized.querySerializer), - bodySerializer: resolveBodySerializer(client.globalBodySerializer, normalized.bodySerializer), - body: normalized.body, - middleware: normalized.middleware, - headers: normalized.headers, - requestInit: resolveRequestInit(client.baseInit, requestInitRecord), - passthroughInit: requestInitRecord - } -} - -export const buildMergedOptions = (resolved: ResolvedFetchOptions): MergedOptions> => { - return Object.freeze({ - baseUrl: resolved.baseUrl, - fetch: globalThis.fetch, - parseAs: resolved.parseAs, - querySerializer: resolved.querySerializer, - bodySerializer: resolved.bodySerializer - }) -} - -export const randomID = (): string => { - if (typeof crypto === "object" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID().slice(0, 9) - } - - return Date.now().toString(36) -} diff --git a/packages/app/src/shell/api-client/promise-client/client-middleware.ts b/packages/app/src/shell/api-client/promise-client/client-middleware.ts deleted file mode 100644 index 4990395..0000000 --- a/packages/app/src/shell/api-client/promise-client/client-middleware.ts +++ /dev/null @@ -1,167 +0,0 @@ -// CHANGE: Middleware execution pipeline for promise-compatible client -// WHY: Centralize request/response/error middleware behavior with typed Effect composition -// SOURCE: n/a -// PURITY: SHELL -// COMPLEXITY: O(n) - -import { Effect } from "effect" - -import type { MiddlewareContext } from "./client-kernel.js" -import type { Middleware } from "./types.js" - -const identity = (value: A): A => value - -const fromMaybePromise = (thunk: () => A | PromiseLike): Effect.Effect => { - return Effect.tryPromise({ - try: () => globalThis.Promise.resolve(thunk()), - catch: identity - }) -} - -const hasOnRequest = (middleware: Middleware): middleware is Middleware & { - onRequest: NonNullable -} => { - return "onRequest" in middleware && typeof middleware.onRequest === "function" -} - -const hasOnResponse = (middleware: Middleware): middleware is Middleware & { - onResponse: NonNullable -} => { - return "onResponse" in middleware && typeof middleware.onResponse === "function" -} - -const hasOnError = (middleware: Middleware): middleware is Middleware & { - onError: NonNullable -} => { - return "onError" in middleware && typeof middleware.onError === "function" -} - -export type RequestMiddlewareResult = { - request: Request - response: Response | undefined -} - -export type ErrorMiddlewareResult = - | { response: Response } - | { error: unknown } - -const reversedIndexSequence = (size: number): Array => { - const indexes: Array = [] - for (let index = size - 1; index >= 0; index -= 1) { - indexes.push(index) - } - return indexes -} - -const reverseMiddlewares = (middlewares: ReadonlyArray): Array => { - const reversed: Array = [] - - for (const index of reversedIndexSequence(middlewares.length)) { - const middleware = middlewares[index] - if (middleware !== undefined) { - reversed.push(middleware) - } - } - - return reversed -} - -const buildMiddlewareParams = (context: MiddlewareContext, request: Request) => { - return { - request, - schemaPath: context.schemaPath, - params: context.params, - options: context.options, - id: context.id - } -} - -export const runOnRequestMiddlewares = ( - middlewares: ReadonlyArray, - context: MiddlewareContext, - initialRequest: Request -): Effect.Effect => - Effect.gen(function*() { - let request = initialRequest - - for (const middleware of middlewares) { - if (!hasOnRequest(middleware)) { - continue - } - - const result = yield* fromMaybePromise(() => middleware.onRequest(buildMiddlewareParams(context, request))) - - if (result instanceof Request) { - request = result - continue - } - - if (result instanceof Response) { - return { request, response: result } - } - } - - return { request, response: undefined } - }) - -export const runOnErrorMiddlewares = ( - middlewares: ReadonlyArray, - context: MiddlewareContext, - request: Request, - error: unknown -): Effect.Effect => - Effect.gen(function*() { - let currentError = error - - for (const middleware of reverseMiddlewares(middlewares)) { - if (!hasOnError(middleware)) { - continue - } - - const result = yield* fromMaybePromise(() => - middleware.onError({ - ...buildMiddlewareParams(context, request), - error: currentError - }) - ) - - if (result instanceof Response) { - return { response: result } - } - - if (result instanceof Error) { - currentError = result - } - } - - return { error: currentError } - }) - -export const runOnResponseMiddlewares = ( - middlewares: ReadonlyArray, - context: MiddlewareContext, - request: Request, - initialResponse: Response -): Effect.Effect => - Effect.gen(function*() { - let response = initialResponse - - for (const middleware of reverseMiddlewares(middlewares)) { - if (!hasOnResponse(middleware)) { - continue - } - - const result = yield* fromMaybePromise(() => - middleware.onResponse({ - ...buildMiddlewareParams(context, request), - response - }) - ) - - if (result instanceof Response) { - response = result - } - } - - return response - }) diff --git a/packages/app/src/shell/api-client/promise-client/client-request.ts b/packages/app/src/shell/api-client/promise-client/client-request.ts deleted file mode 100644 index aca5fab..0000000 --- a/packages/app/src/shell/api-client/promise-client/client-request.ts +++ /dev/null @@ -1,104 +0,0 @@ -// CHANGE: Request construction helpers for promise-compatible client -// WHY: Isolate RequestInit/header/body assembly from create-client orchestration -// SOURCE: n/a -// PURITY: SHELL -// COMPLEXITY: O(n) - -import type { ResolvedClientOptions, ResolvedFetchOptions } from "./client-kernel.js" -import { createFinalURL, mergeHeaders } from "./serialize.js" - -type HeaderScalar = string | number | boolean - -type HeaderValue = HeaderScalar | ReadonlyArray | null | undefined - -type HeaderRecord = Record - -const isHeaderScalar = (value: unknown): value is HeaderScalar => { - return typeof value === "string" || typeof value === "number" || typeof value === "boolean" -} - -const isHeaderArray = (value: unknown): value is ReadonlyArray => { - return Array.isArray(value) && value.every((item) => isHeaderScalar(item)) -} - -const normalizeHeaderParams = (headers: Record | undefined): HeaderRecord | undefined => { - if (headers === undefined) { - return undefined - } - - const result: HeaderRecord = {} - - for (const [key, value] of Object.entries(headers)) { - if (value === undefined || value === null) { - result[key] = value - continue - } - - if (isHeaderScalar(value) || isHeaderArray(value)) { - result[key] = value - } - } - - return result -} - -const createRequestInit = ( - baseInit: RequestInit, - headers: Headers, - serializedBody: unknown -): RequestInit => { - const init: RequestInit = { - ...baseInit, - headers - } - - if (serializedBody === undefined) { - return init - } - - return { - ...init, - body: serializedBody as BodyInit | null - } -} - -const applyPassthroughInit = (request: Request, passthroughInit: Record): void => { - const target = request as unknown as Record - - for (const [key, value] of Object.entries(passthroughInit)) { - if (!(key in request)) { - target[key] = value - } - } -} - -export const buildRequest = ( - client: ResolvedClientOptions, - resolved: ResolvedFetchOptions, - schemaPath: string -): Request => { - const headerParams = normalizeHeaderParams(resolved.params.header) - const serializerHeaders = mergeHeaders(client.baseHeaders, resolved.headers, headerParams) - - const serializedBody = resolved.body === undefined - ? undefined - : resolved.bodySerializer(resolved.body as never, serializerHeaders) - - const autoContentType = serializedBody === undefined || serializedBody instanceof FormData - ? undefined - : { "Content-Type": "application/json" } - - const finalHeaders = mergeHeaders(autoContentType, client.baseHeaders, resolved.headers, headerParams) - - const request = new resolved.RequestCtor( - createFinalURL(schemaPath, { - baseUrl: resolved.baseUrl, - params: resolved.params, - querySerializer: resolved.querySerializer - }), - createRequestInit(resolved.requestInit, finalHeaders, serializedBody) - ) - - applyPassthroughInit(request, resolved.passthroughInit) - return request -} diff --git a/packages/app/src/shell/api-client/promise-client/client-response.ts b/packages/app/src/shell/api-client/promise-client/client-response.ts deleted file mode 100644 index c683903..0000000 --- a/packages/app/src/shell/api-client/promise-client/client-response.ts +++ /dev/null @@ -1,101 +0,0 @@ -// CHANGE: Response parsing and FetchResponse shaping for promise-compatible client -// WHY: Isolate body parsing and no-content handling from create-client orchestration -// SOURCE: n/a -// PURITY: SHELL -// COMPLEXITY: O(|body|) - -import { Effect } from "effect" -import type { MediaType } from "openapi-typescript-helpers" - -import type { FetchResponse, ParseAs } from "./types.js" - -const identity = (value: A): A => value - -const readText = (response: Response): Effect.Effect => { - return Effect.tryPromise({ try: () => response.text(), catch: identity }) -} - -const parseJsonText = (raw: string): Effect.Effect => { - if (raw === "") { - return Effect.void - } - - return Effect.try({ - try: (): unknown => JSON.parse(raw), - catch: identity - }) -} - -const parseAsJson = (response: Response): Effect.Effect => { - return readText(response).pipe(Effect.flatMap((raw) => parseJsonText(raw))) -} - -const parseByMode = (response: Response, parseAs: ParseAs): Effect.Effect => { - if (parseAs === "stream") { - return Effect.succeed(response.body) - } - - if (parseAs === "json") { - return parseAsJson(response) - } - - if (parseAs === "text") { - return readText(response) - } - - if (parseAs === "blob") { - return Effect.tryPromise({ try: () => response.blob(), catch: identity }) - } - - return Effect.tryPromise({ try: () => response.arrayBuffer(), catch: identity }) -} - -const isNoContentResponse = (response: Response, request: Request): boolean => { - const contentLength = response.headers.get("Content-Length") - return response.status === 204 || request.method === "HEAD" || contentLength === "0" -} - -const toFetchResponse = < - Responses extends Record, - Options, - Media extends MediaType ->(value: { data?: unknown; error?: unknown; response: Response }): FetchResponse => { - return value as FetchResponse -} - -export const parseErrorBody = (response: Response): Effect.Effect => { - return readText(response).pipe( - Effect.flatMap((raw) => - Effect.try({ - try: (): unknown => JSON.parse(raw), - catch: () => raw - }) - ) - ) -} - -export const toParsedFetchResponse = < - Responses extends Record, - Options, - Media extends MediaType ->( - response: Response, - request: Request, - parseAs: ParseAs -): Effect.Effect, unknown> => { - if (isNoContentResponse(response, request)) { - return response.ok - ? Effect.succeed(toFetchResponse({ data: undefined, response })) - : Effect.succeed(toFetchResponse({ error: undefined, response })) - } - - if (response.ok) { - return parseByMode(response, parseAs).pipe( - Effect.map((data) => toFetchResponse({ data, response })) - ) - } - - return parseErrorBody(response).pipe( - Effect.map((error) => toFetchResponse({ error, response })) - ) -} diff --git a/packages/app/src/shell/api-client/promise-client/create-client.ts b/packages/app/src/shell/api-client/promise-client/create-client.ts deleted file mode 100644 index faaad30..0000000 --- a/packages/app/src/shell/api-client/promise-client/create-client.ts +++ /dev/null @@ -1,226 +0,0 @@ -// CHANGE: Promise-compatible client implemented via Effect internals -// WHY: Preserve openapi-fetch API shape while keeping all effect handling explicit -// SOURCE: behavior-compatible with openapi-fetch@0.15.x (MIT) -// PURITY: SHELL -// COMPLEXITY: O(1) setup + O(n) middleware per request - -import { Effect } from "effect" -import type { HttpMethod, MediaType } from "openapi-typescript-helpers" - -import { - buildMergedOptions, - type CoreFetchOptions, - type MiddlewareContext, - randomID, - resolveClientOptions, - type ResolvedClientOptions, - type ResolvedFetchOptions, - resolveFetchOptions -} from "./client-kernel.js" -import { runOnErrorMiddlewares, runOnRequestMiddlewares, runOnResponseMiddlewares } from "./client-middleware.js" -import { buildRequest } from "./client-request.js" -import { toParsedFetchResponse } from "./client-response.js" -import type { Client, ClientMethod, ClientOptions, ClientRequestMethod, FetchResponse, Middleware } from "./types.js" - -const identity = (value: A): A => value - -const toObjectRecord = (value: unknown): Record => { - return typeof value === "object" && value !== null ? (value as Record) : {} -} - -const mergeMiddlewares = ( - globalMiddlewares: ReadonlyArray, - localMiddlewares: ReadonlyArray -): Array => { - return [...globalMiddlewares, ...localMiddlewares] -} - -type ErrorRecoveryInput = { - middlewares: ReadonlyArray - context: MiddlewareContext - request: Request - error: unknown -} - -const recoverFromFetchError = (input: ErrorRecoveryInput): Effect.Effect => { - return runOnErrorMiddlewares(input.middlewares, input.context, input.request, input.error).pipe( - Effect.flatMap((handled) => { - return "response" in handled ? Effect.succeed(handled.response) : Effect.fail(handled.error) - }) - ) -} - -type FetchExecutionInput = { - middlewares: ReadonlyArray - context: MiddlewareContext - resolvedFetch: ResolvedFetchOptions - request: Request - shortCircuitResponse: Response | undefined - requestInitExt: Record | undefined -} - -const resolveFetchResponse = (input: FetchExecutionInput): Effect.Effect => { - if (input.shortCircuitResponse !== undefined) { - return Effect.succeed(input.shortCircuitResponse) - } - - return Effect.matchEffect( - Effect.tryPromise({ - try: () => input.resolvedFetch.fetch(input.request, input.requestInitExt), - catch: identity - }), - { - onFailure: (error) => - recoverFromFetchError({ - middlewares: input.middlewares, - context: input.context, - request: input.request, - error - }), - onSuccess: Effect.succeed - } - ) -} - -const resolveFinalResponse = ( - middlewares: ReadonlyArray, - context: MiddlewareContext, - request: Request, - fetchResponse: Response, - shortCircuitResponse: Response | undefined -): Effect.Effect => { - return shortCircuitResponse === undefined - ? runOnResponseMiddlewares(middlewares, context, request, fetchResponse) - : Effect.succeed(fetchResponse) -} - -const createCoreFetchEffect = ( - resolvedClient: ResolvedClientOptions, - globalMiddlewares: ReadonlyArray -) => -, Options>( - schemaPath: string, - fetchOptions?: CoreFetchOptions -): Effect.Effect, unknown> => - Effect.gen(function*() { - const resolvedFetch = resolveFetchOptions(resolvedClient, fetchOptions) - const context: MiddlewareContext = { - schemaPath, - params: resolvedFetch.params, - options: buildMergedOptions(resolvedFetch), - id: randomID() - } - - const middlewares = mergeMiddlewares(globalMiddlewares, resolvedFetch.middleware) - const request = buildRequest(resolvedClient, resolvedFetch, schemaPath) - const onRequest = yield* runOnRequestMiddlewares(middlewares, context, request) - - const fetchResponse = yield* resolveFetchResponse({ - middlewares, - context, - resolvedFetch, - request: onRequest.request, - shortCircuitResponse: onRequest.response, - requestInitExt: resolvedClient.requestInitExt - }) - - const finalResponse = yield* resolveFinalResponse( - middlewares, - context, - onRequest.request, - fetchResponse, - onRequest.response - ) - - return yield* toParsedFetchResponse( - finalResponse, - onRequest.request, - resolvedFetch.parseAs - ) - }) - -type GenericFetchResponse = FetchResponse, unknown, Media> - -const createMethodCaller = ( - callMethod: ( - method: HttpMethod, - path: keyof Paths & string, - init: ReadonlyArray - ) => globalThis.Promise>, - method: Method -): ClientMethod => { - return ((url, ...init) => { - return callMethod(method, url as keyof Paths & string, init as ReadonlyArray) - }) as ClientMethod -} - -const createCallMethod = ( - coreFetchEffect: , Options>( - schemaPath: string, - fetchOptions?: CoreFetchOptions - ) => Effect.Effect, unknown> -) => -( - method: HttpMethod, - path: string, - init: ReadonlyArray -): globalThis.Promise> => { - const fetchOptions = { - ...toObjectRecord(init[0]), - method: method.toUpperCase() - } - - return Effect.runPromise( - coreFetchEffect, unknown>( - path, - fetchOptions as CoreFetchOptions - ) - ) -} - -const createMiddlewareControls = (globalMiddlewares: Array): Pick, "use" | "eject"> => { - const use = (...middleware: ReadonlyArray): void => { - globalMiddlewares.push(...middleware) - } - - const eject = (...middleware: ReadonlyArray): void => { - for (const item of middleware) { - const index = globalMiddlewares.indexOf(item) - if (index !== -1) { - globalMiddlewares.splice(index, 1) - } - } - } - - return { use, eject } -} - -export const createClient = ( - clientOptions?: ClientOptions -): Client => { - const resolvedClient = resolveClientOptions(clientOptions) - const globalMiddlewares: Array = [] - const coreFetchEffect = createCoreFetchEffect(resolvedClient, globalMiddlewares) - const callMethod = createCallMethod(coreFetchEffect) - const controls = createMiddlewareControls(globalMiddlewares) - - const request = ((method, path, ...init) => { - return callMethod(method, path as keyof Paths & string, init as ReadonlyArray) - }) as ClientRequestMethod - - return { - request, - GET: createMethodCaller(callMethod, "get"), - PUT: createMethodCaller(callMethod, "put"), - POST: createMethodCaller(callMethod, "post"), - DELETE: createMethodCaller(callMethod, "delete"), - OPTIONS: createMethodCaller(callMethod, "options"), - HEAD: createMethodCaller(callMethod, "head"), - PATCH: createMethodCaller(callMethod, "patch"), - TRACE: createMethodCaller(callMethod, "trace"), - use: controls.use, - eject: controls.eject - } -} - -export default createClient diff --git a/packages/app/src/shell/api-client/promise-client/index.ts b/packages/app/src/shell/api-client/promise-client/index.ts deleted file mode 100644 index 506bd7d..0000000 --- a/packages/app/src/shell/api-client/promise-client/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -// CHANGE: Promise-client compatibility entrypoint -// WHY: Keep openapi-fetch-compatible public API exported from openapi-effect -// PURITY: SHELL re-export module -// COMPLEXITY: O(1) - -import type { MediaType } from "openapi-typescript-helpers" - -import { createClient } from "./create-client.js" -import { wrapAsPathBasedClient } from "./path-based.js" -import type { ClientOptions, PathBasedClient } from "./types.js" - -export { createClient, createClient as default } from "./create-client.js" -export { - createFinalURL, - createQuerySerializer, - defaultBodySerializer, - defaultPathSerializer, - mergeHeaders, - removeTrailingSlash, - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam -} from "./serialize.js" - -export { wrapAsPathBasedClient } from "./path-based.js" -export type * from "./types.js" - -export const createPathBasedClient = ( - clientOptions?: ClientOptions -): PathBasedClient => { - return wrapAsPathBasedClient(createClient(clientOptions)) -} diff --git a/packages/app/src/shell/api-client/promise-client/path-based.ts b/packages/app/src/shell/api-client/promise-client/path-based.ts deleted file mode 100644 index 788c970..0000000 --- a/packages/app/src/shell/api-client/promise-client/path-based.ts +++ /dev/null @@ -1,68 +0,0 @@ -// CHANGE: Path-based Promise client proxy -// WHY: Preserve openapi-fetch-compatible API (`wrapAsPathBasedClient` / `createPathBasedClient`) -// SOURCE: behavior aligned with openapi-fetch@0.15.x -// PURITY: SHELL -// COMPLEXITY: O(1) per property access - -import type { MediaType } from "openapi-typescript-helpers" - -import type { Client, PathBasedClient } from "./types.js" - -type Forwarder = { - GET: (...init: ReadonlyArray) => unknown - PUT: (...init: ReadonlyArray) => unknown - POST: (...init: ReadonlyArray) => unknown - DELETE: (...init: ReadonlyArray) => unknown - OPTIONS: (...init: ReadonlyArray) => unknown - HEAD: (...init: ReadonlyArray) => unknown - PATCH: (...init: ReadonlyArray) => unknown - TRACE: (...init: ReadonlyArray) => unknown -} - -const createForwarder = ( - client: Client, - url: string -): Forwarder => { - const path = url as never - - return { - GET: (...init) => client.GET(path, ...(init as never)), - PUT: (...init) => client.PUT(path, ...(init as never)), - POST: (...init) => client.POST(path, ...(init as never)), - DELETE: (...init) => client.DELETE(path, ...(init as never)), - OPTIONS: (...init) => client.OPTIONS(path, ...(init as never)), - HEAD: (...init) => client.HEAD(path, ...(init as never)), - PATCH: (...init) => client.PATCH(path, ...(init as never)), - TRACE: (...init) => client.TRACE(path, ...(init as never)) - } -} - -const createProxyHandler = ( - client: Client, - cache: Map -): ProxyHandler> => { - return { - get: (_target, property): unknown => { - if (typeof property !== "string") { - return undefined - } - - const cached = cache.get(property) - if (cached !== undefined) { - return cached - } - - const forwarder = createForwarder(client, property) - cache.set(property, forwarder) - return forwarder - } - } -} - -export const wrapAsPathBasedClient = ( - client: Client -): PathBasedClient => { - const cache = new Map() - const proxy = new Proxy>({}, createProxyHandler(client, cache)) - return proxy as unknown as PathBasedClient -} diff --git a/packages/app/src/shell/api-client/promise-client/serialize-core.ts b/packages/app/src/shell/api-client/promise-client/serialize-core.ts deleted file mode 100644 index cae36a3..0000000 --- a/packages/app/src/shell/api-client/promise-client/serialize-core.ts +++ /dev/null @@ -1,130 +0,0 @@ -// CHANGE: Request URL/body/header serialization helpers for promise-compatible client -// WHY: Keep openapi-fetch-compatible behavior while keeping functions small and testable -// SOURCE: behavior aligned with openapi-fetch@0.15.x (MIT) -// PURITY: CORE helpers -// COMPLEXITY: O(n) - -import { defaultPathSerializer } from "./serialize-path.js" -import type { HeadersOptions, QuerySerializer } from "./types.js" - -const readHeader = (headers: Headers | Record, name: string): string | undefined => { - if (headers instanceof Headers) { - return headers.get(name) ?? headers.get(name.toLowerCase()) ?? undefined - } - - return headers[name] ?? headers[name.toLowerCase()] -} - -const isUrlEncodedBody = (body: unknown): body is Record => { - if (typeof body !== "object" || body === null || Array.isArray(body)) { - return false - } - - return Object.values(body).every((value) => typeof value === "string") -} - -const toHeaderEntries = ( - headers: HeadersOptions -): Array<[string, string | number | boolean | ReadonlyArray | null | undefined]> => { - if (headers instanceof Headers) { - return [...headers.entries()] - } - - if (Array.isArray(headers)) { - return headers - } - - return Object.entries(headers) -} - -const appendHeaderValue = ( - finalHeaders: Headers, - key: string, - rawValue: string | number | boolean | ReadonlyArray | null | undefined -): void => { - if (rawValue === null) { - finalHeaders.delete(key) - return - } - - if (rawValue === undefined) { - return - } - - if (Array.isArray(rawValue)) { - for (const value of rawValue) { - finalHeaders.append(key, String(value)) - } - return - } - - finalHeaders.set(key, String(rawValue)) -} - -/** Serialize body object to string */ -export const defaultBodySerializer = ( - body: unknown, - headers?: Headers | Record -): unknown => { - let serialized: unknown - - if (body instanceof FormData) { - serialized = body - } else if ( - headers !== undefined && - readHeader(headers, "Content-Type") === "application/x-www-form-urlencoded" && - isUrlEncodedBody(body) - ) { - serialized = new URLSearchParams(body).toString() - } else { - serialized = JSON.stringify(body ?? null) - } - - return serialized -} - -/** Construct URL string from baseUrl and handle path and query params */ -export const createFinalURL = ( - pathname: string, - options: { - baseUrl: string - params: { query?: Record; path?: Record } - querySerializer: QuerySerializer> - } -): string => { - const pathURL = options.params.path === undefined - ? `${options.baseUrl}${pathname}` - : defaultPathSerializer(`${options.baseUrl}${pathname}`, options.params.path) - - let search = options.querySerializer(options.params.query ?? {}) - if (search.startsWith("?")) { - search = search.slice(1) - } - - return search.length > 0 ? `${pathURL}?${search}` : pathURL -} - -/** Merge headers a and b, with b taking priority */ -export const mergeHeaders = (...allHeaders: ReadonlyArray): Headers => { - const finalHeaders = new Headers() - - for (const headerInput of allHeaders) { - if (headerInput === undefined) { - continue - } - - for (const [key, rawValue] of toHeaderEntries(headerInput)) { - appendHeaderValue(finalHeaders, key, rawValue) - } - } - - return finalHeaders -} - -/** Remove trailing slash from url */ -export const removeTrailingSlash = (url: string): string => { - return url.endsWith("/") ? url.slice(0, -1) : url -} - -export { createQuerySerializer } from "./serialize-params.js" -export { defaultPathSerializer } from "./serialize-path.js" diff --git a/packages/app/src/shell/api-client/promise-client/serialize-params.ts b/packages/app/src/shell/api-client/promise-client/serialize-params.ts deleted file mode 100644 index b695e22..0000000 --- a/packages/app/src/shell/api-client/promise-client/serialize-params.ts +++ /dev/null @@ -1,304 +0,0 @@ -// CHANGE: Query/path serialization helpers for promise-compatible client -// WHY: Keep openapi-fetch-compatible URL behavior with lint-clean small functions -// SOURCE: behavior aligned with openapi-fetch@0.15.x (MIT) -// PURITY: CORE helpers -// COMPLEXITY: O(n) - -import { - isPrimitive, - isPrimitiveArray, - isPrimitiveRecord, - type Primitive, - type PrimitiveRecord -} from "./serialize-shared.js" -import type { QuerySerializer, QuerySerializerOptions } from "./types.js" - -type PathStyle = "simple" | "label" | "matrix" -type ObjectStyle = PathStyle | "form" | "deepObject" -type ArrayStyle = PathStyle | "form" | "spaceDelimited" | "pipeDelimited" -type Encoder = (value: string) => string - -type ObjectSerializeOptions = { - style: ObjectStyle - explode: boolean - allowReserved?: boolean -} - -type ArraySerializeOptions = { - style: ArrayStyle - explode: boolean - allowReserved?: boolean -} - -const passthroughEncoder: Encoder = (value) => value -const urlEncoder: Encoder = (value) => encodeURIComponent(value) - -const objectJoiners: Record = { - simple: ",", - label: ".", - matrix: ";", - form: "&", - deepObject: "&" -} - -const collapsedArrayJoiners: Record = { - simple: ",", - label: ",", - matrix: ",", - form: ",", - spaceDelimited: "%20", - pipeDelimited: "|" -} - -const explodedArrayJoiners: Record = { - simple: ",", - label: ".", - matrix: ";", - form: "&", - spaceDelimited: "&", - pipeDelimited: "&" -} - -const resolveEncoder = (options: { allowReserved?: boolean } | undefined): Encoder => { - return options?.allowReserved === true ? passthroughEncoder : urlEncoder -} - -function withAllowReserved( - options: Omit, - allowReserved: boolean | undefined -): ArraySerializeOptions -function withAllowReserved( - options: Omit, - allowReserved: boolean | undefined -): ObjectSerializeOptions -function withAllowReserved( - options: Omit | Omit, - allowReserved: boolean | undefined -): ArraySerializeOptions | ObjectSerializeOptions { - if (allowReserved === undefined) { - return options - } - - return { ...options, allowReserved } -} - -const renderDottedOrMatrix = (name: string, style: PathStyle | ArrayStyle | ObjectStyle, packed: string): string => { - if (style === "label") { - return `.${packed}` - } - - if (style === "matrix") { - return `;${name}=${packed}` - } - - return `${name}=${packed}` -} - -const renderCollapsedObject = (name: string, style: ObjectStyle, packed: string): string => { - if (style === "form") { - return `${name}=${packed}` - } - - if (style === "simple" || style === "deepObject") { - return packed - } - - return renderDottedOrMatrix(name, style, packed) -} - -const renderCollapsedArray = (name: string, style: ArrayStyle, packed: string): string => { - if (style === "simple") { - return packed - } - - return renderDottedOrMatrix(name, style, packed) -} - -const serializePrimitiveWithEncoder = (name: string, value: Primitive, encoder: Encoder): string => { - return `${name}=${encoder(String(value))}` -} - -const serializeObjectCollapsed = ( - name: string, - value: PrimitiveRecord, - options: ObjectSerializeOptions, - encoder: Encoder -): string => { - const flattened = Object.entries(value).flatMap(([key, currentValue]) => [ - key, - encoder(String(currentValue)) - ]) - - return renderCollapsedObject(name, options.style, flattened.join(",")) -} - -const serializeObjectExploded = ( - name: string, - value: PrimitiveRecord, - options: ObjectSerializeOptions, - encoder: Encoder -): string => { - const entries = Object.entries(value).map(([key, currentValue]) => { - const finalName = options.style === "deepObject" ? `${name}[${key}]` : key - return serializePrimitiveWithEncoder(finalName, currentValue, encoder) - }) - - const joiner = objectJoiners[options.style] - const joined = entries.join(joiner) - return options.style === "label" || options.style === "matrix" ? `${joiner}${joined}` : joined -} - -const serializeArrayCollapsed = ( - name: string, - value: ReadonlyArray, - options: ArraySerializeOptions, - encoder: Encoder -): string => { - const packed = value.map((item) => encoder(String(item))).join(collapsedArrayJoiners[options.style]) - return renderCollapsedArray(name, options.style, packed) -} - -const serializeArrayExploded = ( - name: string, - value: ReadonlyArray, - options: ArraySerializeOptions, - encoder: Encoder -): string => { - const parts = value.map((item) => { - if (options.style === "simple" || options.style === "label") { - return encoder(String(item)) - } - - return serializePrimitiveWithEncoder(name, item, encoder) - }) - - const joiner = explodedArrayJoiners[options.style] - const joined = parts.join(joiner) - return options.style === "label" || options.style === "matrix" ? `${joiner}${joined}` : joined -} - -const serializePrimitiveQuery = ( - name: string, - rawValue: unknown, - encoder: Encoder -): string | undefined => { - return isPrimitive(rawValue) ? serializePrimitiveWithEncoder(name, rawValue, encoder) : undefined -} - -const serializeArrayQuery = ( - name: string, - rawValue: unknown, - options: QuerySerializerOptions | undefined -): string | undefined => { - if (!isPrimitiveArray(rawValue) || rawValue.length === 0) { - return undefined - } - - return serializeArrayParam( - name, - rawValue, - withAllowReserved( - { - style: "form", - explode: true, - ...options?.array - }, - options?.allowReserved - ) - ) -} - -const serializeObjectQuery = ( - name: string, - rawValue: unknown, - options: QuerySerializerOptions | undefined -): string | undefined => { - if (!isPrimitiveRecord(rawValue)) { - return undefined - } - - return serializeObjectParam( - name, - rawValue, - withAllowReserved( - { - style: "deepObject", - explode: true, - ...options?.object - }, - options?.allowReserved - ) - ) -} - -const serializeQueryValue = ( - name: string, - rawValue: unknown, - options: QuerySerializerOptions | undefined -): string | undefined => { - if (rawValue === undefined || rawValue === null) { - return undefined - } - - const encoder = resolveEncoder(options) - const primitive = serializePrimitiveQuery(name, rawValue, encoder) - if (primitive !== undefined) { - return primitive - } - - const serializedArray = serializeArrayQuery(name, rawValue, options) - if (serializedArray !== undefined) { - return serializedArray - } - - return serializeObjectQuery(name, rawValue, options) -} - -/** Serialize primitive params to string */ -export const serializePrimitiveParam = ( - name: string, - value: string, - options?: { allowReserved?: boolean } -): string => { - const encoder = resolveEncoder(options) - return `${name}=${encoder(value)}` -} - -/** Serialize object param to string */ -export const serializeObjectParam = ( - name: string, - value: PrimitiveRecord, - options: ObjectSerializeOptions -): string => { - const encoder = resolveEncoder(options) - - if (options.style === "deepObject" || options.explode) { - return serializeObjectExploded(name, value, options, encoder) - } - - return serializeObjectCollapsed(name, value, options, encoder) -} - -/** Serialize array param to string */ -export const serializeArrayParam = ( - name: string, - value: ReadonlyArray, - options: ArraySerializeOptions -): string => { - const encoder = resolveEncoder(options) - return options.explode - ? serializeArrayExploded(name, value, options, encoder) - : serializeArrayCollapsed(name, value, options, encoder) -} - -/** Serialize query params to string */ -export const createQuerySerializer = ( - options?: QuerySerializerOptions -): QuerySerializer> => { - return (queryParams) => { - return Object.entries(queryParams) - .map(([name, rawValue]) => serializeQueryValue(name, rawValue, options)) - .filter((value): value is string => value !== undefined) - .join("&") - } -} diff --git a/packages/app/src/shell/api-client/promise-client/serialize-path.ts b/packages/app/src/shell/api-client/promise-client/serialize-path.ts deleted file mode 100644 index a715374..0000000 --- a/packages/app/src/shell/api-client/promise-client/serialize-path.ts +++ /dev/null @@ -1,98 +0,0 @@ -// CHANGE: OpenAPI path serializer extracted from query serializer module -// WHY: Keep files under lint line limits while preserving path-style behavior -// SOURCE: behavior aligned with openapi-fetch@0.15.x (MIT) -// PURITY: CORE helpers -// COMPLEXITY: O(n) - -import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "./serialize-params.js" -import { isPrimitive, isPrimitiveArray, isPrimitiveRecord, type Primitive } from "./serialize-shared.js" - -const PATH_PARAM_RE = /\{[^{}]+\}/g - -type PathStyle = "simple" | "label" | "matrix" - -type ParsedPath = { - name: string - explode: boolean - style: PathStyle -} - -const withStylePrefix = (style: PathStyle, value: string): string => { - if (style === "label") { - return `.${value}` - } - - if (style === "matrix") { - return `;${value}` - } - - return value -} - -const parsePathToken = (rawName: string): ParsedPath => { - const explode = rawName.endsWith("*") - const cleanName = explode ? rawName.slice(0, -1) : rawName - - if (cleanName.startsWith(".")) { - return { name: cleanName.slice(1), explode, style: "label" } - } - - if (cleanName.startsWith(";")) { - return { name: cleanName.slice(1), explode, style: "matrix" } - } - - return { name: cleanName, explode, style: "simple" } -} - -const serializePathPrimitive = (parsed: ParsedPath, value: Primitive): string => { - const encoded = encodeURIComponent(String(value)) - - if (parsed.style === "matrix") { - return withStylePrefix("matrix", serializePrimitiveParam(parsed.name, String(value))) - } - - if (parsed.style === "label") { - return withStylePrefix("label", encoded) - } - - return encoded -} - -const serializePathValue = (parsed: ParsedPath, value: unknown): string | undefined => { - if (isPrimitive(value)) { - return serializePathPrimitive(parsed, value) - } - - if (isPrimitiveArray(value)) { - return serializeArrayParam(parsed.name, value, { - style: parsed.style, - explode: parsed.explode - }) - } - - if (isPrimitiveRecord(value)) { - return serializeObjectParam(parsed.name, value, { - style: parsed.style, - explode: parsed.explode - }) - } - - return undefined -} - -/** Handle OpenAPI path serialization styles */ -export const defaultPathSerializer = (pathname: string, pathParams: Record): string => { - let nextURL = pathname - - for (const match of pathname.match(PATH_PARAM_RE) ?? []) { - const token = match.slice(1, -1) - const parsed = parsePathToken(token) - const replacement = serializePathValue(parsed, pathParams[parsed.name]) - - if (replacement !== undefined) { - nextURL = nextURL.replace(match, replacement) - } - } - - return nextURL -} diff --git a/packages/app/src/shell/api-client/promise-client/serialize-shared.ts b/packages/app/src/shell/api-client/promise-client/serialize-shared.ts deleted file mode 100644 index edb2dcf..0000000 --- a/packages/app/src/shell/api-client/promise-client/serialize-shared.ts +++ /dev/null @@ -1,24 +0,0 @@ -// CHANGE: Shared primitive guards for serializer modules -// WHY: Avoid duplicate guard logic across query/path serializers -// SOURCE: n/a -// PURITY: CORE helpers -// COMPLEXITY: O(n) - -export type Primitive = string | number | boolean -export type PrimitiveRecord = Record - -export const isPrimitive = (value: unknown): value is Primitive => { - return typeof value === "string" || typeof value === "number" || typeof value === "boolean" -} - -export const isPrimitiveArray = (value: unknown): value is ReadonlyArray => { - return Array.isArray(value) && value.every((item) => isPrimitive(item)) -} - -export const isPrimitiveRecord = (value: unknown): value is PrimitiveRecord => { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return false - } - - return Object.values(value).every((item) => isPrimitive(item)) -} diff --git a/packages/app/src/shell/api-client/promise-client/serialize.ts b/packages/app/src/shell/api-client/promise-client/serialize.ts deleted file mode 100644 index b627530..0000000 --- a/packages/app/src/shell/api-client/promise-client/serialize.ts +++ /dev/null @@ -1,16 +0,0 @@ -// CHANGE: Serializer module split into focused helpers -// WHY: Keep openapi-fetch-compatible API while satisfying strict lint limits -// SOURCE: n/a -// PURITY: CORE (re-export) -// COMPLEXITY: O(1) - -export { - createFinalURL, - createQuerySerializer, - defaultBodySerializer, - defaultPathSerializer, - mergeHeaders, - removeTrailingSlash -} from "./serialize-core.js" - -export { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "./serialize-params.js" diff --git a/packages/app/src/shell/api-client/promise-client/types.ts b/packages/app/src/shell/api-client/promise-client/types.ts deleted file mode 100644 index a0d3d42..0000000 --- a/packages/app/src/shell/api-client/promise-client/types.ts +++ /dev/null @@ -1,295 +0,0 @@ -// CHANGE: Promise-client compatibility types without dependency on openapi-fetch -// WHY: Keep drop-in public signatures while preserving repo lint constraints -// SOURCE: API shape is compatible with openapi-fetch@0.15.x (MIT) -// PURITY: CORE (types only) -// COMPLEXITY: O(1) - -import type { - ErrorResponse, - HttpMethod, - IsOperationRequestBodyOptional, - MediaType, - OperationRequestBodyContent, - PathsWithMethod, - RequiredKeysOf, - ResponseObjectMap, - SuccessResponse -} from "openapi-typescript-helpers" - -/** Options for each client instance */ -export interface ClientOptions extends Omit { - /** set the common root URL for all API requests */ - baseUrl?: string - /** custom fetch (defaults to globalThis.fetch) */ - fetch?: (input: Request) => globalThis.Promise - /** custom Request (defaults to globalThis.Request) */ - Request?: typeof Request - /** global querySerializer */ - querySerializer?: QuerySerializer | QuerySerializerOptions - /** global bodySerializer */ - bodySerializer?: BodySerializer - headers?: HeadersOptions - /** RequestInit extension object to pass as 2nd argument to fetch when supported (defaults to undefined) */ - requestInitExt?: Record -} - -export type HeadersOptions = - | Required["headers"] - | Record< - string, - | string - | number - | boolean - | ReadonlyArray - | null - | undefined - > - -export type QuerySerializer = ( - query: T extends { parameters: Record } ? NonNullable - : Record -) => string - -/** @see https://swagger.io/docs/specification/serialization/#query */ -export type QuerySerializerOptions = { - /** Set serialization for arrays. @see https://swagger.io/docs/specification/serialization/#query */ - array?: { - /** default: "form" */ - style: "form" | "spaceDelimited" | "pipeDelimited" - /** default: true */ - explode: boolean - } - /** Set serialization for objects. @see https://swagger.io/docs/specification/serialization/#query */ - object?: { - /** default: "deepObject" */ - style: "form" | "deepObject" - /** default: true */ - explode: boolean - } - /** - * The `allowReserved` keyword specifies whether the reserved characters - * `:/?#[]@!$&'()*+,;=` in parameter values are allowed to be sent as they - * are, or should be percent-encoded. By default, allowReserved is `false`, - * and reserved characters are percent-encoded. - * @see https://swagger.io/docs/specification/serialization/#query - */ - allowReserved?: boolean -} - -export type BodySerializer = ( - body: OperationRequestBodyContent, - headers?: Headers | Record -) => unknown - -type BodyType = { - json: T - text: Awaited> - blob: Awaited> - arrayBuffer: Awaited> - stream: Response["body"] -} - -export type ParseAs = keyof BodyType - -export type ParseAsResponse = Options extends { parseAs: ParseAs } ? BodyType[Options["parseAs"]] - : T - -export interface DefaultParamsOption { - params?: { - query?: Record - } -} - -export type ParamsOption = T extends { parameters: Record } - ? RequiredKeysOf extends never ? { params?: T["parameters"] } - : { params: T["parameters"] } - : DefaultParamsOption - -export type RequestBodyOption = OperationRequestBodyContent extends never ? { body?: never } - : IsOperationRequestBodyOptional extends true ? { body?: OperationRequestBodyContent } - : { body: OperationRequestBodyContent } - -export type RequestOptions = - & ParamsOption - & RequestBodyOption - & { - baseUrl?: string - querySerializer?: QuerySerializer | QuerySerializerOptions - bodySerializer?: BodySerializer - parseAs?: ParseAs - fetch?: ClientOptions["fetch"] - headers?: HeadersOptions - middleware?: ReadonlyArray - } - -export type FetchOptions = RequestOptions & Omit - -export type FetchResponse< - T extends Record, - Options, - Media extends MediaType -> = - | { - data: ParseAsResponse, Media>, Options> - error?: never - response: Response - } - | { - data?: never - error: ErrorResponse, Media> - response: Response - } - -export type MergedOptions = { - baseUrl: string - parseAs: ParseAs - querySerializer: QuerySerializer - bodySerializer: BodySerializer - fetch: typeof globalThis.fetch -} - -export interface MiddlewareCallbackParams { - /** Current Request object */ - request: Request - /** The original OpenAPI schema path (including curly braces) */ - readonly schemaPath: string - /** OpenAPI parameters as provided from openapi-fetch */ - readonly params: { - query?: Record - header?: Record - path?: Record - cookie?: Record - } - /** Unique ID for this request */ - readonly id: string - /** createClient options (read-only) */ - readonly options: MergedOptions -} - -export type MiddlewareOnRequest = ( - options: MiddlewareCallbackParams -) => - | Request - | Response - | undefined - | globalThis.Promise - -export type MiddlewareOnResponse = ( - options: MiddlewareCallbackParams & { response: Response } -) => - | Response - | undefined - | globalThis.Promise - -export type MiddlewareOnError = ( - options: MiddlewareCallbackParams & { error: unknown } -) => - | Response - | Error - | undefined - | globalThis.Promise - -export type Middleware = - | { - onRequest: MiddlewareOnRequest - onResponse?: MiddlewareOnResponse - onError?: MiddlewareOnError - } - | { - onRequest?: MiddlewareOnRequest - onResponse: MiddlewareOnResponse - onError?: MiddlewareOnError - } - | { - onRequest?: MiddlewareOnRequest - onResponse?: MiddlewareOnResponse - onError: MiddlewareOnError - } - -type OperationForLocation = Params extends Record - ? Operation - : never - -type OperationForPathMethod< - Paths extends object, - Path extends keyof Paths, - Method extends HttpMethod -> = OperationForLocation & Record - -/** 2nd param is required only when params/requestBody are required */ -export type MaybeOptionalInit = - RequiredKeysOf>> extends never - ? FetchOptions> | undefined - : FetchOptions> - -// The final init param to accept. -// - Determines if the param is optional or not. -// - Performs arbitrary [key: string] addition. -// Note: the addition MUST happen after all inference happens. -export type InitParam = RequiredKeysOf extends never ? [(Init & Record)?] - : [Init & Record] - -export type ClientMethod = < - Path extends PathsWithMethod, - Init extends MaybeOptionalInit ->( - url: Path, - ...init: InitParam -) => globalThis.Promise, Init, Media>> - -export type ClientRequestMethod = < - Method extends HttpMethod, - Path extends PathsWithMethod, - Init extends MaybeOptionalInit ->( - method: Method, - url: Path, - ...init: InitParam -) => globalThis.Promise, Init, Media>> - -export type ClientForPath, Media extends MediaType> = { - [Method in keyof PathInfo as Uppercase]: >( - ...init: InitParam - ) => globalThis.Promise, Init, Media>> -} - -export interface Client { - request: ClientRequestMethod - /** Call a GET endpoint */ - GET: ClientMethod - /** Call a PUT endpoint */ - PUT: ClientMethod - /** Call a POST endpoint */ - POST: ClientMethod - /** Call a DELETE endpoint */ - DELETE: ClientMethod - /** Call a OPTIONS endpoint */ - OPTIONS: ClientMethod - /** Call a HEAD endpoint */ - HEAD: ClientMethod - /** Call a PATCH endpoint */ - PATCH: ClientMethod - /** Call a TRACE endpoint */ - TRACE: ClientMethod - /** Register middleware */ - use(...middleware: ReadonlyArray): void - /** Unregister middleware */ - eject(...middleware: ReadonlyArray): void -} - -export type ClientPathsWithMethod, Method extends HttpMethod> = - CreatedClient extends Client ? PathsWithMethod - : never - -export type MethodResponse< - CreatedClient extends Client, - Method extends HttpMethod, - Path extends ClientPathsWithMethod, - Options = object -> = CreatedClient extends Client - ? NonNullable, Options, Media>["data"]> - : never - -export type PathBasedClient = { - [Path in keyof Paths]: ClientForPath, Media> -} From 06c4f1abc0df079e57d0b8c9cf0cc39d71660577 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:56:06 +0000 Subject: [PATCH 5/6] fix(app): make openapi-fetch input compat and return Effect --- packages/app/src/index.ts | 12 +- .../api-client/create-client-middleware.ts | 179 ++++++ .../api-client/create-client-response.ts | 115 ++++ .../create-client-runtime-helpers.ts | 142 +++++ .../api-client/create-client-runtime-types.ts | 97 +++ .../shell/api-client/create-client-runtime.ts | 314 ++++++++++ .../shell/api-client/create-client-types.ts | 558 ++++++++--------- .../app/src/shell/api-client/create-client.ts | 585 +++--------------- packages/app/src/shell/api-client/index.ts | 29 +- .../shell/api-client/openapi-compat-path.ts | 82 +++ .../api-client/openapi-compat-request.ts | 153 +++++ .../api-client/openapi-compat-serializers.ts | 277 +++++++++ .../shell/api-client/openapi-compat-utils.ts | 10 + .../api-client/openapi-compat-value-guards.ts | 9 + .../create-client-dispatchers.test.ts | 106 ++-- .../create-client-effect-integration.test.ts | 207 +++---- .../api-client/create-client-effect.test.ts | 307 +++------ packages/app/tsconfig.json | 1 - 18 files changed, 1962 insertions(+), 1221 deletions(-) create mode 100644 packages/app/src/shell/api-client/create-client-middleware.ts create mode 100644 packages/app/src/shell/api-client/create-client-response.ts create mode 100644 packages/app/src/shell/api-client/create-client-runtime-helpers.ts create mode 100644 packages/app/src/shell/api-client/create-client-runtime-types.ts create mode 100644 packages/app/src/shell/api-client/create-client-runtime.ts create mode 100644 packages/app/src/shell/api-client/openapi-compat-path.ts create mode 100644 packages/app/src/shell/api-client/openapi-compat-request.ts create mode 100644 packages/app/src/shell/api-client/openapi-compat-serializers.ts create mode 100644 packages/app/src/shell/api-client/openapi-compat-utils.ts create mode 100644 packages/app/src/shell/api-client/openapi-compat-value-guards.ts diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index ca17333..1879070 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -22,16 +22,26 @@ export { createClient, createClientEffect, createDispatcher, + createFinalURL, + createPathBasedClient, + createQuerySerializer, createStrictClient, createUniversalDispatcher, + defaultBodySerializer, + defaultPathSerializer, executeRequest, + mergeHeaders, parseJSON, registerDefaultDispatchers, + removeTrailingSlash, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, unexpectedContentType, unexpectedStatus } from "./shell/api-client/index.js" -export { createClientEffect as default } from "./shell/api-client/index.js" +export { createClient as default } from "./shell/api-client/index.js" // Generated dispatchers (auto-generated from OpenAPI schema) export * from "./generated/index.js" diff --git a/packages/app/src/shell/api-client/create-client-middleware.ts b/packages/app/src/shell/api-client/create-client-middleware.ts new file mode 100644 index 0000000..6694c05 --- /dev/null +++ b/packages/app/src/shell/api-client/create-client-middleware.ts @@ -0,0 +1,179 @@ +import { Effect } from "effect" + +import { toError } from "./create-client-response.js" +import type { AsyncValue, MergedOptions, Middleware, MiddlewareRequestParams, Thenable } from "./create-client-types.js" + +const isThenable = (value: unknown): value is Thenable => ( + typeof value === "object" + && value !== null + && "then" in value + && typeof Reflect.get(value, "then") === "function" +) + +export const toPromiseEffect = (value: AsyncValue): Effect.Effect => ( + isThenable(value) + ? Effect.async((resume) => { + value.then( + (result) => { + resume(Effect.succeed(result)) + }, + (error) => { + resume(Effect.fail(toError(error))) + } + ) + }) + : Effect.succeed(value) +) + +export type MiddlewareContext = { + schemaPath: string + params: MiddlewareRequestParams + options: MergedOptions + id: string + middleware: Array +} + +const reverseMiddleware = (middleware: Array): Array => { + const output: Array = [] + + for (let index = middleware.length - 1; index >= 0; index -= 1) { + const item = middleware[index] + if (item !== undefined) { + output.push(item) + } + } + + return output +} + +type RequestMiddlewareResult = { + request: Request + response?: Response +} + +const createMiddlewareParams = ( + request: Request, + context: MiddlewareContext +): { + request: Request + schemaPath: string + params: MiddlewareRequestParams + options: MergedOptions + id: string +} => ({ + request, + schemaPath: context.schemaPath, + params: context.params, + options: context.options, + id: context.id +}) + +export const applyRequestMiddleware = ( + request: Request, + context: MiddlewareContext +): Effect.Effect => + Effect.gen(function*() { + let nextRequest = request + + for (const item of context.middleware) { + if (typeof item.onRequest !== "function") { + continue + } + + const result = yield* toPromiseEffect(item.onRequest(createMiddlewareParams(nextRequest, context))) + + if (result === undefined) { + continue + } + + if (result instanceof Request) { + nextRequest = result + continue + } + + if (result instanceof Response) { + return { request: nextRequest, response: result } + } + + return yield* Effect.fail( + new Error("onRequest: must return new Request() or Response() when modifying the request") + ) + } + + return { request: nextRequest } + }) + +export const applyResponseMiddleware = ( + request: Request, + response: Response, + context: MiddlewareContext +): Effect.Effect => + Effect.gen(function*() { + let nextResponse = response + + for (const item of reverseMiddleware(context.middleware)) { + if (typeof item.onResponse !== "function") { + continue + } + + const result = yield* toPromiseEffect(item.onResponse({ + ...createMiddlewareParams(request, context), + response: nextResponse + })) + + if (result === undefined) { + continue + } + + if (!(result instanceof Response)) { + return yield* Effect.fail( + new Error("onResponse: must return new Response() when modifying the response") + ) + } + + nextResponse = result + } + + return nextResponse + }) + +const normalizeErrorResult = ( + result: Response | Error | undefined +): Effect.Effect => { + if (result === undefined || result instanceof Response || result instanceof Error) { + return Effect.succeed(result) + } + + return Effect.fail(new Error("onError: must return new Response() or instance of Error")) +} + +export const applyErrorMiddleware = ( + request: Request, + fetchError: Error, + context: MiddlewareContext +): Effect.Effect => + Effect.gen(function*() { + let nextError: Error = fetchError + + for (const item of reverseMiddleware(context.middleware)) { + if (typeof item.onError !== "function") { + continue + } + + const rawResult = yield* toPromiseEffect(item.onError({ + ...createMiddlewareParams(request, context), + error: nextError + })) + + const result = yield* normalizeErrorResult(rawResult) + if (result instanceof Response) { + return result + } + + if (result instanceof Error) { + nextError = result + } + } + + return yield* Effect.fail(nextError) + }) diff --git a/packages/app/src/shell/api-client/create-client-response.ts b/packages/app/src/shell/api-client/create-client-response.ts new file mode 100644 index 0000000..58601ae --- /dev/null +++ b/packages/app/src/shell/api-client/create-client-response.ts @@ -0,0 +1,115 @@ +import { Effect } from "effect" + +import { asJson } from "../../core/axioms.js" +import type { ParseAs } from "./create-client-types.js" + +type RuntimeFetchResponse = { + data?: unknown + error?: unknown + response: Response +} + +export const toError = (error: unknown): Error => ( + error instanceof Error ? error : new Error(String(error)) +) + +const parseJsonText = (rawText: string): Effect.Effect => ( + rawText.length === 0 + ? Effect.void + : Effect.try({ + try: () => asJson(JSON.parse(rawText)), + catch: toError + }) +) + +const readResponseText = (response: Response): Effect.Effect => ( + Effect.tryPromise({ + try: () => response.text(), + catch: toError + }) +) + +const parseSuccessData = ( + response: Response, + parseAs: ParseAs, + contentLength: string | null +): Effect.Effect => { + if (parseAs === "stream") { + return Effect.succeed(response.body) + } + + if (parseAs === "text") { + return Effect.tryPromise({ try: () => response.text(), catch: toError }) + } + + if (parseAs === "blob") { + return Effect.tryPromise({ try: () => response.blob(), catch: toError }) + } + + if (parseAs === "arrayBuffer") { + return Effect.tryPromise({ try: () => response.arrayBuffer(), catch: toError }) + } + + if (contentLength === null) { + return readResponseText(response).pipe( + Effect.flatMap((rawText) => parseJsonText(rawText)) + ) + } + + return Effect.tryPromise({ try: () => response.json(), catch: toError }) +} + +const parseErrorData = (response: Response): Effect.Effect => ( + readResponseText(response).pipe( + Effect.flatMap((rawText) => + Effect.match( + Effect.try({ + try: () => asJson(JSON.parse(rawText)), + catch: toError + }), + { + onFailure: () => rawText, + onSuccess: (parsed) => parsed + } + ) + ) + ) +) + +const hasChunkedTransferEncoding = (response: Response): boolean => ( + response.headers.get("Transfer-Encoding")?.includes("chunked") === true +) + +const isEmptyResponse = ( + request: Request, + response: Response, + contentLength: string | null +): boolean => ( + response.status === 204 + || request.method === "HEAD" + || (contentLength === "0" && !hasChunkedTransferEncoding(response)) +) + +export const createResponseEnvelope = ( + request: Request, + response: Response, + parseAs: ParseAs +): Effect.Effect => { + const contentLength = response.headers.get("Content-Length") + + if (isEmptyResponse(request, response, contentLength)) { + return response.ok + ? Effect.succeed({ data: undefined, response }) + : Effect.succeed({ error: undefined, response }) + } + + if (response.ok) { + return parseSuccessData(response, parseAs, contentLength).pipe( + Effect.map((data) => ({ data, response })) + ) + } + + return parseErrorData(response).pipe( + Effect.map((error) => ({ error, response })) + ) +} diff --git a/packages/app/src/shell/api-client/create-client-runtime-helpers.ts b/packages/app/src/shell/api-client/create-client-runtime-helpers.ts new file mode 100644 index 0000000..0bb36c4 --- /dev/null +++ b/packages/app/src/shell/api-client/create-client-runtime-helpers.ts @@ -0,0 +1,142 @@ +import { Effect } from "effect" + +import { asStrictApiClient } from "../../core/axioms.js" +import { toError } from "./create-client-response.js" +import type { FetchWithRequestInitExt, HeaderRecord } from "./create-client-runtime-types.js" +import type { + BodySerializer, + ClientOptions, + MergedOptions, + MiddlewareRequestParams, + ParseAs, + PathSerializer, + QuerySerializer, + QuerySerializerOptions +} from "./create-client-types.js" +import { createQuerySerializer } from "./openapi-compat-utils.js" + +export const supportsRequestInitExt = (): boolean => ( + typeof process === "object" + && Number.parseInt(process.versions.node.slice(0, 2), 10) >= 18 + && typeof process.versions["undici"] === "string" +) + +export const randomID = (): string => ( + globalThis.crypto.randomUUID().replaceAll("-", "").slice(0, 9) +) + +const isQuerySerializerOptions = ( + value: QuerySerializer | QuerySerializerOptions | undefined +): value is QuerySerializerOptions => ( + value !== undefined && typeof value === "object" +) + +export const resolveQuerySerializer = ( + globalQuerySerializer: ClientOptions["querySerializer"], + requestQuerySerializer: QuerySerializer | QuerySerializerOptions | undefined +): QuerySerializer => { + let serializer = typeof globalQuerySerializer === "function" + ? globalQuerySerializer + : createQuerySerializer(globalQuerySerializer) + + if (requestQuerySerializer) { + serializer = typeof requestQuerySerializer === "function" + ? requestQuerySerializer + : createQuerySerializer({ + ...(isQuerySerializerOptions(globalQuerySerializer) ? globalQuerySerializer : {}), + ...requestQuerySerializer + }) + } + + return serializer +} + +const isHeaderPrimitive = (value: unknown): value is string | number | boolean => ( + typeof value === "string" || typeof value === "number" || typeof value === "boolean" +) + +export const toHeaderOverrides = (headers: MiddlewareRequestParams["header"]): HeaderRecord => { + if (headers === undefined) { + return {} + } + + const normalized: HeaderRecord = {} + for (const [key, rawValue] of Object.entries(headers)) { + if (rawValue === undefined || rawValue === null || isHeaderPrimitive(rawValue)) { + normalized[key] = rawValue + continue + } + + if (Array.isArray(rawValue)) { + normalized[key] = rawValue.filter((item) => isHeaderPrimitive(item)) + } + } + + return normalized +} + +const isBodyInit = (value: BodyInit | object): value is BodyInit => ( + typeof value === "string" + || value instanceof Blob + || value instanceof URLSearchParams + || value instanceof ArrayBuffer + || value instanceof FormData + || value instanceof ReadableStream +) + +export type SerializedBody = + | { hasBody: false } + | { hasBody: true; value: BodyInit } + +export const serializeBody = ( + body: BodyInit | object | undefined, + serializer: BodySerializer, + headers: Headers +): SerializedBody => { + if (body === undefined) { + return { hasBody: false } + } + + if (isBodyInit(body)) { + return { hasBody: true, value: body } + } + + return { hasBody: true, value: serializer(body, headers) } +} + +export const setCustomRequestFields = (request: Request, init: Record): void => { + for (const key in init) { + if (!(key in request)) { + Reflect.set(request, key, init[key]) + } + } +} + +export const invokeFetch = ( + fetch: NonNullable, + request: Request, + requestInitExt?: Record +): Effect.Effect => { + const fetchWithExt = asStrictApiClient(fetch) + return Effect.tryPromise({ + try: () => fetchWithExt(request, requestInitExt), + catch: toError + }) +} + +export const createMergedOptions = (options: { + baseUrl: string + parseAs: ParseAs + querySerializer: QuerySerializer + bodySerializer: BodySerializer + pathSerializer: PathSerializer + fetch: NonNullable +}): MergedOptions => + Object.freeze({ + baseUrl: options.baseUrl, + parseAs: options.parseAs, + querySerializer: options.querySerializer, + bodySerializer: options.bodySerializer, + pathSerializer: options.pathSerializer, + fetch: asStrictApiClient(options.fetch) + }) diff --git a/packages/app/src/shell/api-client/create-client-runtime-types.ts b/packages/app/src/shell/api-client/create-client-runtime-types.ts new file mode 100644 index 0000000..51304e6 --- /dev/null +++ b/packages/app/src/shell/api-client/create-client-runtime-types.ts @@ -0,0 +1,97 @@ +import type { Effect } from "effect" + +import type { MiddlewareContext } from "./create-client-middleware.js" +import type { + BodySerializer, + ClientOptions, + HeadersOptions, + Middleware, + MiddlewareRequestParams, + ParseAs, + PathSerializer, + QuerySerializer, + QuerySerializerOptions +} from "./create-client-types.js" + +export type RuntimeFetchResponse = { + data?: unknown + error?: unknown + response: Response +} + +export type RuntimeFetchOptions = Omit & { + baseUrl?: string + fetch?: NonNullable + Request?: ClientOptions["Request"] + headers?: HeadersOptions + params?: MiddlewareRequestParams + parseAs?: ParseAs + querySerializer?: QuerySerializer | QuerySerializerOptions + pathSerializer?: PathSerializer + bodySerializer?: BodySerializer + body?: BodyInit | object + middleware?: Array + method?: string + [key: string]: unknown +} + +export type RuntimeClient = { + request: (method: string, url: string, init?: RuntimeFetchOptions) => Effect.Effect + GET: (url: string, init?: RuntimeFetchOptions) => Effect.Effect + PUT: (url: string, init?: RuntimeFetchOptions) => Effect.Effect + POST: (url: string, init?: RuntimeFetchOptions) => Effect.Effect + DELETE: (url: string, init?: RuntimeFetchOptions) => Effect.Effect + OPTIONS: (url: string, init?: RuntimeFetchOptions) => Effect.Effect + HEAD: (url: string, init?: RuntimeFetchOptions) => Effect.Effect + PATCH: (url: string, init?: RuntimeFetchOptions) => Effect.Effect + TRACE: (url: string, init?: RuntimeFetchOptions) => Effect.Effect + use: (...middleware: Array) => void + eject: (...middleware: Array) => void +} + +export type HeaderValue = + | string + | number + | boolean + | Array + | null + | undefined + +export type HeaderRecord = Record + +export type BaseRuntimeConfig = { + Request: typeof Request + baseUrl: string + bodySerializer: BodySerializer | undefined + fetch: NonNullable + pathSerializer: PathSerializer | undefined + headers: HeadersOptions | undefined + querySerializer: QuerySerializer | QuerySerializerOptions | undefined + requestInitExt: Record | undefined + baseOptions: Omit< + ClientOptions, + | "Request" + | "baseUrl" + | "bodySerializer" + | "fetch" + | "headers" + | "querySerializer" + | "pathSerializer" + | "requestInitExt" + > + globalMiddlewares: Array +} + +export type PreparedRequest = { + request: Request + fetch: NonNullable + parseAs: ParseAs + context: MiddlewareContext + middleware: Array + requestInitExt: Record | undefined +} + +export type FetchWithRequestInitExt = ( + input: Request, + requestInitExt?: Record +) => ReturnType diff --git a/packages/app/src/shell/api-client/create-client-runtime.ts b/packages/app/src/shell/api-client/create-client-runtime.ts new file mode 100644 index 0000000..8e96fb5 --- /dev/null +++ b/packages/app/src/shell/api-client/create-client-runtime.ts @@ -0,0 +1,314 @@ +import { Effect } from "effect" + +import { applyErrorMiddleware, applyRequestMiddleware, applyResponseMiddleware } from "./create-client-middleware.js" +import { createResponseEnvelope } from "./create-client-response.js" +import { + createMergedOptions, + invokeFetch, + randomID, + resolveQuerySerializer, + serializeBody, + setCustomRequestFields, + supportsRequestInitExt, + toHeaderOverrides +} from "./create-client-runtime-helpers.js" +import type { SerializedBody } from "./create-client-runtime-helpers.js" +import type { + BaseRuntimeConfig, + PreparedRequest, + RuntimeClient, + RuntimeFetchOptions, + RuntimeFetchResponse +} from "./create-client-runtime-types.js" +import type { + BodySerializer, + ClientOptions, + Middleware, + MiddlewareRequestParams, + ParseAs, + PathSerializer, + QuerySerializer +} from "./create-client-types.js" +import { + createFinalURL, + defaultBodySerializer, + defaultPathSerializer, + mergeHeaders, + removeTrailingSlash +} from "./openapi-compat-utils.js" + +type ResolvedFetchConfig = { + Request: typeof Request + fetch: NonNullable + parseAs: ParseAs + params: MiddlewareRequestParams + body: BodyInit | object | undefined + bodySerializer: BodySerializer + headers: ClientOptions["headers"] + init: Record + finalBaseUrl: string + pathSerializer: PathSerializer + querySerializer: QuerySerializer + middleware: Array +} + +const resolveBaseUrl = (baseUrl: string, localBaseUrl: string | undefined): string => ( + localBaseUrl ? removeTrailingSlash(localBaseUrl) : baseUrl +) + +const resolveBodySerializer = ( + globalBodySerializer: BodySerializer | undefined, + requestBodySerializer: BodySerializer | undefined +): BodySerializer => ( + requestBodySerializer ?? globalBodySerializer ?? defaultBodySerializer +) + +const resolvePathSerializer = ( + globalPathSerializer: PathSerializer | undefined, + requestPathSerializer: PathSerializer | undefined +): PathSerializer => ( + requestPathSerializer ?? globalPathSerializer ?? defaultPathSerializer +) + +const joinMiddleware = ( + globalMiddlewares: Array, + requestMiddlewares: Array +): Array => [...globalMiddlewares, ...requestMiddlewares] + +const resolveFetchConfig = ( + config: BaseRuntimeConfig, + fetchOptions?: RuntimeFetchOptions +): ResolvedFetchConfig => { + const { + Request = config.Request, + baseUrl: localBaseUrl, + body, + bodySerializer: requestBodySerializer, + fetch = config.fetch, + headers, + middleware: requestMiddlewares = [], + params = {}, + parseAs = "json", + pathSerializer: requestPathSerializer, + querySerializer: requestQuerySerializer, + ...init + } = fetchOptions ?? {} + + return { + Request, + fetch, + parseAs, + params, + body, + bodySerializer: resolveBodySerializer(config.bodySerializer, requestBodySerializer), + headers, + init, + finalBaseUrl: resolveBaseUrl(config.baseUrl, localBaseUrl), + pathSerializer: resolvePathSerializer(config.pathSerializer, requestPathSerializer), + querySerializer: resolveQuerySerializer(config.querySerializer, requestQuerySerializer), + middleware: joinMiddleware(config.globalMiddlewares, requestMiddlewares) + } +} + +type ResolvedHeaders = { + serializedBody: SerializedBody + finalHeaders: Headers +} + +const resolveHeaders = ( + config: BaseRuntimeConfig, + resolved: ResolvedFetchConfig +): ResolvedHeaders => { + const headerOverrides = toHeaderOverrides(resolved.params.header) + const serializedBody = serializeBody( + resolved.body, + resolved.bodySerializer, + mergeHeaders(config.headers, resolved.headers, headerOverrides) + ) + + const finalHeaders = mergeHeaders( + !serializedBody.hasBody || serializedBody.value instanceof FormData + ? {} + : { "Content-Type": "application/json" }, + config.headers, + resolved.headers, + headerOverrides + ) + + return { serializedBody, finalHeaders } +} + +const createRequest = ( + config: BaseRuntimeConfig, + schemaPath: string, + resolved: ResolvedFetchConfig, + resolvedHeaders: ResolvedHeaders +): Request => { + const requestInit: RequestInit = { + redirect: "follow", + ...config.baseOptions, + ...resolved.init, + ...(resolvedHeaders.serializedBody.hasBody + ? { body: resolvedHeaders.serializedBody.value } + : {}), + headers: resolvedHeaders.finalHeaders + } + + const request = new resolved.Request( + createFinalURL(schemaPath, { + baseUrl: resolved.finalBaseUrl, + params: resolved.params, + querySerializer: resolved.querySerializer, + pathSerializer: resolved.pathSerializer + }), + requestInit + ) + + setCustomRequestFields(request, resolved.init) + return request +} + +const createPreparedContext = ( + schemaPath: string, + resolved: ResolvedFetchConfig +): PreparedRequest["context"] => ({ + schemaPath, + params: resolved.params, + id: randomID(), + options: createMergedOptions({ + baseUrl: resolved.finalBaseUrl, + parseAs: resolved.parseAs, + querySerializer: resolved.querySerializer, + bodySerializer: resolved.bodySerializer, + pathSerializer: resolved.pathSerializer, + fetch: resolved.fetch + }), + middleware: resolved.middleware +}) + +const prepareRequest = ( + config: BaseRuntimeConfig, + schemaPath: string, + fetchOptions?: RuntimeFetchOptions +): PreparedRequest => { + const resolved = resolveFetchConfig(config, fetchOptions) + const requestHeaders = resolveHeaders(config, resolved) + const request = createRequest(config, schemaPath, resolved, requestHeaders) + + return { + request, + fetch: resolved.fetch, + parseAs: resolved.parseAs, + middleware: resolved.middleware, + requestInitExt: config.requestInitExt, + context: createPreparedContext(schemaPath, resolved) + } +} + +const executeFetch = ( + prepared: PreparedRequest +): Effect.Effect<{ request: Request; response: Response }, Error> => { + if (prepared.middleware.length === 0) { + return invokeFetch(prepared.fetch, prepared.request, prepared.requestInitExt).pipe( + Effect.map((response) => ({ request: prepared.request, response })) + ) + } + + return Effect.gen(function*() { + const requestPhase = yield* applyRequestMiddleware(prepared.request, prepared.context) + const request = requestPhase.request + + const response = requestPhase.response ?? ( + yield* Effect.catchAll( + invokeFetch(prepared.fetch, request, prepared.requestInitExt), + (fetchError) => applyErrorMiddleware(request, fetchError, prepared.context) + ) + ) + + const responseAfterMiddleware = yield* applyResponseMiddleware(request, response, prepared.context) + return { request, response: responseAfterMiddleware } + }) +} + +const createCoreFetch = (config: BaseRuntimeConfig) => +( + schemaPath: string, + fetchOptions?: RuntimeFetchOptions +): Effect.Effect => + Effect.gen(function*() { + const prepared = prepareRequest(config, schemaPath, fetchOptions) + const execution = yield* executeFetch(prepared) + return yield* createResponseEnvelope(execution.request, execution.response, prepared.parseAs) + }) + +const hasMiddlewareHook = (value: Middleware): boolean => ( + "onRequest" in value || "onResponse" in value || "onError" in value +) + +const createBaseRuntimeConfig = ( + clientOptions: ClientOptions | undefined, + globalMiddlewares: Array +): BaseRuntimeConfig => { + const { + Request = globalThis.Request, + baseUrl: rawBaseUrl = "", + bodySerializer, + fetch = globalThis.fetch, + headers, + pathSerializer, + querySerializer, + requestInitExt: rawRequestInitExt, + ...baseOptions + } = { ...clientOptions } + + return { + Request, + baseUrl: removeTrailingSlash(rawBaseUrl), + bodySerializer, + fetch, + headers, + pathSerializer, + querySerializer, + requestInitExt: supportsRequestInitExt() ? rawRequestInitExt : undefined, + baseOptions, + globalMiddlewares + } +} + +const createClientMethods = ( + coreFetch: ReturnType, + globalMiddlewares: Array +): RuntimeClient => ({ + request: (method, url, init) => coreFetch(url, { ...init, method: method.toUpperCase() }), + GET: (url, init) => coreFetch(url, { ...init, method: "GET" }), + PUT: (url, init) => coreFetch(url, { ...init, method: "PUT" }), + POST: (url, init) => coreFetch(url, { ...init, method: "POST" }), + DELETE: (url, init) => coreFetch(url, { ...init, method: "DELETE" }), + OPTIONS: (url, init) => coreFetch(url, { ...init, method: "OPTIONS" }), + HEAD: (url, init) => coreFetch(url, { ...init, method: "HEAD" }), + PATCH: (url, init) => coreFetch(url, { ...init, method: "PATCH" }), + TRACE: (url, init) => coreFetch(url, { ...init, method: "TRACE" }), + use: (...middleware) => { + for (const item of middleware) { + if (!hasMiddlewareHook(item)) { + throw new Error("Middleware must be an object with one of `onRequest()`, `onResponse() or `onError()`") + } + globalMiddlewares.push(item) + } + }, + eject: (...middleware) => { + for (const item of middleware) { + const index = globalMiddlewares.indexOf(item) + if (index !== -1) { + globalMiddlewares.splice(index, 1) + } + } + } +}) + +export const createRuntimeClient = (clientOptions?: ClientOptions): RuntimeClient => { + const globalMiddlewares: Array = [] + const config = createBaseRuntimeConfig(clientOptions, globalMiddlewares) + const coreFetch = createCoreFetch(config) + return createClientMethods(coreFetch, globalMiddlewares) +} diff --git a/packages/app/src/shell/api-client/create-client-types.ts b/packages/app/src/shell/api-client/create-client-types.ts index a141cbc..12f6fcf 100644 --- a/packages/app/src/shell/api-client/create-client-types.ts +++ b/packages/app/src/shell/api-client/create-client-types.ts @@ -1,322 +1,302 @@ -// CHANGE: Extract public createClient types into dedicated module -// WHY: Keep create-client.ts under lint max-lines without weakening type-level invariants -// QUOTE(ТЗ): "Только прогони по всему проекту линтеры" -// REF: user-msg-2 -// SOURCE: n/a -// PURITY: CORE -// EFFECT: none -// INVARIANT: All type-level correlations remain unchanged -// COMPLEXITY: O(1) compile-time / O(0) runtime - -import type * as HttpClient from "@effect/platform/HttpClient" import type { Effect } from "effect" -import type { HttpMethod } from "openapi-typescript-helpers" - import type { - ApiFailure, - ApiSuccess, - HttpError, - OperationFor, - PathsForMethod, - RequestOptionsFor, - ResponsesFor -} from "../../core/api-client/strict-types.js" -import type { Dispatcher } from "../../core/axioms.js" + ErrorResponse, + FilterKeys, + HttpMethod, + IsOperationRequestBodyOptional, + MediaType, + OperationRequestBodyContent, + PathsWithMethod, + Readable, + RequiredKeysOf, + ResponseObjectMap, + SuccessResponse, + Writable +} from "openapi-typescript-helpers" + +export interface ClientOptions extends Omit { + baseUrl?: string + fetch?: (input: Request) => ReturnType + Request?: typeof Request + querySerializer?: QuerySerializer | QuerySerializerOptions + bodySerializer?: BodySerializer + pathSerializer?: PathSerializer + headers?: HeadersOptions + requestInitExt?: Record +} export type HeadersOptions = | Required["headers"] | Record< string, - | string - | number - | boolean - | ReadonlyArray - | null - | undefined + string | number | boolean | Array | null | undefined > -/** - * Effect client configuration options - */ -export interface ClientOptions extends Omit { - readonly baseUrl?: string - readonly headers?: HeadersOptions +export type QuerySerializer = ( + query: T extends { parameters: infer Parameters } ? Parameters extends { query?: infer Query } ? NonNullable + : Record + : Record +) => string + +export type QuerySerializerOptions = { + array?: { + style: "form" | "spaceDelimited" | "pipeDelimited" + explode: boolean + } + object?: { + style: "form" | "deepObject" + explode: boolean + } + allowReserved?: boolean } -// CHANGE: Add dispatcher map type for auto-dispatching clients -// WHY: Enable creating clients that infer dispatcher from path+method without per-call parameter -// QUOTE(ТЗ): "ApiClient и так знает текущие типы. Зачем передавать что либо в GET" -// REF: user-msg-1 -// SOURCE: n/a -// FORMAT THEOREM: ∀ path, method: dispatchers[path][method] = Dispatcher>> -// PURITY: CORE -// EFFECT: none -// INVARIANT: dispatcher map is total for all operations in Paths -// COMPLEXITY: O(1) compile-time / O(0) runtime -export type DispatchersForMethod< - Paths extends object, - Method extends HttpMethod -> = { - readonly [Path in PathsForMethod]: { - readonly [K in Method]: Dispatcher>> +export type BodySerializer = ( + body: Writable> | BodyInit | object, + headers?: Headers | HeadersOptions +) => BodyInit + +export type PathSerializer = ( + pathname: string, + pathParams: Record +) => string + +type BodyType = { + json: T + text: Awaited> + blob: Awaited> + arrayBuffer: Awaited> + stream: Response["body"] +} + +export type ParseAs = keyof BodyType + +export type ParseAsResponse = Options extends { parseAs: ParseAs } ? BodyType[Options["parseAs"]] + : T + +export interface DefaultParamsOption { + params?: { + query?: Record + } +} + +export type ParamsOption = T extends { parameters: infer Parameters } + ? RequiredKeysOf extends never ? { params?: Parameters } + : { params: Parameters } + : DefaultParamsOption + +export type RequestBodyOption = Writable> extends never ? { body?: never } + : IsOperationRequestBodyOptional extends true ? { body?: Writable> } + : { body: Writable> } + +export type FetchOptions = RequestOptions & Omit + +export type FetchResponse< + T extends Record, + Options, + Media extends MediaType +> = + | { + data: ParseAsResponse, Media>>, Options> + error?: never + response: Response } + | { + data?: never + error: Readable, Media>> + response: Response + } + +export type RequestOptions = + & ParamsOption + & RequestBodyOption + & { + baseUrl?: string + querySerializer?: QuerySerializer | QuerySerializerOptions + bodySerializer?: BodySerializer + pathSerializer?: PathSerializer + parseAs?: ParseAs + fetch?: ClientOptions["fetch"] + headers?: HeadersOptions + middleware?: Array + } + +export type MergedOptions = { + baseUrl: string + parseAs: ParseAs + querySerializer: QuerySerializer + bodySerializer: BodySerializer + pathSerializer: PathSerializer + fetch: typeof globalThis.fetch } -export type DispatchersFor = - & DispatchersForMethod - & DispatchersForMethod - & DispatchersForMethod - & DispatchersForMethod - & DispatchersForMethod - & DispatchersForMethod - & DispatchersForMethod +export interface MiddlewareRequestParams { + query?: Record + header?: Record + path?: Record + cookie?: Record +} + +export interface MiddlewareCallbackParams { + request: Request + readonly schemaPath: string + readonly params: MiddlewareRequestParams + readonly id: string + readonly options: MergedOptions +} + +export type Thenable = { + then: ( + onFulfilled: (value: T) => unknown, + onRejected?: (reason: unknown) => unknown + ) => unknown +} + +export type AsyncValue = T | Thenable + +export type MiddlewareOnRequest = ( + options: MiddlewareCallbackParams +) => AsyncValue -type ResponsesForOperation< +export type MiddlewareOnResponse = ( + options: MiddlewareCallbackParams & { response: Response } +) => AsyncValue + +export type MiddlewareOnError = ( + options: MiddlewareCallbackParams & { error: unknown } +) => AsyncValue + +export type Middleware = + | { + onRequest: MiddlewareOnRequest + onResponse?: MiddlewareOnResponse + onError?: MiddlewareOnError + } + | { + onRequest?: MiddlewareOnRequest + onResponse: MiddlewareOnResponse + onError?: MiddlewareOnError + } + | { + onRequest?: MiddlewareOnRequest + onResponse?: MiddlewareOnResponse + onError: MiddlewareOnError + } + +export type MaybeOptionalInit = RequiredKeysOf< + FetchOptions> +> extends never ? FetchOptions> | undefined + : FetchOptions> + +type InitParam = RequiredKeysOf extends never ? [(Init & { [key: string]: unknown })?] + : [Init & { [key: string]: unknown }] + +type OperationFor< Paths extends object, Path extends keyof Paths, Method extends HttpMethod -> = ResponsesFor> +> = Paths[Path] extends Record ? Operation & Record + : never -type RequestEffect< +type MethodResult< Paths extends object, - Path extends keyof Paths, - Method extends HttpMethod + Path extends PathsWithMethod, + Method extends HttpMethod, + Init, + Media extends MediaType > = Effect.Effect< - ApiSuccess>, - ApiFailure>, - HttpClient.HttpClient + FetchResponse, Init, Media>, + Error > -type RequestEffectWithHttpErrorsInSuccess< +export type ClientMethod< Paths extends object, - Path extends keyof Paths, - Method extends HttpMethod + Method extends HttpMethod, + Media extends MediaType +> = < + Path extends PathsWithMethod, + Init extends MaybeOptionalInit> +>( + url: Path, + ...init: InitParam +) => MethodResult + +export type ClientRequestMethod< + Paths extends object, + Media extends MediaType +> = < + Method extends HttpMethod, + Path extends PathsWithMethod, + Init extends MaybeOptionalInit> +>( + method: Method, + url: Path, + ...init: InitParam +) => MethodResult + +type PathMethodResult< + PathInfo extends Record, + Method extends keyof PathInfo, + Init, + Media extends MediaType > = Effect.Effect< - | ApiSuccess> - | HttpError>, - Exclude< - ApiFailure>, - HttpError> - >, - HttpClient.HttpClient + FetchResponse, Init, Media>, + Error > -type DispatcherFor< - Paths extends object, - Path extends keyof Paths, - Method extends HttpMethod -> = Dispatcher> +export type ClientForPath, Media extends MediaType> = { + [Method in keyof PathInfo as Uppercase]: < + Init extends MaybeOptionalInit + >( + ...init: InitParam + ) => PathMethodResult +} -type RequestOptionsForOperation< - Paths extends object, - Path extends keyof Paths, - Method extends HttpMethod -> = RequestOptionsFor> - -/** - * Type-safe API client with full request-side type enforcement - * - * **Key guarantees:** - * 1. GET only works on paths that have `get` method in schema - * 2. POST only works on paths that have `post` method in schema - * 3. Dispatcher type is derived from operation's responses - * 4. Request options (params/query/body) are derived from operation - * - * **Effect Channel Design:** - * - Success channel: `ApiSuccess` - 2xx responses only - * - Error channel: `ApiFailure` - HTTP errors (4xx, 5xx) + boundary errors - * - * @typeParam Paths - OpenAPI paths type from openapi-typescript - * - * @pure false - operations perform HTTP requests - * @invariant ∀ call: path ∈ PathsForMethod ∧ options derived from operation - */ -export type StrictApiClient = { - /** - * Execute GET request - * - * @typeParam Path - Path that supports GET method (enforced at type level) - * @param path - API path with GET method - * @param dispatcher - Response dispatcher (must match operation responses) - * @param options - Request options (typed from operation) - * @returns Effect with 2xx in success channel, errors in error channel - */ - readonly GET: >( - path: Path, - dispatcher: DispatcherFor, - options?: RequestOptionsForOperation - ) => RequestEffect - - /** - * Execute POST request - */ - readonly POST: >( - path: Path, - dispatcher: DispatcherFor, - options?: RequestOptionsForOperation - ) => RequestEffect - - /** - * Execute PUT request - */ - readonly PUT: >( - path: Path, - dispatcher: DispatcherFor, - options?: RequestOptionsForOperation - ) => RequestEffect - - /** - * Execute DELETE request - */ - readonly DELETE: >( - path: Path, - dispatcher: DispatcherFor, - options?: RequestOptionsForOperation - ) => RequestEffect - - /** - * Execute PATCH request - */ - readonly PATCH: >( - path: Path, - dispatcher: DispatcherFor, - options?: RequestOptionsForOperation - ) => RequestEffect - - /** - * Execute HEAD request - */ - readonly HEAD: >( - path: Path, - dispatcher: DispatcherFor, - options?: RequestOptionsForOperation - ) => RequestEffect - - /** - * Execute OPTIONS request - */ - readonly OPTIONS: >( - path: Path, - dispatcher: DispatcherFor, - options?: RequestOptionsForOperation - ) => RequestEffect +export interface Client { + request: ClientRequestMethod + GET: ClientMethod + PUT: ClientMethod + POST: ClientMethod + DELETE: ClientMethod + OPTIONS: ClientMethod + HEAD: ClientMethod + PATCH: ClientMethod + TRACE: ClientMethod + use(...middleware: Array): void + eject(...middleware: Array): void } -/** - * Type-safe API client with auto-dispatching (dispatcher is derived from path+method) - * - * **Key guarantees:** - * 1. GET only works on paths that have `get` method in schema - * 2. Dispatcher is looked up from provided dispatcher map by path+method - * 3. Request options (params/query/body) are derived from operation - * - * **Effect Channel Design:** - * - Success channel: `ApiSuccess` - 2xx responses only - * - Error channel: `ApiFailure` - HTTP errors (4xx, 5xx) + boundary errors - * - * @typeParam Paths - OpenAPI paths type from openapi-typescript - * - * @pure false - operations perform HTTP requests - * @invariant ∀ call: path ∈ PathsForMethod ∧ dispatcherMap[path][method] defined - */ -export type StrictApiClientWithDispatchers = { - /** - * Execute GET request (dispatcher is inferred) - */ - readonly GET: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffect - - /** - * Execute POST request (dispatcher is inferred) - */ - readonly POST: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffect - - /** - * Execute PUT request (dispatcher is inferred) - */ - readonly PUT: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffect - - /** - * Execute DELETE request (dispatcher is inferred) - */ - readonly DELETE: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffect - - /** - * Execute PATCH request (dispatcher is inferred) - */ - readonly PATCH: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffect - - /** - * Execute HEAD request (dispatcher is inferred) - */ - readonly HEAD: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffect - - /** - * Execute OPTIONS request (dispatcher is inferred) - */ - readonly OPTIONS: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffect +export type ClientPathsWithMethod< + CreatedClient extends Client>>, + Method extends HttpMethod +> = CreatedClient extends Client ? PathsWithMethod + : never + +export type MethodResponse< + CreatedClient extends Client>>, + Method extends HttpMethod, + Path extends ClientPathsWithMethod, + Options = object +> = CreatedClient extends Client< + infer Paths extends Record>, + infer Media extends MediaType +> ? NonNullable< + FetchResponse, Options, Media>["data"] + > + : never + +export type PathBasedClient< + Paths extends Record, + Media extends MediaType = MediaType +> = { + [Path in keyof Paths]: ClientForPath, Media> } -/** - * Ergonomic API client where HTTP statuses (2xx + 4xx/5xx from schema) - * are returned in the success value channel. - * - * Boundary/protocol errors remain in the error channel. - * This removes the need for `Effect.either` when handling normal HTTP statuses. - */ -export type ClientEffect = { - readonly GET: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffectWithHttpErrorsInSuccess - - readonly POST: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffectWithHttpErrorsInSuccess - - readonly PUT: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffectWithHttpErrorsInSuccess - - readonly DELETE: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffectWithHttpErrorsInSuccess - - readonly PATCH: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffectWithHttpErrorsInSuccess - - readonly HEAD: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffectWithHttpErrorsInSuccess - - readonly OPTIONS: >( - path: Path, - options?: RequestOptionsForOperation - ) => RequestEffectWithHttpErrorsInSuccess +export type DispatchersFor = { + [Path in keyof Paths]?: { + [Method in HttpMethod]?: object + } } + +export type StrictApiClient = Client +export type StrictApiClientWithDispatchers = Client +export type ClientEffect = Client diff --git a/packages/app/src/shell/api-client/create-client.ts b/packages/app/src/shell/api-client/create-client.ts index 53571ac..6e7edb4 100644 --- a/packages/app/src/shell/api-client/create-client.ts +++ b/packages/app/src/shell/api-client/create-client.ts @@ -1,517 +1,110 @@ -// CHANGE: Type-safe createClient API with full request-side enforcement -// WHY: Ensure path/method → operation → request types are all linked -// QUOTE(ТЗ): "path + method определяют operation, и из неё выводятся request/response types" -// REF: PR#3 blocking review sections 3.2, 3.3 -// SOURCE: n/a -// PURITY: SHELL -// EFFECT: Creates Effect-based API client -// INVARIANT: All operations are type-safe from path → operation → request → response -// COMPLEXITY: O(1) client creation +import type { MediaType } from "openapi-typescript-helpers" -import type * as HttpClient from "@effect/platform/HttpClient" -import { Effect } from "effect" -import type { HttpMethod } from "openapi-typescript-helpers" - -import { asDispatchersFor, asStrictApiClient, asStrictRequestInit, type Dispatcher } from "../../core/axioms.js" -import type { - ClientEffect, - ClientOptions, - DispatchersFor, - DispatchersForMethod, - StrictApiClientWithDispatchers -} from "./create-client-types.js" -import type { StrictRequestInit } from "./strict-client.js" -import { createUniversalDispatcher, executeRequest } from "./strict-client.js" +import { asStrictApiClient } from "../../core/axioms.js" +import type { RuntimeClient, RuntimeFetchOptions } from "./create-client-runtime-types.js" +import { createRuntimeClient } from "./create-client-runtime.js" +import type { Client, ClientEffect, ClientOptions, DispatchersFor, PathBasedClient } from "./create-client-types.js" export type { + Client, ClientEffect, + ClientForPath, + ClientMethod, ClientOptions, + ClientPathsWithMethod, + ClientRequestMethod, DispatchersFor, + FetchOptions, + FetchResponse, + HeadersOptions, + MethodResponse, + Middleware, + MiddlewareCallbackParams, + ParseAs, + PathBasedClient, + QuerySerializer, + QuerySerializerOptions, + RequestBodyOption, + RequestOptions, StrictApiClient, StrictApiClientWithDispatchers } from "./create-client-types.js" -export { createUniversalDispatcher } from "./strict-client.js" - -/** - * Primitive value type for path/query parameters - * - * @pure true - type alias only - */ -type ParamValue = string | number | boolean - -/** - * Query parameter value - can be primitive or array of primitives - * - * @pure true - type alias only - */ -type QueryValue = ParamValue | ReadonlyArray - -// CHANGE: Add default dispatcher registry for auto-dispatching createClient -// WHY: Allow createClient(options) without explicitly passing dispatcher map -// QUOTE(ТЗ): "const apiClient = createClient(clientOptions)" -// REF: user-msg-4 -// SOURCE: n/a -// FORMAT THEOREM: ∀ call: defaultDispatchers = dispatchersByPath ⇒ createClient uses dispatcher(path, method) -// PURITY: SHELL -// EFFECT: none -// INVARIANT: defaultDispatchers is set before createClient use -// COMPLEXITY: O(1) -let defaultDispatchers: DispatchersFor | undefined - -/** - * Register default dispatcher map used by createClient(options) - * - * @pure false - mutates module-level registry - * @invariant defaultDispatchers set exactly once per app boot - */ -export const registerDefaultDispatchers = ( - dispatchers: DispatchersFor -): void => { - defaultDispatchers = dispatchers -} - -/** - * Resolve default dispatcher map or fail fast - * - * @pure false - reads module-level registry - * @invariant defaultDispatchers must be set for auto-dispatching client - */ -const resolveDefaultDispatchers = (): DispatchersFor => { - if (defaultDispatchers === undefined) { - throw new Error("Default dispatchers are not registered. Import generated dispatchers module.") - } - return asDispatchersFor>(defaultDispatchers) -} - -const applyPathParams = (path: string, params?: Record): string => { - if (params === undefined) { - return path - } - let url = path - for (const [key, value] of Object.entries(params)) { - url = url.replace("{" + key + "}", encodeURIComponent(String(value))) - } - return url +export { + createFinalURL, + createQuerySerializer, + defaultBodySerializer, + defaultPathSerializer, + mergeHeaders, + removeTrailingSlash, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam +} from "./openapi-compat-utils.js" + +export const createClient = ( + clientOptions?: ClientOptions +): Client => asStrictApiClient>(createRuntimeClient(clientOptions)) + +class PathCallForwarder { + constructor( + private readonly client: RuntimeClient, + private readonly url: string + ) {} + + private readonly call = ( + method: "GET" | "PUT" | "POST" | "DELETE" | "OPTIONS" | "HEAD" | "PATCH" | "TRACE", + init?: RuntimeFetchOptions + ) => this.client[method](this.url, init) + + public readonly GET = (init?: RuntimeFetchOptions) => this.call("GET", init) + public readonly PUT = (init?: RuntimeFetchOptions) => this.call("PUT", init) + public readonly POST = (init?: RuntimeFetchOptions) => this.call("POST", init) + public readonly DELETE = (init?: RuntimeFetchOptions) => this.call("DELETE", init) + public readonly OPTIONS = (init?: RuntimeFetchOptions) => this.call("OPTIONS", init) + public readonly HEAD = (init?: RuntimeFetchOptions) => this.call("HEAD", init) + public readonly PATCH = (init?: RuntimeFetchOptions) => this.call("PATCH", init) + public readonly TRACE = (init?: RuntimeFetchOptions) => this.call("TRACE", init) } -const buildQueryString = (query?: Record): string => { - if (query === undefined) { - return "" - } - - const searchParams = new URLSearchParams() - for (const [key, value] of Object.entries(query)) { - if (Array.isArray(value)) { - for (const item of value) { - searchParams.append(key, String(item)) +export const wrapAsPathBasedClient = < + Paths extends Record, + Media extends MediaType = MediaType +>( + client: Client +): PathBasedClient => { + const cache = new Map() + const target = asStrictApiClient>({}) + + return new Proxy(target, { + get: (_target, property) => { + if (typeof property !== "string") { + return } - continue - } - searchParams.set(key, String(value)) - } - return searchParams.toString() -} - -const appendQueryString = (url: string, queryString: string): string => { - if (queryString.length === 0) { - return url - } - return url.includes("?") ? url + "&" + queryString : url + "?" + queryString -} - -const withBaseUrl = (baseUrl: string | undefined, url: string): string => { - // If baseUrl is not provided, keep a relative URL (browser-friendly) - if (baseUrl === undefined || baseUrl === "") { - return url - } - - // Construct full URL - return new URL(url, baseUrl).toString() -} -/** - * Build URL with path parameters and query string - * - * @param baseUrl - Base URL for the API - * @param path - Path template with placeholders - * @param params - Path parameters to substitute - * @param query - Query parameters to append - * @returns Fully constructed URL - * - * @pure true - * @complexity O(n + m) where n = |params|, m = |query| - */ -const buildUrl = ( - baseUrl: string | undefined, - path: string, - params?: Record, - query?: Record -): string => { - const urlWithParams = applyPathParams(path, params) - const queryString = buildQueryString(query) - const urlWithQuery = appendQueryString(urlWithParams, queryString) - return withBaseUrl(baseUrl, urlWithQuery) -} - -/** - * Check if body is already a BodyInit type (not a plain object needing serialization) - * - * @pure true - */ -const isBodyInit = (body: BodyInit | object): body is BodyInit => - typeof body === "string" - || body instanceof Blob - || body instanceof ArrayBuffer - || body instanceof ReadableStream - || body instanceof FormData - || body instanceof URLSearchParams - -/** - * Serialize body to BodyInit - passes through BodyInit types, JSON-stringifies objects - * - * @pure true - * @returns BodyInit or undefined, with consistent return path - */ -const serializeBody = (body: BodyInit | object | undefined): BodyInit | undefined => { - // Early return for undefined - if (body === undefined) { - return body - } - // Pass through existing BodyInit types - if (isBodyInit(body)) { - return body - } - // Plain object - serialize to JSON string (which is a valid BodyInit) - const serialized: BodyInit = JSON.stringify(body) - return serialized -} - -/** - * Check if body requires JSON Content-Type header - * - * @pure true - */ -const needsJsonContentType = (body: BodyInit | object | undefined): boolean => - body !== undefined - && typeof body !== "string" - && !(body instanceof Blob) - && !(body instanceof FormData) - -const toHeadersFromRecord = ( - headersInit: Record< - string, - | string - | number - | boolean - | ReadonlyArray - | null - | undefined - > -): Headers => { - const headers = new Headers() + const cached = cache.get(property) + if (cached !== undefined) { + return cached + } - for (const [key, value] of Object.entries(headersInit)) { - if (value === null || value === undefined) { - continue + const forwarder = new PathCallForwarder(asStrictApiClient(client), property) + cache.set(property, forwarder) + return forwarder } - if (Array.isArray(value)) { - headers.set(key, value.map(String).join(",")) - continue - } - headers.set(key, String(value)) - } - - return headers -} - -/** - * Merge headers from client options and request options - * - * @pure true - * @complexity O(n) where n = number of headers - */ -const toHeaders = (headersInit: ClientOptions["headers"] | undefined): Headers => { - if (headersInit === undefined) { - return new Headers() - } - - if (headersInit instanceof Headers) { - return new Headers(headersInit) - } - - if (Array.isArray(headersInit)) { - return new Headers(headersInit) - } - - return toHeadersFromRecord(headersInit) -} - -const mergeHeaders = ( - clientHeaders: ClientOptions["headers"] | undefined, - requestHeaders: ClientOptions["headers"] | undefined -): Headers => { - const headers = toHeaders(clientHeaders) - const optHeaders = toHeaders(requestHeaders) - for (const [key, value] of optHeaders.entries()) { - headers.set(key, value) - } - return headers -} - -/** - * Request options type for method handlers - * - * @pure true - type alias only - */ -type MethodHandlerOptions = { - params?: Record | undefined - query?: Record | undefined - body?: BodyInit | object | undefined - headers?: ClientOptions["headers"] | undefined - signal?: AbortSignal | undefined -} - -/** - * Create HTTP method handler with full type constraints - * - * @param method - HTTP method - * @param clientOptions - Client configuration - * @returns Method handler function - * - * @pure false - creates function that performs HTTP requests - * @complexity O(1) handler creation - */ -const createMethodHandler = ( - method: HttpMethod, - clientOptions: ClientOptions -) => -( - path: string, - dispatcher: Dispatcher, - options?: MethodHandlerOptions -) => { - const url = buildUrl(clientOptions.baseUrl, path, options?.params, options?.query) - const headers = mergeHeaders(clientOptions.headers, options?.headers) - const body = serializeBody(options?.body) - - if (needsJsonContentType(options?.body)) { - headers.set("Content-Type", "application/json") - } - - const config: StrictRequestInit = asStrictRequestInit({ - method, - url, - dispatcher, - headers, - body, - signal: options?.signal }) - - return executeRequest(config) } -/** - * Create method handler that infers dispatcher from map - * - * @pure false - creates function that performs HTTP requests - * @complexity O(1) handler creation - */ -const createMethodHandlerWithDispatchers = ( - method: Method, - clientOptions: ClientOptions, - dispatchers: DispatchersForMethod -) => - & string>( - path: Path, - options?: MethodHandlerOptions -) => - createMethodHandler(method, clientOptions)( - path, - dispatchers[path][method], - options - ) - -// CHANGE: Create method handler that infers dispatcher from map -// WHY: Allow per-call API without passing dispatcher parameter -// QUOTE(ТЗ): "Зачем передавать что либо в GET" -// REF: user-msg-1 -// SOURCE: n/a -// FORMAT THEOREM: ∀ path ∈ PathsForMethod: dispatchers[path][method] = Dispatcher> -// PURITY: SHELL -// EFFECT: Effect, ApiFailure, HttpClient> -// INVARIANT: Dispatcher lookup is total for all operations in Paths -// COMPLEXITY: O(1) runtime + O(1) dispatcher lookup -/** - * Create type-safe Effect-based API client - * - * The client enforces: - * 1. Method availability: GET only on paths with `get`, POST only on paths with `post` - * 2. Dispatcher correlation: must match operation's responses - * 3. Request options: params/query/body typed from operation - * - * @typeParam Paths - OpenAPI paths type from openapi-typescript - * @param options - Client configuration - * @returns API client with typed methods for all operations - * - * @pure false - creates client that performs HTTP requests - * @effect Client methods return Effect - * @invariant ∀ path, method: path ∈ PathsForMethod - * @complexity O(1) client creation - * - * @example - * ```typescript - * import createClient from "openapi-effect" - * import type { Paths } from "./generated/schema" - * import "./generated/dispatchers-by-path" // registers default dispatchers - * - * const client = createClient({ - * baseUrl: "https://api.example.com", - * credentials: "include" - * }) - * - * // Type-safe call - dispatcher inferred from path+method - * const result = yield* client.GET("/pets/{petId}", { - * params: { petId: "123" } // Required because getPet has path params - * }) - * - * // Compile error: "/pets/{petId}" has no "put" method - * // client.PUT("/pets/{petId}", ...) // Type error! - * ``` - */ -export const createClient = ( - options: ClientOptions, - dispatchers?: DispatchersFor -): StrictApiClientWithDispatchers => { - const resolvedDispatchers = dispatchers ?? resolveDefaultDispatchers() - - return asStrictApiClient>({ - GET: createMethodHandlerWithDispatchers("get", options, resolvedDispatchers), - POST: createMethodHandlerWithDispatchers("post", options, resolvedDispatchers), - PUT: createMethodHandlerWithDispatchers("put", options, resolvedDispatchers), - DELETE: createMethodHandlerWithDispatchers("delete", options, resolvedDispatchers), - PATCH: createMethodHandlerWithDispatchers("patch", options, resolvedDispatchers), - HEAD: createMethodHandlerWithDispatchers("head", options, resolvedDispatchers), - OPTIONS: createMethodHandlerWithDispatchers("options", options, resolvedDispatchers) - }) -} - -// CHANGE: Add createMethodHandlerWithUniversalDispatcher for zero-boilerplate client -// WHY: Enable createClientEffect(options) without code generation or dispatcher registry -// QUOTE(ТЗ): "Я не хочу создавать какие-то дополнительные модули" -// REF: issue-5 -// SOURCE: n/a -// FORMAT THEOREM: ∀ path, method: universalDispatcher handles response classification generically -// PURITY: SHELL -// EFFECT: Effect, ApiFailure, HttpClient> -// INVARIANT: 2xx → success channel, non-2xx → error channel -// COMPLEXITY: O(1) handler creation + O(1) universal dispatcher creation per call -const createMethodHandlerWithUniversalDispatcher = ( - method: HttpMethod, - clientOptions: ClientOptions -) => -( - path: string, - options?: MethodHandlerOptions -) => - createMethodHandler(method, clientOptions)( - path, - createUniversalDispatcher(), - options - ) - -type HttpErrorTag = { readonly _tag: "HttpError" } +export const createPathBasedClient = < + Paths extends Record, + Media extends MediaType = MediaType +>( + clientOptions?: ClientOptions +): PathBasedClient => wrapAsPathBasedClient(createClient(clientOptions)) -const isHttpErrorValue = (error: unknown): error is HttpErrorTag => - typeof error === "object" - && error !== null - && "_tag" in error - && Reflect.get(error, "_tag") === "HttpError" - -const exposeHttpErrorsAsValues = ( - request: Effect.Effect -): Effect.Effect< - A | Extract, - Exclude>, - HttpClient.HttpClient -> => - request.pipe( - Effect.catchIf( - (error): error is Extract => isHttpErrorValue(error), - (error) => Effect.succeed(error) - ) - ) - -const createMethodHandlerWithUniversalDispatcherValue = ( - method: HttpMethod, - clientOptions: ClientOptions -) => -( - path: string, - options?: MethodHandlerOptions -) => - exposeHttpErrorsAsValues( - createMethodHandlerWithUniversalDispatcher(method, clientOptions)(path, options) - ) - -// CHANGE: Add createClientEffect — zero-boilerplate Effect-based API client -// WHY: Enable the user's desired DSL without any generated code or dispatcher setup -// QUOTE(ТЗ): "const apiClientEffect = createClientEffect(clientOptions); apiClientEffect.POST('/api/auth/login', { body: credentials })" -// REF: issue-5 -// SOURCE: n/a -// FORMAT THEOREM: ∀ Paths, options: createClientEffect(options) → ClientEffect -// PURITY: SHELL -// EFFECT: Client methods return Effect -// INVARIANT: ∀ path, method: path ∈ PathsForMethod (compile-time) ∧ response classified by status range (runtime) -// COMPLEXITY: O(1) client creation -/** - * Create type-safe Effect-based API client with zero boilerplate - * - * Uses a universal dispatcher and exposes HTTP statuses as values: - * - 2xx → success value (ApiSuccess) - * - non-2xx schema statuses → success value (HttpError with _tag) - * - boundary/protocol failures stay in error channel - * - JSON parsed automatically for application/json content types - * - * **No code generation needed.** No dispatcher registry needed. - * Just pass your OpenAPI Paths type and client options. - * - * @typeParam Paths - OpenAPI paths type from openapi-typescript - * @param options - Client configuration (baseUrl, credentials, headers, etc.) - * @returns API client with typed methods for all operations - * - * @pure false - creates client that performs HTTP requests - * @effect Client methods return Effect - * @invariant ∀ path, method: path ∈ PathsForMethod - * @complexity O(1) client creation - * - * @example - * ```typescript - * import { createClientEffect, type ClientOptions } from "openapi-effect" - * import type { paths } from "./openapi.d.ts" - * - * const clientOptions: ClientOptions = { - * baseUrl: "https://petstore.example.com", - * credentials: "include" - * } - * const apiClientEffect = createClientEffect(clientOptions) - * - * // Type-safe call — path, method, and body all enforced at compile time - * const result = yield* apiClientEffect.POST("/api/auth/login", { - * body: { email: "user@example.com", password: "secret" } - * }) - * ``` - */ export const createClientEffect = ( - options: ClientOptions -): ClientEffect => { - return asStrictApiClient>({ - GET: createMethodHandlerWithUniversalDispatcherValue("get", options), - POST: createMethodHandlerWithUniversalDispatcherValue("post", options), - PUT: createMethodHandlerWithUniversalDispatcherValue("put", options), - DELETE: createMethodHandlerWithUniversalDispatcherValue("delete", options), - PATCH: createMethodHandlerWithUniversalDispatcherValue("patch", options), - HEAD: createMethodHandlerWithUniversalDispatcherValue("head", options), - OPTIONS: createMethodHandlerWithUniversalDispatcherValue("options", options) - }) -} + clientOptions?: ClientOptions +): ClientEffect => createClient(clientOptions) + +export const registerDefaultDispatchers = ( + _dispatchers: DispatchersFor +): void => {} diff --git a/packages/app/src/shell/api-client/index.ts b/packages/app/src/shell/api-client/index.ts index 6501150..16c2ee0 100644 --- a/packages/app/src/shell/api-client/index.ts +++ b/packages/app/src/shell/api-client/index.ts @@ -28,10 +28,37 @@ export { // High-level client creation API export type { + Client, ClientEffect, + ClientForPath, ClientOptions, DispatchersFor, + FetchOptions, + FetchResponse, + HeadersOptions, + Middleware, + ParseAs, + PathBasedClient, + QuerySerializer, + QuerySerializerOptions, + RequestBodyOption, + RequestOptions as FetchRequestOptions, StrictApiClient, StrictApiClientWithDispatchers } from "./create-client.js" -export { createClient, createClientEffect, registerDefaultDispatchers } from "./create-client.js" +export { + createClient, + createClientEffect, + createFinalURL, + createPathBasedClient, + createQuerySerializer, + defaultBodySerializer, + defaultPathSerializer, + mergeHeaders, + registerDefaultDispatchers, + removeTrailingSlash, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, + wrapAsPathBasedClient +} from "./create-client.js" diff --git a/packages/app/src/shell/api-client/openapi-compat-path.ts b/packages/app/src/shell/api-client/openapi-compat-path.ts new file mode 100644 index 0000000..e51bfc5 --- /dev/null +++ b/packages/app/src/shell/api-client/openapi-compat-path.ts @@ -0,0 +1,82 @@ +import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "./openapi-compat-serializers.js" +import { isPrimitive, isRecord } from "./openapi-compat-value-guards.js" + +const PATH_PARAM_RE = /\{[^{}]+\}/g + +type PathStyle = "simple" | "label" | "matrix" + +type PathTokenMeta = { + name: string + explode: boolean + style: PathStyle +} + +const toPathTokenMeta = (rawName: string): PathTokenMeta => { + let name = rawName + let explode = false + let style: PathStyle = "simple" + + if (name.endsWith("*")) { + explode = true + name = name.slice(0, Math.max(0, name.length - 1)) + } + + if (name.startsWith(".")) { + style = "label" + name = name.slice(1) + } else if (name.startsWith(";")) { + style = "matrix" + name = name.slice(1) + } + + return { name, explode, style } +} + +const serializePathValue = ( + name: string, + value: unknown, + meta: PathTokenMeta +): string | undefined => { + if (Array.isArray(value)) { + return serializeArrayParam(name, value, { style: meta.style, explode: meta.explode }) + } + + if (isRecord(value)) { + return serializeObjectParam(name, value, { style: meta.style, explode: meta.explode }) + } + + if (!isPrimitive(value)) { + return + } + + if (meta.style === "matrix") { + return `;${serializePrimitiveParam(name, value)}` + } + + const encoded = encodeURIComponent(String(value)) + return meta.style === "label" ? `.${encoded}` : encoded +} + +export const defaultPathSerializer = ( + pathname: string, + pathParams: Record +): string => { + let nextURL = pathname + + for (const match of pathname.match(PATH_PARAM_RE) ?? []) { + const rawName = match.slice(1, -1) + const meta = toPathTokenMeta(rawName) + const value = pathParams[meta.name] + + if (value === undefined || value === null) { + continue + } + + const serializedValue = serializePathValue(meta.name, value, meta) + if (serializedValue !== undefined) { + nextURL = nextURL.replace(match, serializedValue) + } + } + + return nextURL +} diff --git a/packages/app/src/shell/api-client/openapi-compat-request.ts b/packages/app/src/shell/api-client/openapi-compat-request.ts new file mode 100644 index 0000000..88fd715 --- /dev/null +++ b/packages/app/src/shell/api-client/openapi-compat-request.ts @@ -0,0 +1,153 @@ +import type { HeadersOptions, PathSerializer, QuerySerializer } from "./create-client-types.js" + +const isRecord = (value: unknown): value is Record => ( + value !== null && typeof value === "object" && !Array.isArray(value) +) + +const toFormRecord = (value: unknown): Record => { + if (!isRecord(value)) { + return {} + } + + const formRecord: Record = {} + for (const [key, item] of Object.entries(value)) { + formRecord[key] = String(item) + } + + return formRecord +} + +type HeaderRecord = Record< + string, + string | number | boolean | Array | null | undefined +> + +const isHeaderRecord = (headers: HeadersOptions): headers is HeaderRecord => ( + !(headers instanceof Headers) && !Array.isArray(headers) +) + +const getHeaderValue = (headers: Headers | HeadersOptions | undefined, key: string): string | undefined => { + if (headers === undefined) { + return + } + + if (headers instanceof Headers) { + return headers.get(key) ?? undefined + } + + if (!isHeaderRecord(headers)) { + return + } + + const value = headers[key] + if (value === undefined || value === null || Array.isArray(value)) { + return + } + + return String(value) +} + +const stringifyBody = (body: unknown): string => { + return JSON.stringify(body) +} + +export const defaultBodySerializer = ( + body: unknown, + headers?: Headers | HeadersOptions +): string => { + if (body === undefined) { + return "" + } + + const contentType = getHeaderValue(headers, "Content-Type") ?? getHeaderValue(headers, "content-type") + if (contentType === "application/x-www-form-urlencoded") { + return new URLSearchParams(toFormRecord(body)).toString() + } + + return stringifyBody(body) +} + +export const createFinalURL = ( + pathname: string, + options: { + baseUrl: string + params: { + query?: Record + path?: Record + } + querySerializer: QuerySerializer + pathSerializer: PathSerializer + } +): string => { + let finalURL = `${options.baseUrl}${pathname}` + + if (options.params.path) { + finalURL = options.pathSerializer(finalURL, options.params.path) + } + + let queryString = options.querySerializer(options.params.query ?? {}) + if (queryString.startsWith("?")) { + queryString = queryString.slice(1) + } + + if (queryString.length > 0) { + finalURL = `${finalURL}?${queryString}` + } + + return finalURL +} + +const applyHeaderValue = (target: Headers, key: string, value: HeaderRecord[string]): void => { + if (value === null) { + target.delete(key) + return + } + + if (Array.isArray(value)) { + for (const item of value) { + target.append(key, String(item)) + } + return + } + + if (value !== undefined) { + target.set(key, String(value)) + } +} + +const mergeHeaderSource = (target: Headers, source: HeadersOptions): void => { + if (source instanceof Headers) { + for (const [key, value] of source.entries()) { + target.set(key, value) + } + return + } + + if (!isHeaderRecord(source)) { + return + } + + for (const [key, value] of Object.entries(source)) { + applyHeaderValue(target, key, value) + } +} + +export const mergeHeaders = ( + ...allHeaders: Array +): Headers => { + const finalHeaders = new Headers() + + for (const source of allHeaders) { + if (source === undefined || typeof source !== "object") { + continue + } + + mergeHeaderSource(finalHeaders, source) + } + + return finalHeaders +} + +export const removeTrailingSlash = (url: string): string => ( + url.endsWith("/") ? url.slice(0, Math.max(0, url.length - 1)) : url +) diff --git a/packages/app/src/shell/api-client/openapi-compat-serializers.ts b/packages/app/src/shell/api-client/openapi-compat-serializers.ts new file mode 100644 index 0000000..c2b8b84 --- /dev/null +++ b/packages/app/src/shell/api-client/openapi-compat-serializers.ts @@ -0,0 +1,277 @@ +import type { QuerySerializer, QuerySerializerOptions } from "./create-client-types.js" +import { isPrimitive, isRecord, type Primitive } from "./openapi-compat-value-guards.js" + +type PathStyle = "simple" | "label" | "matrix" +type ObjectParamStyle = PathStyle | "form" | "deepObject" +type ArrayParamStyle = PathStyle | "form" | "spaceDelimited" | "pipeDelimited" + +const OBJECT_JOINER_BY_STYLE: Readonly> = { + simple: ",", + label: ".", + matrix: ";", + form: "&", + deepObject: "&" +} + +const ARRAY_JOINER_BY_STYLE: Readonly< + Record +> = { + simple: { explodeFalse: ",", explodeTrue: "," }, + label: { explodeFalse: ",", explodeTrue: "." }, + matrix: { explodeFalse: ",", explodeTrue: ";" }, + form: { explodeFalse: ",", explodeTrue: "&" }, + spaceDelimited: { explodeFalse: "%20", explodeTrue: "&" }, + pipeDelimited: { explodeFalse: "|", explodeTrue: "&" } +} + +const encodeValue = (value: Primitive, allowReserved: boolean): string => ( + allowReserved ? String(value) : encodeURIComponent(String(value)) +) + +const formatExplodeFalse = ( + name: string, + style: ObjectParamStyle | ArrayParamStyle, + value: string +): string => { + if (style === "simple") { + return value + } + if (style === "label") { + return `.${value}` + } + if (style === "matrix") { + return `;${name}=${value}` + } + return `${name}=${value}` +} + +const formatExplodeTrue = ( + style: ObjectParamStyle | ArrayParamStyle, + joiner: string, + value: string +): string => ( + style === "label" || style === "matrix" ? `${joiner}${value}` : value +) + +const toPrimitiveList = (value: Array): Array => { + const items: Array = [] + for (const item of value) { + if (isPrimitive(item)) { + items.push(item) + } + } + return items +} + +const getQueryEntries = (queryParams: unknown): Array<[string, unknown]> => ( + isRecord(queryParams) ? Object.entries(queryParams) : [] +) + +const toObjectPairs = ( + name: string, + value: Record, + allowReserved: boolean, + explode: boolean, + style: ObjectParamStyle +): Array => { + const entries: Array = [] + + for (const [key, rawValue] of Object.entries(value)) { + if (!isPrimitive(rawValue)) { + continue + } + + if (!explode) { + entries.push(key, encodeValue(rawValue, allowReserved)) + continue + } + + const nextName = style === "deepObject" ? `${name}[${key}]` : key + entries.push( + serializePrimitiveParam(nextName, rawValue, { + allowReserved + }) + ) + } + + return entries +} + +const toArrayValues = ( + name: string, + value: Array, + style: ArrayParamStyle, + allowReserved: boolean, + explode: boolean +): Array => { + const entries: Array = [] + + for (const item of toPrimitiveList(value)) { + if (explode && style !== "simple" && style !== "label") { + entries.push( + serializePrimitiveParam(name, item, { + allowReserved + }) + ) + continue + } + + entries.push(encodeValue(item, allowReserved)) + } + + return entries +} + +const finalizeSerializedParam = (options: { + name: string + style: ObjectParamStyle | ArrayParamStyle + explode: boolean + values: Array + joinerWhenExplodeFalse: string + joinerWhenExplodeTrue: string +}): string => { + const joiner = options.explode ? options.joinerWhenExplodeTrue : options.joinerWhenExplodeFalse + const serializedValue = options.values.join(joiner) + + return options.explode + ? formatExplodeTrue(options.style, options.joinerWhenExplodeTrue, serializedValue) + : formatExplodeFalse(options.name, options.style, serializedValue) +} + +export const serializePrimitiveParam = ( + name: string, + value: Primitive, + options?: { allowReserved?: boolean } +): string => ( + `${name}=${encodeValue(value, options?.allowReserved === true)}` +) + +export const serializeObjectParam = ( + name: string, + value: unknown, + options: { + style: ObjectParamStyle + explode: boolean + allowReserved?: boolean + } +): string => { + if (!isRecord(value)) { + return "" + } + + const pairs = toObjectPairs( + name, + value, + options.allowReserved === true, + options.explode, + options.style + ) + + return finalizeSerializedParam({ + name, + style: options.style, + explode: options.explode, + values: pairs, + joinerWhenExplodeFalse: ",", + joinerWhenExplodeTrue: OBJECT_JOINER_BY_STYLE[options.style] + }) +} + +export const serializeArrayParam = ( + name: string, + value: Array, + options: { + style: ArrayParamStyle + explode: boolean + allowReserved?: boolean + } +): string => { + if (!Array.isArray(value)) { + return "" + } + + const values = toArrayValues( + name, + value, + options.style, + options.allowReserved === true, + options.explode + ) + + return finalizeSerializedParam({ + name, + style: options.style, + explode: options.explode, + values, + joinerWhenExplodeFalse: ARRAY_JOINER_BY_STYLE[options.style].explodeFalse, + joinerWhenExplodeTrue: ARRAY_JOINER_BY_STYLE[options.style].explodeTrue + }) +} + +const serializeQueryEntry = ( + name: string, + value: unknown, + options?: QuerySerializerOptions +): string | undefined => { + if (value === undefined || value === null) { + return + } + + return Array.isArray(value) + ? serializeArrayQueryEntry(name, value, options) + : serializeNonArrayQueryEntry(name, value, options) +} + +const serializeArrayQueryEntry = ( + name: string, + value: Array, + options?: QuerySerializerOptions +): string | undefined => { + if (value.length === 0) { + return + } + + return serializeArrayParam(name, value, { + style: "form", + explode: true, + ...options?.array, + allowReserved: options?.allowReserved === true + }) +} + +const serializeNonArrayQueryEntry = ( + name: string, + value: unknown, + options?: QuerySerializerOptions +): string | undefined => { + if (isRecord(value)) { + return serializeObjectParam(name, value, { + style: "deepObject", + explode: true, + ...options?.object, + allowReserved: options?.allowReserved === true + }) + } + + if (isPrimitive(value)) { + return serializePrimitiveParam(name, value, options) + } + + return undefined +} + +export const createQuerySerializer = ( + options?: QuerySerializerOptions +): QuerySerializer => +(queryParams) => { + const serialized: Array = [] + + for (const [name, value] of getQueryEntries(queryParams)) { + const entry = serializeQueryEntry(name, value, options) + if (entry !== undefined) { + serialized.push(entry) + } + } + + return serialized.join("&") +} diff --git a/packages/app/src/shell/api-client/openapi-compat-utils.ts b/packages/app/src/shell/api-client/openapi-compat-utils.ts new file mode 100644 index 0000000..469a049 --- /dev/null +++ b/packages/app/src/shell/api-client/openapi-compat-utils.ts @@ -0,0 +1,10 @@ +export { + createQuerySerializer, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam +} from "./openapi-compat-serializers.js" + +export { defaultPathSerializer } from "./openapi-compat-path.js" + +export { createFinalURL, defaultBodySerializer, mergeHeaders, removeTrailingSlash } from "./openapi-compat-request.js" diff --git a/packages/app/src/shell/api-client/openapi-compat-value-guards.ts b/packages/app/src/shell/api-client/openapi-compat-value-guards.ts new file mode 100644 index 0000000..a82cb85 --- /dev/null +++ b/packages/app/src/shell/api-client/openapi-compat-value-guards.ts @@ -0,0 +1,9 @@ +export type Primitive = string | number | boolean + +export const isPrimitive = (value: unknown): value is Primitive => ( + typeof value === "string" || typeof value === "number" || typeof value === "boolean" +) + +export const isRecord = (value: unknown): value is Record => ( + value !== null && typeof value === "object" && !Array.isArray(value) +) diff --git a/packages/app/tests/api-client/create-client-dispatchers.test.ts b/packages/app/tests/api-client/create-client-dispatchers.test.ts index 2edb978..e37a360 100644 --- a/packages/app/tests/api-client/create-client-dispatchers.test.ts +++ b/packages/app/tests/api-client/create-client-dispatchers.test.ts @@ -1,17 +1,14 @@ -// CHANGE: Add tests for auto-dispatching createClient -// WHY: Verify dispatcher map removes per-call dispatcher parameters while preserving error handling -// QUOTE(ТЗ): "ApiClient и так знает текущие типы. Зачем передавать что либо в GET" -// REF: user-msg-1 +// CHANGE: Add tests for createClient dispatcher-less usage with openapi-fetch envelope +// WHY: Verify createClient can be used without per-call dispatcher and keeps openapi-fetch response shape +// QUOTE(ТЗ): "openapi-effect должен почти 1 в 1 заменяться с openapi-fetch" +// REF: user-msg-2026-02-12 // SOURCE: n/a -// FORMAT THEOREM: ∀ op ∈ Operations: dispatchersByPath[path][method] = dispatcher(op) // PURITY: SHELL // EFFECT: Effect -// INVARIANT: 2xx → isRight (success), non-2xx → isLeft (HttpError) +// INVARIANT: 2xx -> data, non-2xx -> error in success channel // COMPLEXITY: O(1) per test -import * as HttpClient from "@effect/platform/HttpClient" -import * as HttpClientResponse from "@effect/platform/HttpClientResponse" -import { Effect, Either, Layer } from "effect" +import { Effect } from "effect" import { describe, expect, it } from "vitest" import "../../src/generated/dispatchers-by-path.js" @@ -20,40 +17,17 @@ import type { Paths } from "../fixtures/petstore.openapi.js" type PetstorePaths = Paths & object -// CHANGE: Define dispatcher map for auto-dispatching client tests -// WHY: Verify createClient can infer dispatcher from path+method -// QUOTE(ТЗ): "Зачем передавать что либо в GET" -// REF: user-msg-1 -// SOURCE: n/a -// FORMAT THEOREM: ∀ op ∈ Operations: dispatchersByPath[path][method] = dispatcher(op) -// PURITY: SHELL -// EFFECT: none -// INVARIANT: dispatcher map is total for all operations in Paths -// COMPLEXITY: O(1) -/** - * Create a mock HttpClient layer that returns a fixed response - * Note: 204 and 304 responses cannot have a body per HTTP spec - * - * @pure true - returns pure layer - */ -const createMockHttpClientLayer = ( +const createMockFetch = ( status: number, headers: Record, body: string -): Layer.Layer => - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make( - (request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - // 204 and 304 responses cannot have a body - status === 204 || status === 304 - ? new Response(null, { status, headers: new Headers(headers) }) - : new Response(body, { status, headers: new Headers(headers) }) - ) - ) +) => +(_request: Request) => + Effect.runPromise( + Effect.succeed( + status === 204 || status === 304 + ? new Response(null, { status, headers: new Headers(headers) }) + : new Response(body, { status, headers: new Headers(headers) }) ) ) @@ -65,43 +39,37 @@ describe("createClient (auto-dispatching)", () => { { id: "2", name: "Spot" } ]) - const client = createClient({ baseUrl: "https://api.example.com" }) + const client = createClient({ + baseUrl: "https://api.example.com", + fetch: createMockFetch(200, { "content-type": "application/json" }, successBody) + }) - const result = yield* Effect.either( - client.GET("/pets", { query: { limit: 10 } }).pipe( - Effect.provide( - createMockHttpClientLayer(200, { "content-type": "application/json" }, successBody) - ) - ) - ) + const result = yield* client.GET("/pets", { + params: { + query: { limit: 10 } + } + }) - expect(Either.isRight(result)).toBe(true) - if (Either.isRight(result)) { - expect(result.right.status).toBe(200) - expect(Array.isArray(result.right.body)).toBe(true) - } + expect(result.response.status).toBe(200) + expect(Array.isArray(result.data)).toBe(true) }).pipe(Effect.runPromise)) - it("should return HttpError for schema 404 without passing dispatcher", () => + it("should keep schema 404 inside response envelope", () => Effect.gen(function*() { const errorBody = JSON.stringify({ code: 404, message: "Pet not found" }) - const client = createClient({ baseUrl: "https://api.example.com" }) + const client = createClient({ + baseUrl: "https://api.example.com", + fetch: createMockFetch(404, { "content-type": "application/json" }, errorBody) + }) - const result = yield* Effect.either( - client.GET("/pets/{petId}", { params: { petId: "999" } }).pipe( - Effect.provide( - createMockHttpClientLayer(404, { "content-type": "application/json" }, errorBody) - ) - ) - ) + const result = yield* client.GET("/pets/{petId}", { + params: { + path: { petId: "999" } + } + }) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toMatchObject({ - _tag: "HttpError", - status: 404 - }) - } + expect(result.response.status).toBe(404) + expect(result.error).toMatchObject({ code: 404, message: "Pet not found" }) }).pipe(Effect.runPromise)) }) diff --git a/packages/app/tests/api-client/create-client-effect-integration.test.ts b/packages/app/tests/api-client/create-client-effect-integration.test.ts index a6aa598..5ce3eee 100644 --- a/packages/app/tests/api-client/create-client-effect-integration.test.ts +++ b/packages/app/tests/api-client/create-client-effect-integration.test.ts @@ -1,182 +1,113 @@ -// CHANGE: Integration test verifying the exact user DSL snippet from issue #5 -// WHY: Ensure the import/usage pattern requested by the user compiles and works end-to-end -// QUOTE(ТЗ): "import { createClientEffect, type ClientOptions } from 'openapi-effect' ... apiClientEffect.POST('/api/auth/login', { body: credentials })" -// REF: issue-5 +// CHANGE: Integration test for createClientEffect using openapi-fetch response envelope +// WHY: Validate drop-in input contract with Effect output channel +// QUOTE(ТЗ): "input 1 в 1 ... output Effect<,,>" +// REF: user-msg-2026-02-12 // SOURCE: n/a -// FORMAT THEOREM: ∀ Paths, options: createClientEffect(options).POST(path, { body }) → Effect // PURITY: SHELL // EFFECT: Effect -// INVARIANT: The exact user snippet compiles and produces correct runtime behavior +// INVARIANT: HTTP non-2xx stays in `error` field, transport failures stay in Effect error channel // COMPLEXITY: O(1) per test -import * as HttpClient from "@effect/platform/HttpClient" -import * as HttpClientResponse from "@effect/platform/HttpClientResponse" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { describe, expect, it } from "vitest" -// CHANGE: Import via the package's main entry point (src/index.ts) -// WHY: Verify the user's import pattern `from "openapi-effect"` resolves correctly -// REF: issue-5 +import type { paths } from "../../src/core/api/openapi.js" import { createClientEffect } from "../../src/index.js" import type { ClientOptions } from "../../src/index.js" -// CHANGE: Import paths from the auth OpenAPI schema -// WHY: Match the user's pattern `import type { paths } from "./openapi.d.ts"` -// REF: issue-5 -import type { paths } from "../../src/core/api/openapi.js" - -/** - * Create a mock HttpClient layer that returns a fixed response - * - * @pure true - returns pure layer - */ -const createMockHttpClientLayer = ( +const createMockFetch = ( status: number, headers: Record, body: string -): Layer.Layer => - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make( - (request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - status === 204 || status === 304 - ? new Response(null, { status, headers: new Headers(headers) }) - : new Response(body, { status, headers: new Headers(headers) }) - ) - ) +) => +(_request: Request) => + Effect.runPromise( + Effect.succeed( + status === 204 || status === 304 + ? new Response(null, { status, headers: new Headers(headers) }) + : new Response(body, { status, headers: new Headers(headers) }) ) ) -/** - * Test fixtures for integration tests - * - * @pure true - immutable test data factories - */ -const fixtures = { - loginBody: () => ({ email: "user@example.com", password: `test-${Date.now()}` }), - wrongLoginBody: () => ({ email: "user@example.com", password: `wrong-${Date.now()}` }) -} as const - -// ============================================================================= -// SECTION: Exact user snippet integration test (CI/CD check) -// ============================================================================= - -describe("CI/CD: exact user snippet from issue #5", () => { - // --- The exact code from the user's PR comment --- - const clientOptions: ClientOptions = { - baseUrl: "https://petstore.example.com", - credentials: "include" - } - const apiClientEffect = createClientEffect(clientOptions) - - it("should compile and execute: apiClientEffect.POST('/api/auth/login', { body: credentials })", () => +describe("createClientEffect integration", () => { + it("returns { data, response } for 200 login", () => Effect.gen(function*() { - const credentials = fixtures.loginBody() const successBody = JSON.stringify({ id: "550e8400-e29b-41d4-a716-446655440000", - email: "user@example.com", - firstName: "John", - lastName: "Doe", - profileImageUrl: null, - emailVerified: true, - phoneVerified: false + email: "user@example.com" }) - // Type-safe — path, method, and body all enforced at compile time - const result = yield* apiClientEffect.POST("/api/auth/login", { - body: credentials - }).pipe( - Effect.provide( - createMockHttpClientLayer(200, { "content-type": "application/json" }, successBody) - ) - ) - - expect("_tag" in result).toBe(false) - expect(result.status).toBe(200) - expect(result.contentType).toBe("application/json") - const body = result.body as { id: string; email: string } - expect(body.email).toBe("user@example.com") - }).pipe(Effect.runPromise)) + const clientOptions: ClientOptions = { + baseUrl: "https://petstore.example.com", + credentials: "include", + fetch: createMockFetch(200, { "content-type": "application/json" }, successBody) + } + const apiClientEffect = createClientEffect(clientOptions) - it("should compile and execute: yield* apiClientEffect.POST (inside Effect.gen)", () => - Effect.gen(function*() { - const credentials = fixtures.loginBody() - const successBody = JSON.stringify({ - id: "550e8400-e29b-41d4-a716-446655440000", - email: "user@example.com" + const generatedPassword = `pw-${Date.now()}` + const result = yield* apiClientEffect.POST("/api/auth/login", { + body: { email: "user@example.com", password: generatedPassword } }) - // This verifies the `yield*` pattern from the user's snippet - const result = yield* apiClientEffect.POST("/api/auth/login", { - body: credentials - }).pipe( - Effect.provide( - createMockHttpClientLayer(200, { "content-type": "application/json" }, successBody) - ) - ) - - expect("_tag" in result).toBe(false) - expect(result.status).toBe(200) - expect(result.contentType).toBe("application/json") - const body = result.body as { email: string } - expect(body.email).toBe("user@example.com") + expect(result.response.status).toBe(200) + expect(result.error).toBeUndefined() + expect(result.data).toMatchObject({ email: "user@example.com" }) }).pipe(Effect.runPromise)) - it("should expose 401 as HttpError value without Effect.either", () => + it("returns { error, response } for 401 login", () => Effect.gen(function*() { - const credentials = fixtures.wrongLoginBody() const errorBody = JSON.stringify({ error: "invalid_credentials" }) - const result = yield* apiClientEffect.POST("/api/auth/login", { - body: credentials - }).pipe( - Effect.provide( - createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody) - ) - ) - - expect("_tag" in result).toBe(true) - if ("_tag" in result) { - expect(result).toMatchObject({ - _tag: "HttpError", - status: 401 - }) + const clientOptions: ClientOptions = { + baseUrl: "https://petstore.example.com", + credentials: "include", + fetch: createMockFetch(401, { "content-type": "application/json" }, errorBody) } + const apiClientEffect = createClientEffect(clientOptions) + + const generatedPassword = `bad-${Date.now()}` + const result = yield* apiClientEffect.POST("/api/auth/login", { + body: { email: "user@example.com", password: generatedPassword } + }) + + expect(result.response.status).toBe(401) + expect(result.data).toBeUndefined() + expect(result.error).toMatchObject({ error: "invalid_credentials" }) }).pipe(Effect.runPromise)) - it("should work with GET requests (no body required)", () => + it("handles GET without body", () => Effect.gen(function*() { const profileBody = JSON.stringify({ id: "550e8400-e29b-41d4-a716-446655440000", email: "user@example.com" }) - const result = yield* apiClientEffect.GET("/api/auth/me").pipe( - Effect.provide( - createMockHttpClientLayer(200, { "content-type": "application/json" }, profileBody) - ) - ) + const clientOptions: ClientOptions = { + baseUrl: "https://petstore.example.com", + credentials: "include", + fetch: createMockFetch(200, { "content-type": "application/json" }, profileBody) + } + const apiClientEffect = createClientEffect(clientOptions) + + const result = yield* apiClientEffect.GET("/api/auth/me") - expect("_tag" in result).toBe(false) - expect(result.status).toBe(200) - const body = result.body as { email: string } - expect(body.email).toBe("user@example.com") + expect(result.response.status).toBe(200) + expect(result.data).toMatchObject({ email: "user@example.com" }) }).pipe(Effect.runPromise)) - it("should handle 204 no-content responses", () => + it("handles 204 no-content", () => Effect.gen(function*() { - const result = yield* apiClientEffect.POST("/api/auth/logout").pipe( - Effect.provide( - createMockHttpClientLayer(204, {}, "") - ) - ) - - expect("_tag" in result).toBe(false) - expect(result.status).toBe(204) - expect(result.contentType).toBe("none") - expect(result.body).toBeUndefined() + const clientOptions: ClientOptions = { + baseUrl: "https://petstore.example.com", + credentials: "include", + fetch: createMockFetch(204, {}, "") + } + const apiClientEffect = createClientEffect(clientOptions) + + const result = yield* apiClientEffect.POST("/api/auth/logout") + + expect(result.response.status).toBe(204) + expect(result.data).toBeUndefined() + expect(result.error).toBeUndefined() }).pipe(Effect.runPromise)) }) diff --git a/packages/app/tests/api-client/create-client-effect.test.ts b/packages/app/tests/api-client/create-client-effect.test.ts index 3e69987..f667c4c 100644 --- a/packages/app/tests/api-client/create-client-effect.test.ts +++ b/packages/app/tests/api-client/create-client-effect.test.ts @@ -1,17 +1,14 @@ -// CHANGE: Add tests for createClientEffect with auth OpenAPI schema -// WHY: Verify the zero-boilerplate DSL works end-to-end with real-world auth schema -// QUOTE(ТЗ): "apiClientEffect.POST('/api/auth/login', { body: credentials }) — Что бы это работало" -// REF: issue-5 +// CHANGE: Runtime tests for createClientEffect with openapi-fetch-compatible envelope +// WHY: Ensure non-2xx is represented in `error` field and Effect error channel is transport-only +// QUOTE(ТЗ): "openapi-effect должен почти 1 в 1 заменяться с openapi-fetch" +// REF: user-msg-2026-02-12 // SOURCE: n/a -// FORMAT THEOREM: ∀ op ∈ AuthOperations: createClientEffect(options).METHOD(path, opts) → Effect // PURITY: SHELL // EFFECT: Effect -// INVARIANT: HTTP statuses are returned as values, boundary failures stay in error channel +// INVARIANT: Success/error envelopes follow openapi-fetch contract // COMPLEXITY: O(1) per test -import * as HttpClient from "@effect/platform/HttpClient" -import * as HttpClientResponse from "@effect/platform/HttpClientResponse" -import { Effect, Layer } from "effect" +import { Effect, Either } from "effect" import { describe, expect, it } from "vitest" import type { paths } from "../../src/core/api/openapi.js" @@ -20,265 +17,123 @@ import { createClientEffect } from "../../src/shell/api-client/create-client.js" type AuthPaths = paths & object -/** - * Test fixtures for auth API testing - * - * @pure true - immutable test data - */ -const fixtures = { - loginBody: () => ({ email: "user@example.com", password: `test-${Date.now()}` }), - wrongLoginBody: () => ({ email: "user@example.com", password: `wrong-${Date.now()}` }), - registerBody: () => ({ token: "invite-token-123", password: `Pass-${Date.now()}!` }) -} as const - -/** - * Create a mock HttpClient layer that returns a fixed response - * - * @pure true - returns pure layer - */ -const createMockHttpClientLayer = ( +const createMockFetch = ( status: number, headers: Record, body: string -): Layer.Layer => - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make( - (request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - status === 204 || status === 304 - ? new Response(null, { status, headers: new Headers(headers) }) - : new Response(body, { status, headers: new Headers(headers) }) - ) - ) +) => +(_request: Request) => + Effect.runPromise( + Effect.succeed( + status === 204 || status === 304 + ? new Response(null, { status, headers: new Headers(headers) }) + : new Response(body, { status, headers: new Headers(headers) }) ) ) -describe("createClientEffect (zero-boilerplate, auth schema)", () => { - const clientOptions: ClientOptions = { - baseUrl: "https://petstore.example.com", - credentials: "include" - } - const apiClientEffect = createClientEffect(clientOptions) +const createFailingFetch = (message: string) => (_request: Request) => + Effect.runPromise(Effect.fail(new Error(message))) - it("should POST /api/auth/login with body and return 200 success", () => +describe("createClientEffect", () => { + it("returns success envelope for login 200", () => Effect.gen(function*() { - const credentials = fixtures.loginBody() const successBody = JSON.stringify({ id: "550e8400-e29b-41d4-a716-446655440000", - email: "user@example.com", - firstName: "John", - lastName: "Doe", - profileImageUrl: null, - emailVerified: true, - phoneVerified: false + email: "user@example.com" }) - const result = yield* apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( - Effect.provide( - createMockHttpClientLayer(200, { "content-type": "application/json" }, successBody) - ) - ) - - expect("_tag" in result).toBe(false) - expect(result.status).toBe(200) - expect(result.contentType).toBe("application/json") - const body = result.body as { id: string; email: string } - expect(body.id).toBe("550e8400-e29b-41d4-a716-446655440000") - expect(body.email).toBe("user@example.com") - }).pipe(Effect.runPromise)) - - it("should return HttpError value for 401 invalid_credentials", () => - Effect.gen(function*() { - const credentials = fixtures.wrongLoginBody() - const errorBody = JSON.stringify({ error: "invalid_credentials" }) - - const result = yield* apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( - Effect.provide( - createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody) - ) - ) - - expect("_tag" in result).toBe(true) - if ("_tag" in result) { - expect(result).toMatchObject({ - _tag: "HttpError", - status: 401 - }) - const body = result.body as { error: string } - expect(body.error).toBe("invalid_credentials") + const clientOptions: ClientOptions = { + baseUrl: "https://petstore.example.com", + credentials: "include", + fetch: createMockFetch(200, { "content-type": "application/json" }, successBody) } - }).pipe(Effect.runPromise)) + const apiClientEffect = createClientEffect(clientOptions) - it("should return HttpError value for 400 bad request", () => - Effect.gen(function*() { - const credentials = fixtures.loginBody() - const errorBody = JSON.stringify({ error: "invalid_payload" }) - - const result = yield* apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( - Effect.provide( - createMockHttpClientLayer(400, { "content-type": "application/json" }, errorBody) - ) - ) + const generatedPassword = `ok-${Date.now()}` + const result = yield* apiClientEffect.POST("/api/auth/login", { + body: { email: "user@example.com", password: generatedPassword } + }) - expect("_tag" in result).toBe(true) - if ("_tag" in result) { - expect(result).toMatchObject({ - _tag: "HttpError", - status: 400 - }) - } + expect(result.response.status).toBe(200) + expect(result.error).toBeUndefined() + expect(result.data).toMatchObject({ email: "user@example.com" }) }).pipe(Effect.runPromise)) - it("should return HttpError value for 500 internal_error", () => + it("returns error envelope for login 401", () => Effect.gen(function*() { - const credentials = fixtures.loginBody() - const errorBody = JSON.stringify({ error: "internal_error" }) - - const result = yield* apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( - Effect.provide( - createMockHttpClientLayer(500, { "content-type": "application/json" }, errorBody) - ) - ) + const errorBody = JSON.stringify({ error: "invalid_credentials" }) - expect("_tag" in result).toBe(true) - if ("_tag" in result) { - expect(result).toMatchObject({ - _tag: "HttpError", - status: 500 - }) + const clientOptions: ClientOptions = { + baseUrl: "https://petstore.example.com", + credentials: "include", + fetch: createMockFetch(401, { "content-type": "application/json" }, errorBody) } - }).pipe(Effect.runPromise)) + const apiClientEffect = createClientEffect(clientOptions) - it("should POST /api/auth/logout and return 204 no-content success", () => - Effect.gen(function*() { - const result = yield* apiClientEffect.POST("/api/auth/logout").pipe( - Effect.provide( - createMockHttpClientLayer(204, {}, "") - ) - ) - - expect("_tag" in result).toBe(false) - expect(result.status).toBe(204) - expect(result.contentType).toBe("none") - expect(result.body).toBeUndefined() - }).pipe(Effect.runPromise)) - - it("should GET /api/auth/me and return 200 with user profile", () => - Effect.gen(function*() { - const profileBody = JSON.stringify({ - id: "550e8400-e29b-41d4-a716-446655440000", - email: "user@example.com", - firstName: "John", - lastName: "Doe", - profileImageUrl: null, - emailVerified: true, - phoneVerified: false, - birthDate: null, - about: null, - messengers: [], - memberships: [], - adminProjectIds: [], - workEmail: null, - workPhone: null + const generatedPassword = `bad-${Date.now()}` + const result = yield* apiClientEffect.POST("/api/auth/login", { + body: { email: "user@example.com", password: generatedPassword } }) - const result = yield* apiClientEffect.GET("/api/auth/me").pipe( - Effect.provide( - createMockHttpClientLayer(200, { "content-type": "application/json" }, profileBody) - ) - ) - - expect("_tag" in result).toBe(false) - expect(result.status).toBe(200) - const body = result.body as { email: string; firstName: string } - expect(body.email).toBe("user@example.com") - expect(body.firstName).toBe("John") + expect(result.response.status).toBe(401) + expect(result.data).toBeUndefined() + expect(result.error).toMatchObject({ error: "invalid_credentials" }) }).pipe(Effect.runPromise)) - it("should GET /api/auth/me and return HttpError value for 401 unauthorized", () => + it("returns undefined data for 204", () => Effect.gen(function*() { - const errorBody = JSON.stringify({ error: "unauthorized" }) + const clientOptions: ClientOptions = { + baseUrl: "https://petstore.example.com", + credentials: "include", + fetch: createMockFetch(204, {}, "") + } + const apiClientEffect = createClientEffect(clientOptions) - const result = yield* apiClientEffect.GET("/api/auth/me").pipe( - Effect.provide( - createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody) - ) - ) + const result = yield* apiClientEffect.POST("/api/auth/logout") - expect("_tag" in result).toBe(true) - if ("_tag" in result) { - expect(result).toMatchObject({ - _tag: "HttpError", - status: 401 - }) - } + expect(result.response.status).toBe(204) + expect(result.data).toBeUndefined() + expect(result.error).toBeUndefined() }).pipe(Effect.runPromise)) - it("should POST /api/register with body and return 201 success", () => + it("returns success envelope for register 201", () => Effect.gen(function*() { - const registerBody = fixtures.registerBody() const successBody = JSON.stringify({ id: "550e8400-e29b-41d4-a716-446655440001", - email: "new@example.com", - firstName: "Jane", - lastName: "Doe", - profileImageUrl: null + email: "new@example.com" }) - const result = yield* apiClientEffect.POST("/api/register", { body: registerBody }).pipe( - Effect.provide( - createMockHttpClientLayer(201, { "content-type": "application/json" }, successBody) - ) - ) - - expect("_tag" in result).toBe(false) - expect(result.status).toBe(201) - const body = result.body as { id: string; email: string } - expect(body.email).toBe("new@example.com") - }).pipe(Effect.runPromise)) - - it("should POST /api/register and return HttpError value for 409 user_exists", () => - Effect.gen(function*() { - const registerBody = fixtures.registerBody() - const errorBody = JSON.stringify({ error: "user_exists" }) + const clientOptions: ClientOptions = { + baseUrl: "https://petstore.example.com", + credentials: "include", + fetch: createMockFetch(201, { "content-type": "application/json" }, successBody) + } + const apiClientEffect = createClientEffect(clientOptions) - const result = yield* apiClientEffect.POST("/api/register", { body: registerBody }).pipe( - Effect.provide( - createMockHttpClientLayer(409, { "content-type": "application/json" }, errorBody) - ) - ) + const generatedPassword = `new-${Date.now()}` + const result = yield* apiClientEffect.POST("/api/register", { + body: { token: "invite-token", password: generatedPassword } + }) - expect("_tag" in result).toBe(true) - if ("_tag" in result) { - expect(result).toMatchObject({ - _tag: "HttpError", - status: 409 - }) - const body = result.body as { error: string } - expect(body.error).toBe("user_exists") - } + expect(result.response.status).toBe(201) + expect(result.error).toBeUndefined() + expect(result.data).toMatchObject({ email: "new@example.com" }) }).pipe(Effect.runPromise)) - it("should return UnexpectedContentType for non-JSON response", () => + it("keeps transport failures in Effect error channel", () => Effect.gen(function*() { - const credentials = fixtures.loginBody() + const clientOptions: ClientOptions = { + baseUrl: "https://petstore.example.com", + credentials: "include", + fetch: createFailingFetch("network down") + } + const apiClientEffect = createClientEffect(clientOptions) - const result = yield* apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( - Effect.provide( - createMockHttpClientLayer(200, { "content-type": "text/html" }, "error") - ), - Effect.catchTag("UnexpectedContentType", (error) => Effect.succeed(error)) - ) + const outcome = yield* Effect.either(apiClientEffect.GET("/api/auth/me")) - expect("_tag" in result).toBe(true) - if ("_tag" in result) { - expect(result).toMatchObject({ - _tag: "UnexpectedContentType", - status: 200 - }) + expect(Either.isLeft(outcome)).toBe(true) + if (Either.isLeft(outcome)) { + expect(outcome.left.message).toContain("network down") } }).pipe(Effect.runPromise)) }) diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index c4c320e..bc4c415 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -13,7 +13,6 @@ "include": [ "src/**/*", "tests/**/*", - "examples/**/*", "vite.config.ts", "vitest.config.ts" ], From 166df37553952393fb14329cb640cf2827e95462 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:59:03 +0000 Subject: [PATCH 6/6] fix(shell): avoid Effect.catchAll in api client --- .../app/src/shell/api-client/create-client-runtime.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/app/src/shell/api-client/create-client-runtime.ts b/packages/app/src/shell/api-client/create-client-runtime.ts index 8e96fb5..adef39f 100644 --- a/packages/app/src/shell/api-client/create-client-runtime.ts +++ b/packages/app/src/shell/api-client/create-client-runtime.ts @@ -219,9 +219,11 @@ const executeFetch = ( const request = requestPhase.request const response = requestPhase.response ?? ( - yield* Effect.catchAll( - invokeFetch(prepared.fetch, request, prepared.requestInitExt), - (fetchError) => applyErrorMiddleware(request, fetchError, prepared.context) + yield* invokeFetch(prepared.fetch, request, prepared.requestInitExt).pipe( + Effect.matchEffect({ + onFailure: (fetchError) => applyErrorMiddleware(request, fetchError, prepared.context), + onSuccess: (response) => Effect.succeed(response) + }) ) )