diff --git a/README.md b/README.md index 3fc3fff..c0b8f9a 100644 --- a/README.md +++ b/README.md @@ -8,29 +8,42 @@ Drop-in replacement for `openapi-fetch` with an opt-in Effect API. pnpm add @prover-coder-ai/openapi-effect ``` -## Usage (Promise API) +## Usage (Envelope API) -This package implements an `openapi-fetch` compatible API, so most code can be migrated by changing only the import. +This package implements `openapi-fetch` compatible method inputs and response envelopes. ```ts +import { Effect } from "effect" import createClient from "@prover-coder-ai/openapi-effect" import type { paths } from "./openapi" const client = createClient({ baseUrl: "https://api.example.com" }) -const { data, error } = await client.GET("/pets", { - params: { query: { limit: 10 } } -}) +const program = Effect.gen(function* () { + const { data, error } = yield* client.GET("/pets", { + params: { query: { limit: 10 } } + }) -if (error) { - // handle error -} + return error ?? data +}) ``` ## Usage (Effect API) -Effect-based client is available as an opt-in API. +`createClientEffect` keeps the same method inputs but moves non-2xx responses into the Effect error channel. ```ts -import { createClientEffect, FetchHttpClient } from "@prover-coder-ai/openapi-effect" +import { Effect } from "effect" +import { createClientEffect } from "@prover-coder-ai/openapi-effect" +import type { paths } from "./openapi" + +const client = createClientEffect({ baseUrl: "https://api.example.com" }) + +const program = Effect.gen(function* () { + const result = yield* client.GET("/pets", { + params: { query: { limit: 10 } } + }) + + return result.body +}) ``` diff --git a/packages/app/src/core/axioms.ts b/packages/app/src/core/axioms.ts index c88853e..81242fe 100644 --- a/packages/app/src/core/axioms.ts +++ b/packages/app/src/core/axioms.ts @@ -141,3 +141,12 @@ export const asStrictApiClient = (client: object): T => client as T * @pure true */ export const asDispatchersFor = (value: unknown): T => value as T + +/** + * Cast middleware callback output after async boundary normalization. + * AXIOM: Middleware runtime validation checks the concrete Request/Response/Error + * shape before the value is used to modify execution. + * + * @pure true + */ +export const asMiddlewareResult = (value: unknown): T => value as T diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 1879070..e2d5f7a 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -10,8 +10,19 @@ export type * from "./core/api-client/index.js" export { assertNever } from "./core/api-client/index.js" export type { + Client, + ClientEffect, ClientOptions, DispatchersFor, + EffectClient, + EffectClientMethod, + EffectClientRequestMethod, + FetchOptions, + FetchResponse, + Middleware, + PathBasedClient, + QuerySerializer, + QuerySerializerOptions, StrictApiClient, StrictApiClientWithDispatchers } from "./shell/api-client/create-client.js" diff --git a/packages/app/src/shell/api-client/create-client-effect-types.ts b/packages/app/src/shell/api-client/create-client-effect-types.ts new file mode 100644 index 0000000..2643945 --- /dev/null +++ b/packages/app/src/shell/api-client/create-client-effect-types.ts @@ -0,0 +1,76 @@ +// CHANGE: Define Effect-channel client types over openapi-fetch-compatible inputs +// WHY: Method inputs stay derived from openapi-fetch helpers while output is inferred from operation responses +// QUOTE(ТЗ): "output должен отличаться тем что он стаёт Effect ... input должен быть 1 в 1" +// REF: user-msg-openapi-effect-input-compat +// SOURCE: n/a +// PURITY: CORE - compile-time types only +// EFFECT: Effect, ApiFailure, never> +// INVARIANT: ∀ call: Path ∧ Method select exactly one OpenAPI operation response set +// COMPLEXITY: O(1) runtime / compile-time only + +import type { Effect } from "effect" +import type { HttpMethod, PathsWithMethod } from "openapi-typescript-helpers" + +import type { ApiFailure, ApiSuccess, ResponsesFor } from "../../core/api-client/strict-types.js" +import type { + MaybeOptionalInit, + MethodArgs, + Middleware, + OperationFor, + RequestMethodArgs +} from "./create-client-types.js" + +type EffectMethodResult< + Paths extends object, + Path extends PathsWithMethod, + Method extends HttpMethod +> = Effect.Effect< + ApiSuccess>>, + ApiFailure>> +> + +type EffectPath = PathsWithMethod + +type EffectInit< + Paths extends object, + Method extends HttpMethod, + Path extends EffectPath +> = MaybeOptionalInit> + +export interface EffectClientMethod< + Paths extends object, + Method extends HttpMethod +> { + < + Path extends EffectPath, + Init extends EffectInit + >( + ...args: MethodArgs + ): EffectMethodResult +} + +export interface EffectClientRequestMethod { + < + Method extends HttpMethod, + Path extends EffectPath, + Init extends EffectInit + >( + ...args: RequestMethodArgs> + ): EffectMethodResult +} + +export interface EffectClient { + request: EffectClientRequestMethod + GET: EffectClientMethod + PUT: EffectClientMethod + POST: EffectClientMethod + DELETE: EffectClientMethod + OPTIONS: EffectClientMethod + HEAD: EffectClientMethod + PATCH: EffectClientMethod + TRACE: EffectClientMethod + use(...middleware: Array): void + eject(...middleware: Array): void +} + +export type ClientEffect = EffectClient diff --git a/packages/app/src/shell/api-client/create-client-middleware.ts b/packages/app/src/shell/api-client/create-client-middleware.ts index 6694c05..ff84cc2 100644 --- a/packages/app/src/shell/api-client/create-client-middleware.ts +++ b/packages/app/src/shell/api-client/create-client-middleware.ts @@ -1,7 +1,8 @@ import { Effect } from "effect" +import { asMiddlewareResult } from "../../core/axioms.js" import { toError } from "./create-client-response.js" -import type { AsyncValue, MergedOptions, Middleware, MiddlewareRequestParams, Thenable } from "./create-client-types.js" +import type { MergedOptions, Middleware, MiddlewareRequestParams, Thenable } from "./create-client-types.js" const isThenable = (value: unknown): value is Thenable => ( typeof value === "object" @@ -10,19 +11,27 @@ const isThenable = (value: unknown): value is Thenable => ( && typeof Reflect.get(value, "then") === "function" ) -export const toPromiseEffect = (value: AsyncValue): Effect.Effect => ( +const succeedMiddlewareResult = (value: unknown): Effect.Effect => { + if (value === undefined) { + return Effect.sync((): undefined => undefined) + } + + return Effect.succeed(asMiddlewareResult(value)) +} + +export const toPromiseEffect = (value: unknown): Effect.Effect => ( isThenable(value) - ? Effect.async((resume) => { + ? Effect.async((resume) => { value.then( (result) => { - resume(Effect.succeed(result)) + resume(succeedMiddlewareResult(result)) }, (error) => { resume(Effect.fail(toError(error))) } ) }) - : Effect.succeed(value) + : succeedMiddlewareResult(value) ) export type MiddlewareContext = { @@ -80,7 +89,9 @@ export const applyRequestMiddleware = ( continue } - const result = yield* toPromiseEffect(item.onRequest(createMiddlewareParams(nextRequest, context))) + const result = yield* toPromiseEffect( + item.onRequest(createMiddlewareParams(nextRequest, context)) + ) if (result === undefined) { continue @@ -116,10 +127,12 @@ export const applyResponseMiddleware = ( continue } - const result = yield* toPromiseEffect(item.onResponse({ - ...createMiddlewareParams(request, context), - response: nextResponse - })) + const result = yield* toPromiseEffect( + item.onResponse({ + ...createMiddlewareParams(request, context), + response: nextResponse + }) + ) if (result === undefined) { continue @@ -160,10 +173,12 @@ export const applyErrorMiddleware = ( continue } - const rawResult = yield* toPromiseEffect(item.onError({ - ...createMiddlewareParams(request, context), - error: nextError - })) + const rawResult = yield* toPromiseEffect( + item.onError({ + ...createMiddlewareParams(request, context), + error: nextError + }) + ) const result = yield* normalizeErrorResult(rawResult) if (result instanceof Response) { diff --git a/packages/app/src/shell/api-client/create-client-response.ts b/packages/app/src/shell/api-client/create-client-response.ts index 58601ae..e9117f4 100644 --- a/packages/app/src/shell/api-client/create-client-response.ts +++ b/packages/app/src/shell/api-client/create-client-response.ts @@ -1,6 +1,13 @@ import { Effect } from "effect" +import type { + BoundaryError, + ParseError, + TransportError, + UnexpectedContentType +} from "../../core/api-client/strict-types.js" import { asJson } from "../../core/axioms.js" +import type { RuntimeApiSuccess, RuntimeEffectFailure, RuntimeHttpError } from "./create-client-runtime-types.js" import type { ParseAs } from "./create-client-types.js" type RuntimeFetchResponse = { @@ -13,6 +20,11 @@ export const toError = (error: unknown): Error => ( error instanceof Error ? error : new Error(String(error)) ) +export const toTransportError = (error: unknown): TransportError => ({ + _tag: "TransportError", + error: toError(error) +}) + const parseJsonText = (rawText: string): Effect.Effect => ( rawText.length === 0 ? Effect.void @@ -29,6 +41,13 @@ const readResponseText = (response: Response): Effect.Effect => ( }) ) +const readResponseTextStrict = (response: Response): Effect.Effect => ( + Effect.tryPromise({ + try: () => response.text(), + catch: toTransportError + }) +) + const parseSuccessData = ( response: Response, parseAs: ParseAs, @@ -113,3 +132,171 @@ export const createResponseEnvelope = ( Effect.map((error) => ({ error, response })) ) } + +const normalizeContentType = (value: string | null): string | undefined => ( + value?.split(";")[0]?.trim().toLowerCase() +) + +const resolveContentType = (response: Response, fallback: string): string => ( + normalizeContentType(response.headers.get("content-type")) ?? fallback +) + +const createParseError = ( + status: number, + contentType: string, + body: string, + error: unknown +): ParseError => ({ + _tag: "ParseError", + status, + contentType, + error: toError(error), + body +}) + +const createUnexpectedContentType = ( + response: Response, + body: string +): UnexpectedContentType => ({ + _tag: "UnexpectedContentType", + status: response.status, + expected: ["application/json"], + actual: response.headers.get("content-type") ?? undefined, + body +}) + +const parseJsonStrict = ( + response: Response, + body: string +): Effect.Effect => { + const contentType = response.headers.get("content-type") + if (normalizeContentType(contentType) !== "application/json") { + return Effect.fail(createUnexpectedContentType(response, body)) + } + + if (body.length === 0) { + return Effect.void + } + + return Effect.try({ + try: () => asJson(JSON.parse(body)), + catch: (error) => createParseError(response.status, "application/json", body, error) + }) +} + +type StrictParsedBody = { + readonly contentType: string + readonly body: unknown +} + +const mapStrictBinary = ( + response: Response, + bodyEffect: Effect.Effect +): Effect.Effect => + bodyEffect.pipe( + Effect.map((body) => ({ + contentType: resolveContentType(response, "application/octet-stream"), + body + })) + ) + +const parseStrictBlob = (response: Response): Effect.Effect => + mapStrictBinary( + response, + Effect.tryPromise({ + try: () => response.blob(), + catch: toTransportError + }) + ) + +const parseStrictArrayBuffer = (response: Response): Effect.Effect => + mapStrictBinary( + response, + Effect.tryPromise({ + try: () => response.arrayBuffer(), + catch: toTransportError + }) + ) + +const parseStrictData = ( + response: Response, + parseAs: ParseAs +): Effect.Effect => { + if (parseAs === "stream") { + return Effect.succeed({ + contentType: resolveContentType(response, "stream"), + body: response.body + }) + } + + if (parseAs === "text") { + return readResponseTextStrict(response).pipe( + Effect.map((body) => ({ + contentType: resolveContentType(response, "text/plain"), + body + })) + ) + } + + if (parseAs === "blob") { + return parseStrictBlob(response) + } + + if (parseAs === "arrayBuffer") { + return parseStrictArrayBuffer(response) + } + + return readResponseTextStrict(response).pipe( + Effect.flatMap((body) => + parseJsonStrict(response, body).pipe( + Effect.map((parsed) => ({ + contentType: "application/json", + body: parsed + })) + ) + ) + ) +} + +const createStrictVariant = ( + response: Response, + parsed: StrictParsedBody +): RuntimeApiSuccess => ({ + status: response.status, + contentType: parsed.contentType, + body: parsed.body +}) + +const createStrictHttpError = (success: RuntimeApiSuccess): RuntimeHttpError => ({ + _tag: "HttpError", + ...success +}) + +export const createStrictResponseEffect = ( + request: Request, + response: Response, + parseAs: ParseAs +): Effect.Effect => { + const contentLength = response.headers.get("Content-Length") + + if (isEmptyResponse(request, response, contentLength)) { + const variant: RuntimeApiSuccess = { + status: response.status, + contentType: "none", + body: undefined + } + + return response.ok + ? Effect.succeed(variant) + : Effect.fail(createStrictHttpError(variant)) + } + + return parseStrictData(response, parseAs).pipe( + Effect.flatMap((parsed) => { + const variant = createStrictVariant(response, parsed) + return response.ok + ? Effect.succeed(variant) + : Effect.fail(createStrictHttpError(variant)) + }) + ) +} diff --git a/packages/app/src/shell/api-client/create-client-runtime-methods.ts b/packages/app/src/shell/api-client/create-client-runtime-methods.ts new file mode 100644 index 0000000..e0af483 --- /dev/null +++ b/packages/app/src/shell/api-client/create-client-runtime-methods.ts @@ -0,0 +1,54 @@ +// CHANGE: Extract runtime method builder shared by envelope and Effect clients +// WHY: Keep runtime module below lint size limits without duplicating method implementations +// QUOTE(ТЗ): "input должен быть 1 в 1" +// REF: user-msg-openapi-effect-input-compat +// SOURCE: n/a +// PURITY: SHELL +// EFFECT: none - builds lazy Effect-returning methods +// INVARIANT: All HTTP verb methods preserve the same `(url, init?)` runtime shape +// COMPLEXITY: O(1) + +import type { Effect } from "effect" + +import type { RuntimeClientFor, RuntimeFetchOptions } from "./create-client-runtime-types.js" +import type { Middleware } from "./create-client-types.js" + +export type CoreFetch = ( + schemaPath: string, + fetchOptions?: RuntimeFetchOptions +) => Effect.Effect + +const hasMiddlewareHook = (value: Middleware): boolean => ( + "onRequest" in value || "onResponse" in value || "onError" in value +) + +export const createClientMethods = ( + coreFetch: CoreFetch, + globalMiddlewares: Array +): RuntimeClientFor => ({ + 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) + } + } + } +}) 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 index 51304e6..5d0aa3a 100644 --- a/packages/app/src/shell/api-client/create-client-runtime-types.ts +++ b/packages/app/src/shell/api-client/create-client-runtime-types.ts @@ -1,5 +1,6 @@ import type { Effect } from "effect" +import type { BoundaryError } from "../../core/api-client/strict-types.js" import type { MiddlewareContext } from "./create-client-middleware.js" import type { BodySerializer, @@ -19,6 +20,18 @@ export type RuntimeFetchResponse = { response: Response } +export type RuntimeApiSuccess = { + readonly status: number + readonly contentType: string + readonly body: unknown +} + +export type RuntimeHttpError = RuntimeApiSuccess & { + readonly _tag: "HttpError" +} + +export type RuntimeEffectFailure = RuntimeHttpError | BoundaryError + export type RuntimeFetchOptions = Omit & { baseUrl?: string fetch?: NonNullable @@ -35,20 +48,23 @@ export type RuntimeFetchOptions = Omit 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 +export type RuntimeClientFor = { + 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 RuntimeClient = RuntimeClientFor +export type RuntimeEffectClient = RuntimeClientFor + export type HeaderValue = | string | number diff --git a/packages/app/src/shell/api-client/create-client-runtime.ts b/packages/app/src/shell/api-client/create-client-runtime.ts index adef39f..252fe63 100644 --- a/packages/app/src/shell/api-client/create-client-runtime.ts +++ b/packages/app/src/shell/api-client/create-client-runtime.ts @@ -1,7 +1,7 @@ import { Effect } from "effect" import { applyErrorMiddleware, applyRequestMiddleware, applyResponseMiddleware } from "./create-client-middleware.js" -import { createResponseEnvelope } from "./create-client-response.js" +import { createResponseEnvelope, createStrictResponseEffect, toTransportError } from "./create-client-response.js" import { createMergedOptions, invokeFetch, @@ -13,10 +13,12 @@ import { toHeaderOverrides } from "./create-client-runtime-helpers.js" import type { SerializedBody } from "./create-client-runtime-helpers.js" +import { createClientMethods } from "./create-client-runtime-methods.js" import type { BaseRuntimeConfig, PreparedRequest, RuntimeClient, + RuntimeEffectClient, RuntimeFetchOptions, RuntimeFetchResponse } from "./create-client-runtime-types.js" @@ -243,9 +245,18 @@ const createCoreFetch = (config: BaseRuntimeConfig) => return yield* createResponseEnvelope(execution.request, execution.response, prepared.parseAs) }) -const hasMiddlewareHook = (value: Middleware): boolean => ( - "onRequest" in value || "onResponse" in value || "onError" in value -) +const createCoreEffectFetch = (config: BaseRuntimeConfig) => +( + schemaPath: string, + fetchOptions?: RuntimeFetchOptions +) => + Effect.gen(function*() { + const prepared = prepareRequest(config, schemaPath, fetchOptions) + const execution = yield* executeFetch(prepared).pipe( + Effect.mapError(toTransportError) + ) + return yield* createStrictResponseEffect(execution.request, execution.response, prepared.parseAs) + }) const createBaseRuntimeConfig = ( clientOptions: ClientOptions | undefined, @@ -277,40 +288,16 @@ const createBaseRuntimeConfig = ( } } -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) } + +export const createRuntimeEffectClient = (clientOptions?: ClientOptions): RuntimeEffectClient => { + const globalMiddlewares: Array = [] + const config = createBaseRuntimeConfig(clientOptions, globalMiddlewares) + const coreFetch = createCoreEffectFetch(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 12f6fcf..e6865f4 100644 --- a/packages/app/src/shell/api-client/create-client-types.ts +++ b/packages/app/src/shell/api-client/create-client-types.ts @@ -151,19 +151,19 @@ export type Thenable = { ) => unknown } -export type AsyncValue = T | Thenable +export type AsyncValue = T | undefined | Thenable -export type MiddlewareOnRequest = ( - options: MiddlewareCallbackParams -) => AsyncValue +export type MiddlewareOnRequest = + | ((options: MiddlewareCallbackParams) => AsyncValue) + | ((options: MiddlewareCallbackParams) => void) -export type MiddlewareOnResponse = ( - options: MiddlewareCallbackParams & { response: Response } -) => AsyncValue +export type MiddlewareOnResponse = + | ((options: MiddlewareCallbackParams & { response: Response }) => AsyncValue) + | ((options: MiddlewareCallbackParams & { response: Response }) => void) -export type MiddlewareOnError = ( - options: MiddlewareCallbackParams & { error: unknown } -) => AsyncValue +export type MiddlewareOnError = + | ((options: MiddlewareCallbackParams & { error: unknown }) => AsyncValue) + | ((options: MiddlewareCallbackParams & { error: unknown }) => void) export type Middleware = | { @@ -187,10 +187,10 @@ export type MaybeOptionalInit = RequiredK > extends never ? FetchOptions> | undefined : FetchOptions> -type InitParam = RequiredKeysOf extends never ? [(Init & { [key: string]: unknown })?] +export type InitParam = RequiredKeysOf extends never ? [(Init & { [key: string]: unknown })?] : [Init & { [key: string]: unknown }] -type OperationFor< +export type OperationFor< Paths extends object, Path extends keyof Paths, Method extends HttpMethod @@ -208,6 +208,18 @@ type MethodResult< Error > +export type MethodArgs< + Paths extends object, + Method extends HttpMethod, + Path extends PathsWithMethod, + Init extends MaybeOptionalInit> +> = [url: Path, ...init: InitParam] + +export type RequestMethodArgs> = [ + method: Method, + ...args: Args +] + export type ClientMethod< Paths extends object, Method extends HttpMethod, @@ -216,8 +228,7 @@ export type ClientMethod< Path extends PathsWithMethod, Init extends MaybeOptionalInit> >( - url: Path, - ...init: InitParam + ...args: MethodArgs ) => MethodResult export type ClientRequestMethod< @@ -228,9 +239,7 @@ export type ClientRequestMethod< Path extends PathsWithMethod, Init extends MaybeOptionalInit> >( - method: Method, - url: Path, - ...init: InitParam + ...args: RequestMethodArgs> ) => MethodResult type PathMethodResult< @@ -299,4 +308,3 @@ export type DispatchersFor = { 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 6e7edb4..5f9ba5d 100644 --- a/packages/app/src/shell/api-client/create-client.ts +++ b/packages/app/src/shell/api-client/create-client.ts @@ -1,13 +1,13 @@ import type { MediaType } from "openapi-typescript-helpers" import { asStrictApiClient } from "../../core/axioms.js" +import type { ClientEffect, EffectClient } from "./create-client-effect-types.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" +import { createRuntimeClient, createRuntimeEffectClient } from "./create-client-runtime.js" +import type { Client, ClientOptions, DispatchersFor, PathBasedClient } from "./create-client-types.js" export type { Client, - ClientEffect, ClientForPath, ClientMethod, ClientOptions, @@ -30,6 +30,13 @@ export type { StrictApiClientWithDispatchers } from "./create-client-types.js" +export type { + ClientEffect, + EffectClient, + EffectClientMethod, + EffectClientRequestMethod +} from "./create-client-effect-types.js" + export { createFinalURL, createQuerySerializer, @@ -103,7 +110,7 @@ export const createPathBasedClient = < export const createClientEffect = ( clientOptions?: ClientOptions -): ClientEffect => createClient(clientOptions) +): ClientEffect => asStrictApiClient>(createRuntimeEffectClient(clientOptions)) export const registerDefaultDispatchers = ( _dispatchers: DispatchersFor diff --git a/packages/app/src/shell/api-client/index.ts b/packages/app/src/shell/api-client/index.ts index 16c2ee0..d9e9c01 100644 --- a/packages/app/src/shell/api-client/index.ts +++ b/packages/app/src/shell/api-client/index.ts @@ -33,6 +33,9 @@ export type { ClientForPath, ClientOptions, DispatchersFor, + EffectClient, + EffectClientMethod, + EffectClientRequestMethod, FetchOptions, FetchResponse, HeadersOptions, diff --git a/packages/app/tests/api-client/auth-type-tests.test.ts b/packages/app/tests/api-client/auth-type-tests.test.ts index b71728c..8c3a85d 100644 --- a/packages/app/tests/api-client/auth-type-tests.test.ts +++ b/packages/app/tests/api-client/auth-type-tests.test.ts @@ -9,6 +9,7 @@ import { describe, expectTypeOf, it } from "vitest" +import type { Effect } from "effect" import type { ApiFailure, ApiSuccess, @@ -17,6 +18,7 @@ import type { RequestOptionsFor } from "../../src/core/api-client/strict-types.js" import type { operations, paths } from "../../src/core/api/openapi.js" +import { createClientEffect } from "../../src/index.js" // Response types for each auth operation type LoginResponses = operations["auth.postLogin"]["responses"] @@ -178,3 +180,62 @@ describe("Auth schema: RequestOptionsFor body constraints", () => { expectTypeOf().toEqualTypeOf() }) }) + +// ============================================================================= +// SECTION: createClientEffect keeps openapi-fetch input shape and infers output +// ============================================================================= + +describe("createClientEffect: input compatibility with inferred Effect output", () => { + const client = createClientEffect() + + it("infers login success and failure channels from the path and method", () => { + const generatedPassword = `pw-${Date.now()}` + const _result = client.POST("/api/auth/login", { + body: { email: "user@example.com", password: generatedPassword } + }) + + expectTypeOf().toEqualTypeOf< + Effect.Effect, ApiFailure> + >() + }) + + it("does not require dispatcher or output schema in method options", () => { + const generatedPassword = `pw-${Date.now()}` + const _login = client.POST("/api/auth/login", { + body: { email: "user@example.com", password: generatedPassword } + }) + + const _me = client.GET("/api/auth/me") + const _logout = client.POST("/api/auth/logout") + + expectTypeOf().toEqualTypeOf< + Effect.Effect, ApiFailure> + >() + expectTypeOf().toEqualTypeOf< + Effect.Effect, ApiFailure> + >() + expectTypeOf().toEqualTypeOf< + Effect.Effect, ApiFailure> + >() + }) + + it("accepts openapi-fetch middleware hooks that return void", () => { + client.use({ + onRequest: () => {} + }) + + expectTypeOf().toEqualTypeOf() + }) + + it("rejects missing required request body", () => { + // @ts-expect-error login body is required by the OpenAPI operation + client.POST("/api/auth/login") + expectTypeOf().toEqualTypeOf() + }) + + it("rejects method/path combinations absent from the schema", () => { + // @ts-expect-error /api/auth/login does not define GET + client.GET("/api/auth/login") + expectTypeOf().toEqualTypeOf() + }) +}) 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 5ce3eee..607f3d3 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,14 +1,14 @@ -// CHANGE: Integration test for createClientEffect using openapi-fetch response envelope -// WHY: Validate drop-in input contract with Effect output channel +// CHANGE: Integration test for createClientEffect using openapi-fetch inputs and strict Effect output +// WHY: Validate drop-in input contract with inferred Effect success/error channels // QUOTE(ТЗ): "input 1 в 1 ... output Effect<,,>" // REF: user-msg-2026-02-12 // SOURCE: n/a // PURITY: SHELL // EFFECT: Effect -// INVARIANT: HTTP non-2xx stays in `error` field, transport failures stay in Effect error channel +// INVARIANT: HTTP non-2xx goes to Effect error channel without per-call schema // COMPLEXITY: O(1) per test -import { Effect } from "effect" +import { Effect, Either } from "effect" import { describe, expect, it } from "vitest" import type { paths } from "../../src/core/api/openapi.js" @@ -30,7 +30,7 @@ const createMockFetch = ( ) describe("createClientEffect integration", () => { - it("returns { data, response } for 200 login", () => + it("returns ApiSuccess for 200 login", () => Effect.gen(function*() { const successBody = JSON.stringify({ id: "550e8400-e29b-41d4-a716-446655440000", @@ -49,12 +49,12 @@ describe("createClientEffect integration", () => { body: { email: "user@example.com", password: generatedPassword } }) - expect(result.response.status).toBe(200) - expect(result.error).toBeUndefined() - expect(result.data).toMatchObject({ email: "user@example.com" }) + expect(result.status).toBe(200) + expect(result.contentType).toBe("application/json") + expect(result.body).toMatchObject({ email: "user@example.com" }) }).pipe(Effect.runPromise)) - it("returns { error, response } for 401 login", () => + it("returns HttpError in error channel for 401 login", () => Effect.gen(function*() { const errorBody = JSON.stringify({ error: "invalid_credentials" }) @@ -66,13 +66,21 @@ describe("createClientEffect integration", () => { 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" }) + const result = yield* Effect.either( + apiClientEffect.POST("/api/auth/login", { + body: { email: "user@example.com", password: generatedPassword } + }) + ) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left).toMatchObject({ + _tag: "HttpError", + status: 401, + contentType: "application/json", + body: { error: "invalid_credentials" } + }) + } }).pipe(Effect.runPromise)) it("handles GET without body", () => @@ -91,8 +99,8 @@ describe("createClientEffect integration", () => { const result = yield* apiClientEffect.GET("/api/auth/me") - expect(result.response.status).toBe(200) - expect(result.data).toMatchObject({ email: "user@example.com" }) + expect(result.status).toBe(200) + expect(result.body).toMatchObject({ email: "user@example.com" }) }).pipe(Effect.runPromise)) it("handles 204 no-content", () => @@ -106,8 +114,8 @@ describe("createClientEffect integration", () => { const result = yield* apiClientEffect.POST("/api/auth/logout") - expect(result.response.status).toBe(204) - expect(result.data).toBeUndefined() - expect(result.error).toBeUndefined() + expect(result.status).toBe(204) + expect(result.contentType).toBe("none") + expect(result.body).toBeUndefined() }).pipe(Effect.runPromise)) }) diff --git a/packages/app/tests/api-client/create-client-effect-type-tests.test.ts b/packages/app/tests/api-client/create-client-effect-type-tests.test.ts new file mode 100644 index 0000000..8f2cdd7 --- /dev/null +++ b/packages/app/tests/api-client/create-client-effect-type-tests.test.ts @@ -0,0 +1,88 @@ +// CHANGE: Type-level contract tests for createClientEffect input inference +// WHY: Prove openapi-fetch-style inputs work while output is inferred as Effect channels +// QUOTE(ТЗ): "input должен быть 1 в 1" +// REF: user-msg-openapi-effect-input-compat +// SOURCE: n/a +// PURITY: CORE - compile-time tests only +// EFFECT: none - type assertions at compile time +// INVARIANT: method input types are derived from Paths; output types are derived from operation responses + +import type { Effect } from "effect" +import { describe, expectTypeOf, it } from "vitest" + +import type { ApiFailure, ApiSuccess } from "../../src/core/api-client/strict-types.js" +import { createClientEffect } from "../../src/index.js" +import type { Operations, Paths } from "../fixtures/petstore.openapi.js" + +type PetstorePaths = Paths & object +type ListPetsResponses = Operations["listPets"]["responses"] +type CreatePetResponses = Operations["createPet"]["responses"] +type GetPetResponses = Operations["getPet"]["responses"] + +describe("createClientEffect: openapi-fetch input shape", () => { + const client = createClientEffect() + + it("accepts GET query params and infers listPets response channels", () => { + const _result = client.GET("/pets", { + params: { query: { limit: 10 } } + }) + + expectTypeOf().toEqualTypeOf< + Effect.Effect, ApiFailure> + >() + }) + + it("accepts path params and infers getPet response channels", () => { + const _result = client.GET("/pets/{petId}", { + params: { path: { petId: "42" } } + }) + + expectTypeOf().toEqualTypeOf< + Effect.Effect, ApiFailure> + >() + }) + + it("accepts POST body and infers createPet response channels", () => { + const _result = client.POST("/pets", { + body: { name: "Rex" } + }) + + expectTypeOf().toEqualTypeOf< + Effect.Effect, ApiFailure> + >() + }) + + it("accepts request(method, path, init) with the same nested params", () => { + const _result = client.request("get", "/pets", { + params: { query: { limit: 10 } } + }) + + expectTypeOf().toEqualTypeOf< + Effect.Effect, ApiFailure> + >() + }) + + it("rejects missing path params", () => { + // @ts-expect-error /pets/{petId} requires params.path.petId + client.GET("/pets/{petId}") + expectTypeOf().toEqualTypeOf() + }) + + it("rejects wrong nested path param shape", () => { + // @ts-expect-error petId is required inside params.path + client.GET("/pets/{petId}", { params: { path: {} } }) + expectTypeOf().toEqualTypeOf() + }) + + it("rejects missing POST body", () => { + // @ts-expect-error /pets POST requires request body + client.POST("/pets") + expectTypeOf().toEqualTypeOf() + }) + + it("rejects invalid method/path combinations", () => { + // @ts-expect-error /pets/{petId} does not define POST + client.POST("/pets/{petId}", { body: { name: "Rex" } }) + expectTypeOf().toEqualTypeOf() + }) +}) 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 f667c4c..84157bf 100644 --- a/packages/app/tests/api-client/create-client-effect.test.ts +++ b/packages/app/tests/api-client/create-client-effect.test.ts @@ -1,11 +1,11 @@ -// 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 +// CHANGE: Runtime tests for createClientEffect with openapi-fetch-compatible inputs and strict Effect output +// WHY: Ensure non-2xx is represented in the Effect error channel without per-call output schema // QUOTE(ТЗ): "openapi-effect должен почти 1 в 1 заменяться с openapi-fetch" // REF: user-msg-2026-02-12 // SOURCE: n/a // PURITY: SHELL // EFFECT: Effect -// INVARIANT: Success/error envelopes follow openapi-fetch contract +// INVARIANT: 2xx -> success channel; non-2xx/boundary errors -> error channel // COMPLEXITY: O(1) per test import { Effect, Either } from "effect" @@ -54,9 +54,9 @@ describe("createClientEffect", () => { body: { email: "user@example.com", password: generatedPassword } }) - expect(result.response.status).toBe(200) - expect(result.error).toBeUndefined() - expect(result.data).toMatchObject({ email: "user@example.com" }) + expect(result.status).toBe(200) + expect(result.contentType).toBe("application/json") + expect(result.body).toMatchObject({ email: "user@example.com" }) }).pipe(Effect.runPromise)) it("returns error envelope for login 401", () => @@ -71,13 +71,21 @@ describe("createClientEffect", () => { 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" }) + const result = yield* Effect.either( + apiClientEffect.POST("/api/auth/login", { + body: { email: "user@example.com", password: generatedPassword } + }) + ) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left).toMatchObject({ + _tag: "HttpError", + status: 401, + contentType: "application/json", + body: { error: "invalid_credentials" } + }) + } }).pipe(Effect.runPromise)) it("returns undefined data for 204", () => @@ -91,9 +99,9 @@ describe("createClientEffect", () => { const result = yield* apiClientEffect.POST("/api/auth/logout") - expect(result.response.status).toBe(204) - expect(result.data).toBeUndefined() - expect(result.error).toBeUndefined() + expect(result.status).toBe(204) + expect(result.contentType).toBe("none") + expect(result.body).toBeUndefined() }).pipe(Effect.runPromise)) it("returns success envelope for register 201", () => @@ -115,9 +123,9 @@ describe("createClientEffect", () => { body: { token: "invite-token", password: generatedPassword } }) - expect(result.response.status).toBe(201) - expect(result.error).toBeUndefined() - expect(result.data).toMatchObject({ email: "new@example.com" }) + expect(result.status).toBe(201) + expect(result.contentType).toBe("application/json") + expect(result.body).toMatchObject({ email: "new@example.com" }) }).pipe(Effect.runPromise)) it("keeps transport failures in Effect error channel", () => @@ -133,7 +141,10 @@ describe("createClientEffect", () => { expect(Either.isLeft(outcome)).toBe(true) if (Either.isLeft(outcome)) { - expect(outcome.left.message).toContain("network down") + expect(outcome.left).toMatchObject({ _tag: "TransportError" }) + if (outcome.left._tag === "TransportError") { + expect(outcome.left.error.message).toContain("network down") + } } }).pipe(Effect.runPromise)) })