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..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', }, @@ -358,6 +438,6 @@ export default defineConfig( extends: [tseslint.configs.disableTypeChecked], }, - // 6) Глобальные игноры + // 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 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/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..1879070 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 "openapi-fetch" -export { default as createClient } from "openapi-fetch" -export * from "openapi-fetch" - -// 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,17 +19,29 @@ export type { export type { Decoder, Dispatcher, RawResponse, StrictClient, StrictRequestInit } from "./shell/api-client/index.js" export { - createClient as createClientStrict, + 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 { 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..adef39f --- /dev/null +++ b/packages/app/src/shell/api-client/create-client-runtime.ts @@ -0,0 +1,316 @@ +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* invokeFetch(prepared.fetch, request, prepared.requestInitExt).pipe( + Effect.matchEffect({ + onFailure: (fetchError) => applyErrorMiddleware(request, fetchError, prepared.context), + onSuccess: (response) => Effect.succeed(response) + }) + ) + ) + + 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 34970e7..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,310 +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 { ClientOptions as OpenapiFetchClientOptions } from "openapi-fetch" -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" - -/** - * Client configuration options - * - * @pure - immutable configuration - */ -export type ClientOptions = OpenapiFetchClientOptions - -// 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>> + 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 | Array | null | undefined + > + +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 +} + +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 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 DispatchersFor = - & DispatchersForMethod - & DispatchersForMethod - & DispatchersForMethod - & DispatchersForMethod - & DispatchersForMethod - & DispatchersForMethod - & DispatchersForMethod +export type AsyncValue = T | Thenable + +export type MiddlewareOnRequest = ( + options: MiddlewareCallbackParams +) => AsyncValue + +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 ResponsesForOperation< +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" ], 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: