diff --git a/packages/app/.jscpd.json b/packages/app/.jscpd.json index 4b94bce..fdf8e5b 100644 --- a/packages/app/.jscpd.json +++ b/packages/app/.jscpd.json @@ -13,7 +13,7 @@ "**/tests/api-client/**", "**/src/shell/api-client/create-client.ts", "**/src/index.ts", - "**/src/core/api/openapi.d.ts" + "**/src/core/api/openapi.d.ts" ], "skipComments": true, "ignorePattern": [ diff --git a/packages/app/src/core/api/openapi.d.ts b/packages/app/src/core/api/openapi.d.ts index 8a75f78..bf9036b 100644 --- a/packages/app/src/core/api/openapi.d.ts +++ b/packages/app/src/core/api/openapi.d.ts @@ -1,445 +1,445 @@ export interface paths { - "/api/auth/login": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["auth.postLogin"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/auth/logout": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["auth.postLogout"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/auth/me": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["auth.getMe"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/register": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["registration.postRegister"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + "/api/auth/login": { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + post: operations["auth.postLogin"] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + "/api/auth/logout": { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + post: operations["auth.postLogout"] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + "/api/auth/me": { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get: operations["auth.getMe"] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + "/api/register": { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + post: operations["registration.postRegister"] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } } export interface operations { - "auth.postLogin": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - /** Format: email */ - email: string; - /** Format: password */ - password: string; - }; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: string; - }; - content: { - "application/json": { - id: components["schemas"]["UUID"]; - /** Format: email */ - email: string; - firstName: string; - lastName: string; - profileImageUrl: string | null; - emailVerified: boolean; - phoneVerified: boolean; - }; - }; - }; - /** @description The request did not match the expected schema */ - 400: { - headers: { - [name: string]: string; - }; - content: { - "application/json": - | components["schemas"]["HttpApiDecodeError"] - | { - /** @enum {string} */ - error: "invalid_payload"; - }; - }; - }; - /** @description Error */ - 401: { - headers: { - [name: string]: string; - }; - content: { - "application/json": { - /** @enum {string} */ - error: "invalid_credentials"; - }; - }; - }; - /** @description Error */ - 500: { - headers: { - [name: string]: string; - }; - content: { - "application/json": { - /** @enum {string} */ - error: "internal_error"; - }; - }; - }; - }; - }; - "auth.postLogout": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 204: { - headers: { - [name: string]: string; - }; - content?: never; - }; - /** @description The request did not match the expected schema */ - 400: { - headers: { - [name: string]: string; - }; - content: { - "application/json": components["schemas"]["HttpApiDecodeError"]; - }; - }; - /** @description Error */ - 401: { - headers: { - [name: string]: string; - }; - content: { - "application/json": { - /** @enum {string} */ - error: "unauthorized"; - }; - }; - }; - /** @description Error */ - 500: { - headers: { - [name: string]: string; - }; - content: { - "application/json": - | { - /** @enum {string} */ - error: "internal_error"; - } - | { - /** @enum {string} */ - error: "logout_failed"; - }; - }; - }; - }; - }; - "auth.getMe": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: string; - }; - content: { - "application/json": { - id: components["schemas"]["UUID"]; - /** Format: email */ - email: string; - firstName: string; - lastName: string; - profileImageUrl: string | null; - emailVerified: boolean; - phoneVerified: boolean; - birthDate: string | null; - about: string | null; - messengers: { - /** @enum {string} */ - platform: "telegram" | "whatsapp"; - handle: string; - }[]; - memberships: { - projectId: components["schemas"]["UUID"]; - departmentId: components["schemas"]["UUID"]; - positionId: components["schemas"]["UUID"]; - /** @enum {string} */ - role: "super_admin" | "admin" | "manager"; - }[]; - adminProjectIds: components["schemas"]["UUID"][]; - workEmail: string | null; - workPhone: string | null; - }; - }; - }; - /** @description The request did not match the expected schema */ - 400: { - headers: { - [name: string]: string; - }; - content: { - "application/json": components["schemas"]["HttpApiDecodeError"]; - }; - }; - /** @description Error */ - 401: { - headers: { - [name: string]: string; - }; - content: { - "application/json": { - /** @enum {string} */ - error: "unauthorized"; - }; - }; - }; - /** @description Error */ - 404: { - headers: { - [name: string]: string; - }; - content: { - "application/json": { - /** @enum {string} */ - error: "profile_not_found"; - }; - }; - }; - /** @description Error */ - 500: { - headers: { - [name: string]: string; - }; - content: { - "application/json": { - /** @enum {string} */ - error: "internal_error"; - }; - }; - }; - }; - }; - "registration.postRegister": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - token: string; - /** Format: password */ - password: string; - }; - }; - }; - responses: { - /** @description Success */ - 201: { - headers: { - [name: string]: string; - }; - content: { - "application/json": { - id: components["schemas"]["UUID"]; - /** Format: email */ - email: string; - firstName: string; - lastName: string; - profileImageUrl: string | null; - }; - }; - }; - /** @description The request did not match the expected schema */ - 400: { - headers: { - [name: string]: string; - }; - content: { - "application/json": - | components["schemas"]["HttpApiDecodeError"] - | { - /** @enum {string} */ - error: "invalid_payload"; - } - | { - /** @enum {string} */ - error: "weak_password"; - policy: { - /** @enum {boolean} */ - ok: false; - tooShort: boolean; - missingLower: boolean; - missingUpper: boolean; - missingDigit: boolean; - missingSymbol: boolean; - }; - }; - }; - }; - /** @description Error */ - 404: { - headers: { - [name: string]: string; - }; - content: { - "application/json": { - /** @enum {string} */ - error: "invitation_not_found_or_expired"; - }; - }; - }; - /** @description Error */ - 409: { - headers: { - [name: string]: string; - }; - content: { - "application/json": { - /** @enum {string} */ - error: "user_exists"; - }; - }; - }; - /** @description Error */ - 500: { - headers: { - [name: string]: string; - }; - content: { - "application/json": - | { - /** @enum {string} */ - error: "internal_error"; - } - | { - /** @enum {string} */ - error: "user_creation_failed"; - }; - }; - }; - }; - }; + "auth.postLogin": { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + "application/json": { + /** Format: email */ + email: string + /** Format: password */ + password: string + } + } + } + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: string + } + content: { + "application/json": { + id: components["schemas"]["UUID"] + /** Format: email */ + email: string + firstName: string + lastName: string + profileImageUrl: string | null + emailVerified: boolean + phoneVerified: boolean + } + } + } + /** @description The request did not match the expected schema */ + 400: { + headers: { + [name: string]: string + } + content: { + "application/json": + | components["schemas"]["HttpApiDecodeError"] + | { + /** @enum {string} */ + error: "invalid_payload" + } + } + } + /** @description Error */ + 401: { + headers: { + [name: string]: string + } + content: { + "application/json": { + /** @enum {string} */ + error: "invalid_credentials" + } + } + } + /** @description Error */ + 500: { + headers: { + [name: string]: string + } + content: { + "application/json": { + /** @enum {string} */ + error: "internal_error" + } + } + } + } + } + "auth.postLogout": { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description Success */ + 204: { + headers: { + [name: string]: string + } + content?: never + } + /** @description The request did not match the expected schema */ + 400: { + headers: { + [name: string]: string + } + content: { + "application/json": components["schemas"]["HttpApiDecodeError"] + } + } + /** @description Error */ + 401: { + headers: { + [name: string]: string + } + content: { + "application/json": { + /** @enum {string} */ + error: "unauthorized" + } + } + } + /** @description Error */ + 500: { + headers: { + [name: string]: string + } + content: { + "application/json": + | { + /** @enum {string} */ + error: "internal_error" + } + | { + /** @enum {string} */ + error: "logout_failed" + } + } + } + } + } + "auth.getMe": { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: string + } + content: { + "application/json": { + id: components["schemas"]["UUID"] + /** Format: email */ + email: string + firstName: string + lastName: string + profileImageUrl: string | null + emailVerified: boolean + phoneVerified: boolean + birthDate: string | null + about: string | null + messengers: Array<{ + /** @enum {string} */ + platform: "telegram" | "whatsapp" + handle: string + }> + memberships: Array<{ + projectId: components["schemas"]["UUID"] + departmentId: components["schemas"]["UUID"] + positionId: components["schemas"]["UUID"] + /** @enum {string} */ + role: "super_admin" | "admin" | "manager" + }> + adminProjectIds: Array + workEmail: string | null + workPhone: string | null + } + } + } + /** @description The request did not match the expected schema */ + 400: { + headers: { + [name: string]: string + } + content: { + "application/json": components["schemas"]["HttpApiDecodeError"] + } + } + /** @description Error */ + 401: { + headers: { + [name: string]: string + } + content: { + "application/json": { + /** @enum {string} */ + error: "unauthorized" + } + } + } + /** @description Error */ + 404: { + headers: { + [name: string]: string + } + content: { + "application/json": { + /** @enum {string} */ + error: "profile_not_found" + } + } + } + /** @description Error */ + 500: { + headers: { + [name: string]: string + } + content: { + "application/json": { + /** @enum {string} */ + error: "internal_error" + } + } + } + } + } + "registration.postRegister": { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + "application/json": { + token: string + /** Format: password */ + password: string + } + } + } + responses: { + /** @description Success */ + 201: { + headers: { + [name: string]: string + } + content: { + "application/json": { + id: components["schemas"]["UUID"] + /** Format: email */ + email: string + firstName: string + lastName: string + profileImageUrl: string | null + } + } + } + /** @description The request did not match the expected schema */ + 400: { + headers: { + [name: string]: string + } + content: { + "application/json": + | components["schemas"]["HttpApiDecodeError"] + | { + /** @enum {string} */ + error: "invalid_payload" + } + | { + /** @enum {string} */ + error: "weak_password" + policy: { + /** @enum {boolean} */ + ok: false + tooShort: boolean + missingLower: boolean + missingUpper: boolean + missingDigit: boolean + missingSymbol: boolean + } + } + } + } + /** @description Error */ + 404: { + headers: { + [name: string]: string + } + content: { + "application/json": { + /** @enum {string} */ + error: "invitation_not_found_or_expired" + } + } + } + /** @description Error */ + 409: { + headers: { + [name: string]: string + } + content: { + "application/json": { + /** @enum {string} */ + error: "user_exists" + } + } + } + /** @description Error */ + 500: { + headers: { + [name: string]: string + } + content: { + "application/json": + | { + /** @enum {string} */ + error: "internal_error" + } + | { + /** @enum {string} */ + error: "user_creation_failed" + } + } + } + } + } } export interface components { - schemas: { - /** - * Format: uuid - * @description a Universally Unique Identifier - */ - UUID: string; - /** @description The request did not match the expected schema */ - HttpApiDecodeError: { - issues: components["schemas"]["Issue"][]; - message: string; - /** @enum {string} */ - _tag: "HttpApiDecodeError"; - }; - /** @description Represents an error encountered while parsing a value to match the schema */ - Issue: { - /** - * @description The tag identifying the type of parse issue - * @enum {string} - */ - _tag: - | "Pointer" - | "Unexpected" - | "Missing" - | "Composite" - | "Refinement" - | "Transformation" - | "Type" - | "Forbidden"; - /** @description The path to the property where the issue occurred */ - path: components["schemas"]["PropertyKey"][]; - /** @description A descriptive message explaining the issue */ - message: string; - }; - PropertyKey: - | string - | number - | { - /** @enum {string} */ - _tag: "symbol"; - key: string; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; + schemas: { + /** + * Format: uuid + * @description a Universally Unique Identifier + */ + UUID: string + /** @description The request did not match the expected schema */ + HttpApiDecodeError: { + issues: Array + message: string + /** @enum {string} */ + _tag: "HttpApiDecodeError" + } + /** @description Represents an error encountered while parsing a value to match the schema */ + Issue: { + /** + * @description The tag identifying the type of parse issue + * @enum {string} + */ + _tag: + | "Pointer" + | "Unexpected" + | "Missing" + | "Composite" + | "Refinement" + | "Transformation" + | "Type" + | "Forbidden" + /** @description The path to the property where the issue occurred */ + path: Array + /** @description A descriptive message explaining the issue */ + message: string + } + PropertyKey: + | string + | number + | { + /** @enum {string} */ + _tag: "symbol" + key: string + } + } + responses: never + parameters: never + requestBodies: never + headers: never + pathItems: never } diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 4895391..2cda8cc 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -14,7 +14,7 @@ export type { StrictApiClient, StrictApiClientWithDispatchers } from "./shell/api-client/create-client.js" -export { registerDefaultDispatchers } from "./shell/api-client/create-client.js" +export { createClientEffect, registerDefaultDispatchers } from "./shell/api-client/create-client.js" // Core types (for advanced type manipulation) // Effect Channel Design: @@ -48,6 +48,7 @@ export type { Decoder, Dispatcher, RawResponse, StrictClient, StrictRequestInit export { createDispatcher, createStrictClient, + createUniversalDispatcher, executeRequest, parseJSON, unexpectedContentType, diff --git a/packages/app/src/shell/api-client/create-client.ts b/packages/app/src/shell/api-client/create-client.ts index d0e9037..beee71d 100644 --- a/packages/app/src/shell/api-client/create-client.ts +++ b/packages/app/src/shell/api-client/create-client.ts @@ -18,7 +18,7 @@ import type { StrictApiClientWithDispatchers } from "./create-client-types.js" import type { StrictRequestInit } from "./strict-client.js" -import { executeRequest } from "./strict-client.js" +import { createUniversalDispatcher, executeRequest } from "./strict-client.js" export type { ClientOptions, @@ -26,6 +26,7 @@ export type { StrictApiClient, StrictApiClientWithDispatchers } from "./create-client-types.js" +export { createUniversalDispatcher } from "./strict-client.js" /** * Primitive value type for path/query parameters @@ -323,3 +324,88 @@ export const createClient = ( 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 + ) + +// 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) → StrictApiClientWithDispatchers +// 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 that classifies responses by HTTP status range: + * - 2xx → success channel (ApiSuccess) + * - non-2xx → error channel (HttpError) + * - 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 +): StrictApiClientWithDispatchers => { + return asStrictApiClient>({ + GET: createMethodHandlerWithUniversalDispatcher("get", options), + POST: createMethodHandlerWithUniversalDispatcher("post", options), + PUT: createMethodHandlerWithUniversalDispatcher("put", options), + DELETE: createMethodHandlerWithUniversalDispatcher("delete", options), + PATCH: createMethodHandlerWithUniversalDispatcher("patch", options), + HEAD: createMethodHandlerWithUniversalDispatcher("head", options), + OPTIONS: createMethodHandlerWithUniversalDispatcher("options", options) + }) +} diff --git a/packages/app/src/shell/api-client/index.ts b/packages/app/src/shell/api-client/index.ts index 5bb1fc7..47e0b49 100644 --- a/packages/app/src/shell/api-client/index.ts +++ b/packages/app/src/shell/api-client/index.ts @@ -19,6 +19,7 @@ export type { export { createDispatcher, createStrictClient, + createUniversalDispatcher, executeRequest, parseJSON, unexpectedContentType, @@ -27,4 +28,4 @@ export { // High-level client creation API export type { ClientOptions, DispatchersFor, StrictApiClient, StrictApiClientWithDispatchers } from "./create-client.js" -export { createClient, registerDefaultDispatchers } from "./create-client.js" +export { createClient, createClientEffect, registerDefaultDispatchers } from "./create-client.js" diff --git a/packages/app/src/shell/api-client/strict-client.ts b/packages/app/src/shell/api-client/strict-client.ts index e2faf85..c821232 100644 --- a/packages/app/src/shell/api-client/strict-client.ts +++ b/packages/app/src/shell/api-client/strict-client.ts @@ -390,4 +390,82 @@ export const createStrictClient = (): StrictClient< } satisfies StrictClient } +// CHANGE: Add universal dispatcher that handles any OpenAPI responses generically +// WHY: Enable createClient(options) without code generation or manual dispatcher wiring +// QUOTE(ТЗ): "Я не хочу создавать какие-то дополнительные модули" +// REF: issue-5 +// SOURCE: n/a +// FORMAT THEOREM: ∀ status, ct: universalDispatcher(status, ct, text) → success(2xx) ∨ httpError(non-2xx) ∨ boundaryError +// PURITY: SHELL +// EFFECT: Effect, Exclude, TransportError>, never> +// INVARIANT: 2xx → success channel, non-2xx → error channel, no-content → body: undefined +// COMPLEXITY: O(1) per dispatch + O(|text|) for JSON parsing + +/** + * Create a universal dispatcher that handles any OpenAPI response generically + * + * The universal dispatcher classifies responses by status code range: + * - 2xx → success channel (ApiSuccess) + * - non-2xx → error channel (HttpError) + * + * For JSON content types, it parses the body. For no-content responses (empty body), + * it returns undefined body with contentType "none". + * + * This enables using createClient(options) without generating + * per-operation dispatchers, fulfilling the zero-boilerplate DSL requirement. + * + * @pure true - returns pure dispatcher function + * @complexity O(1) creation + O(|body|) per dispatch + */ +export const createUniversalDispatcher = (): Dispatcher => { + return asDispatcher((response: RawResponse) => { + const contentType = response.headers.get("content-type") ?? undefined + const is2xx = response.status >= 200 && response.status < 300 + + // No-content response (empty body or 204) + if (response.text === "" || response.status === 204) { + const variant = { + status: response.status, + contentType: "none" as const, + body: undefined + } as const + + return is2xx + ? Effect.succeed(variant) + : Effect.fail({ + _tag: "HttpError" as const, + ...variant + }) + } + + // JSON content type + if (contentType?.includes("application/json")) { + return Effect.gen(function*() { + const parsed = yield* parseJSON(response.status, "application/json", response.text) + const variant = { + status: response.status, + contentType: "application/json" as const, + body: parsed + } as const + + if (is2xx) { + return variant + } + return yield* Effect.fail({ + _tag: "HttpError" as const, + ...variant + }) + }) + } + + // Unknown content type + return Effect.fail(unexpectedContentType( + response.status, + ["application/json"], + contentType, + response.text + )) + }) +} + export { type Dispatcher, type RawResponse } from "../../core/axioms.js" diff --git a/packages/app/tests/api-client/auth-type-tests.test.ts b/packages/app/tests/api-client/auth-type-tests.test.ts new file mode 100644 index 0000000..b71728c --- /dev/null +++ b/packages/app/tests/api-client/auth-type-tests.test.ts @@ -0,0 +1,180 @@ +// CHANGE: Type-level tests for auth OpenAPI schema (openapi.d.ts) +// WHY: Verify createClientEffect enforces path/method/body constraints for real auth schema +// QUOTE(ТЗ): "apiClientEffect.POST('/api/auth/login', { body: credentials }) — Что бы это работало" +// REF: issue-5 +// SOURCE: n/a +// PURITY: CORE - compile-time tests only +// EFFECT: none - type assertions at compile time +// INVARIANT: Auth schema paths and operations are correctly typed through createClientEffect + +import { describe, expectTypeOf, it } from "vitest" + +import type { + ApiFailure, + ApiSuccess, + HttpError, + PathsForMethod, + RequestOptionsFor +} from "../../src/core/api-client/strict-types.js" +import type { operations, paths } from "../../src/core/api/openapi.js" + +// Response types for each auth operation +type LoginResponses = operations["auth.postLogin"]["responses"] +type LogoutResponses = operations["auth.postLogout"]["responses"] +type MeResponses = operations["auth.getMe"]["responses"] +type RegisterResponses = operations["registration.postRegister"]["responses"] + +// ============================================================================= +// SECTION: Auth schema paths/method constraints +// ============================================================================= + +describe("Auth schema: PathsForMethod constraints", () => { + it("/api/auth/login only supports POST", () => { + type PostPaths = PathsForMethod + expectTypeOf<"/api/auth/login">().toExtend() + }) + + it("/api/auth/logout only supports POST", () => { + type PostPaths = PathsForMethod + expectTypeOf<"/api/auth/logout">().toExtend() + }) + + it("/api/auth/me only supports GET", () => { + type GetPaths = PathsForMethod + expectTypeOf<"/api/auth/me">().toExtend() + }) + + it("/api/register only supports POST", () => { + type PostPaths = PathsForMethod + expectTypeOf<"/api/register">().toExtend() + }) + + it("/api/auth/login does NOT support GET", () => { + type GetPaths = PathsForMethod + expectTypeOf<"/api/auth/login">().not.toExtend() + }) + + it("/api/auth/me does NOT support POST", () => { + type PostPaths = PathsForMethod + expectTypeOf<"/api/auth/me">().not.toExtend() + }) +}) + +// ============================================================================= +// SECTION: Auth schema success/error status literal preservation +// ============================================================================= + +describe("Auth schema: success status is literal (not number)", () => { + it("login: success status is exactly 200", () => { + type Success = ApiSuccess + expectTypeOf().toEqualTypeOf<200>() + }) + + it("logout: success status is exactly 204", () => { + type Success = ApiSuccess + expectTypeOf().toEqualTypeOf<204>() + }) + + it("getMe: success status is exactly 200", () => { + type Success = ApiSuccess + expectTypeOf().toEqualTypeOf<200>() + }) + + it("register: success status is exactly 201", () => { + type Success = ApiSuccess + expectTypeOf().toEqualTypeOf<201>() + }) +}) + +describe("Auth schema: HttpError status is literal union from schema", () => { + it("login: HttpError status is 400 | 401 | 500", () => { + type ErrorType = HttpError + expectTypeOf().toEqualTypeOf<400 | 401 | 500>() + }) + + it("logout: HttpError status is 400 | 401 | 500", () => { + type ErrorType = HttpError + expectTypeOf().toEqualTypeOf<400 | 401 | 500>() + }) + + it("getMe: HttpError status is 400 | 401 | 404 | 500", () => { + type ErrorType = HttpError + expectTypeOf().toEqualTypeOf<400 | 401 | 404 | 500>() + }) + + it("register: HttpError status is 400 | 404 | 409 | 500", () => { + type ErrorType = HttpError + expectTypeOf().toEqualTypeOf<400 | 404 | 409 | 500>() + }) +}) + +// ============================================================================= +// SECTION: ApiFailure includes HttpError + BoundaryError +// ============================================================================= + +describe("Auth schema: ApiFailure union", () => { + it("login: ApiFailure includes HttpError _tag in union", () => { + type Failure = ApiFailure + // HttpError is a member of the union + type HasHttpError = Extract extends never ? false : true + expectTypeOf().toEqualTypeOf() + }) + + it("login: ApiFailure includes TransportError _tag in union", () => { + type Failure = ApiFailure + type HasTransportError = Extract extends never ? false : true + expectTypeOf().toEqualTypeOf() + }) + + it("login: ApiFailure includes UnexpectedStatus _tag in union", () => { + type Failure = ApiFailure + type HasUnexpectedStatus = Extract extends never ? false : true + expectTypeOf().toEqualTypeOf() + }) + + it("login: ApiFailure includes ParseError _tag in union", () => { + type Failure = ApiFailure + type HasParseError = Extract extends never ? false : true + expectTypeOf().toEqualTypeOf() + }) + + it("login: ApiFailure includes DecodeError _tag in union", () => { + type Failure = ApiFailure + type HasDecodeError = Extract extends never ? false : true + expectTypeOf().toEqualTypeOf() + }) +}) + +// ============================================================================= +// SECTION: RequestOptionsFor enforces body requirement +// ============================================================================= + +describe("Auth schema: RequestOptionsFor body constraints", () => { + it("login requires body with email and password", () => { + type LoginOp = operations["auth.postLogin"] + type Opts = RequestOptionsFor + // body should be required (not optional) + expectTypeOf().toHaveProperty("body") + }) + + it("register requires body with token and password", () => { + type RegisterOp = operations["registration.postRegister"] + type Opts = RequestOptionsFor + expectTypeOf().toHaveProperty("body") + }) + + it("logout does NOT require body", () => { + type LogoutOp = operations["auth.postLogout"] + type Opts = RequestOptionsFor + // body should be optional + type HasOptionalBody = undefined extends Opts["body"] ? true : false + expectTypeOf().toEqualTypeOf() + }) + + it("getMe does NOT require body", () => { + type MeOp = operations["auth.getMe"] + type Opts = RequestOptionsFor + type HasOptionalBody = undefined extends Opts["body"] ? true : false + 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 new file mode 100644 index 0000000..e39853d --- /dev/null +++ b/packages/app/tests/api-client/create-client-effect-integration.test.ts @@ -0,0 +1,185 @@ +// 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, PR#6 comment from skulidropek +// 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 +// 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 { 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 { 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 = ( + 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) }) + ) + ) + ) + ) + +/** + * 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 })", () => + 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 + }) + + // Type-safe — path, method, and body all enforced at compile time + const result = yield* Effect.either( + apiClientEffect.POST("/api/auth/login", { + body: credentials + }).pipe( + Effect.provide( + createMockHttpClientLayer(200, { "content-type": "application/json" }, successBody) + ) + ) + ) + + expect(Either.isRight(result)).toBe(true) + if (Either.isRight(result)) { + expect(result.right.status).toBe(200) + expect(result.right.contentType).toBe("application/json") + const body = result.right.body as { id: string; email: string } + expect(body.email).toBe("user@example.com") + } + }).pipe(Effect.runPromise)) + + 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" + }) + + // 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(result.status).toBe(200) + expect(result.contentType).toBe("application/json") + const body = result.body as { email: string } + expect(body.email).toBe("user@example.com") + }).pipe(Effect.runPromise)) + + it("should handle error responses via Effect error channel", () => + Effect.gen(function*() { + const credentials = fixtures.wrongLoginBody() + const errorBody = JSON.stringify({ error: "invalid_credentials" }) + + const result = yield* Effect.either( + apiClientEffect.POST("/api/auth/login", { + body: credentials + }).pipe( + Effect.provide( + createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody) + ) + ) + ) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left).toMatchObject({ + _tag: "HttpError", + status: 401 + }) + } + }).pipe(Effect.runPromise)) + + it("should work with GET requests (no body required)", () => + 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) + ) + ) + + expect(result.status).toBe(200) + const body = result.body as { email: string } + expect(body.email).toBe("user@example.com") + }).pipe(Effect.runPromise)) + + it("should handle 204 no-content responses", () => + Effect.gen(function*() { + const result = yield* apiClientEffect.POST("/api/auth/logout").pipe( + Effect.provide( + createMockHttpClientLayer(204, {}, "") + ) + ) + + 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.test.ts b/packages/app/tests/api-client/create-client-effect.test.ts new file mode 100644 index 0000000..e5b2dbc --- /dev/null +++ b/packages/app/tests/api-client/create-client-effect.test.ts @@ -0,0 +1,311 @@ +// 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 +// SOURCE: n/a +// FORMAT THEOREM: ∀ op ∈ AuthOperations: createClientEffect(options).METHOD(path, opts) → Effect +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: 2xx → isRight (success), non-2xx → isLeft (HttpError), transport failure → isLeft (TransportError) +// 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 { describe, expect, it } from "vitest" + +import type { paths } from "../../src/core/api/openapi.js" +import type { ClientOptions } from "../../src/shell/api-client/create-client-types.js" +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 = ( + 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) }) + ) + ) + ) + ) + +describe("createClientEffect (zero-boilerplate, auth schema)", () => { + const clientOptions: ClientOptions = { + baseUrl: "https://petstore.example.com", + credentials: "include" + } + const apiClientEffect = createClientEffect(clientOptions) + + it("should POST /api/auth/login with body and return 200 success", () => + 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 + }) + + const result = yield* Effect.either( + apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( + Effect.provide( + createMockHttpClientLayer(200, { "content-type": "application/json" }, successBody) + ) + ) + ) + + expect(Either.isRight(result)).toBe(true) + if (Either.isRight(result)) { + expect(result.right.status).toBe(200) + expect(result.right.contentType).toBe("application/json") + const body = result.right.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 for 401 invalid_credentials", () => + Effect.gen(function*() { + const credentials = fixtures.wrongLoginBody() + const errorBody = JSON.stringify({ error: "invalid_credentials" }) + + const result = yield* Effect.either( + apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( + Effect.provide( + createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody) + ) + ) + ) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left).toMatchObject({ + _tag: "HttpError", + status: 401 + }) + const left = result.left as { body: { error: string } } + expect(left.body.error).toBe("invalid_credentials") + } + }).pipe(Effect.runPromise)) + + it("should return HttpError for 400 bad request", () => + Effect.gen(function*() { + const credentials = fixtures.loginBody() + const errorBody = JSON.stringify({ error: "invalid_payload" }) + + const result = yield* Effect.either( + apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( + Effect.provide( + createMockHttpClientLayer(400, { "content-type": "application/json" }, errorBody) + ) + ) + ) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left).toMatchObject({ + _tag: "HttpError", + status: 400 + }) + } + }).pipe(Effect.runPromise)) + + it("should return HttpError for 500 internal_error", () => + Effect.gen(function*() { + const credentials = fixtures.loginBody() + const errorBody = JSON.stringify({ error: "internal_error" }) + + const result = yield* Effect.either( + apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( + Effect.provide( + createMockHttpClientLayer(500, { "content-type": "application/json" }, errorBody) + ) + ) + ) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left).toMatchObject({ + _tag: "HttpError", + status: 500 + }) + } + }).pipe(Effect.runPromise)) + + it("should POST /api/auth/logout and return 204 no-content success", () => + Effect.gen(function*() { + const result = yield* Effect.either( + apiClientEffect.POST("/api/auth/logout").pipe( + Effect.provide( + createMockHttpClientLayer(204, {}, "") + ) + ) + ) + + expect(Either.isRight(result)).toBe(true) + if (Either.isRight(result)) { + expect(result.right.status).toBe(204) + expect(result.right.contentType).toBe("none") + expect(result.right.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 result = yield* Effect.either( + apiClientEffect.GET("/api/auth/me").pipe( + Effect.provide( + createMockHttpClientLayer(200, { "content-type": "application/json" }, profileBody) + ) + ) + ) + + expect(Either.isRight(result)).toBe(true) + if (Either.isRight(result)) { + expect(result.right.status).toBe(200) + const body = result.right.body as { email: string; firstName: string } + expect(body.email).toBe("user@example.com") + expect(body.firstName).toBe("John") + } + }).pipe(Effect.runPromise)) + + it("should GET /api/auth/me and return HttpError for 401 unauthorized", () => + Effect.gen(function*() { + const errorBody = JSON.stringify({ error: "unauthorized" }) + + const result = yield* Effect.either( + apiClientEffect.GET("/api/auth/me").pipe( + Effect.provide( + createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody) + ) + ) + ) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left).toMatchObject({ + _tag: "HttpError", + status: 401 + }) + } + }).pipe(Effect.runPromise)) + + it("should POST /api/register with body and return 201 success", () => + 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 + }) + + const result = yield* Effect.either( + apiClientEffect.POST("/api/register", { body: registerBody }).pipe( + Effect.provide( + createMockHttpClientLayer(201, { "content-type": "application/json" }, successBody) + ) + ) + ) + + expect(Either.isRight(result)).toBe(true) + if (Either.isRight(result)) { + expect(result.right.status).toBe(201) + const body = result.right.body as { id: string; email: string } + expect(body.email).toBe("new@example.com") + } + }).pipe(Effect.runPromise)) + + it("should POST /api/register and return HttpError for 409 user_exists", () => + Effect.gen(function*() { + const registerBody = fixtures.registerBody() + const errorBody = JSON.stringify({ error: "user_exists" }) + + const result = yield* Effect.either( + apiClientEffect.POST("/api/register", { body: registerBody }).pipe( + Effect.provide( + createMockHttpClientLayer(409, { "content-type": "application/json" }, errorBody) + ) + ) + ) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left).toMatchObject({ + _tag: "HttpError", + status: 409 + }) + const left = result.left as { body: { error: string } } + expect(left.body.error).toBe("user_exists") + } + }).pipe(Effect.runPromise)) + + it("should return UnexpectedContentType for non-JSON response", () => + Effect.gen(function*() { + const credentials = fixtures.loginBody() + + const result = yield* Effect.either( + apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( + Effect.provide( + createMockHttpClientLayer(200, { "content-type": "text/html" }, "error") + ) + ) + ) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left).toMatchObject({ + _tag: "UnexpectedContentType", + status: 200 + }) + } + }).pipe(Effect.runPromise)) +})