diff --git a/.gitignore b/.gitignore index 358127e..f6af235 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* reports/ + diff --git a/package.json b/package.json index 0e40b57..e721810 100644 --- a/package.json +++ b/package.json @@ -8,18 +8,18 @@ "packages/*" ], "scripts": { - "build": "pnpm --filter @effect-template/app build", - "check": "pnpm --filter @effect-template/app check", + "build": "pnpm --filter @prover-coder-ai/openapi-effect build", + "check": "pnpm --filter @prover-coder-ai/openapi-effect check", "changeset": "changeset", "changeset-publish": "node -e \"if (!process.env.NPM_TOKEN) { console.log('Skipping publish: NPM_TOKEN is not set'); process.exit(0); }\" && changeset publish", "changeset-version": "changeset version", - "dev": "pnpm --filter @effect-template/app dev", - "lint": "pnpm --filter @effect-template/app lint", - "lint:tests": "pnpm --filter @effect-template/app lint:tests", - "lint:effect": "pnpm --filter @effect-template/app lint:effect", - "test": "pnpm --filter @effect-template/app test", - "typecheck": "pnpm --filter @effect-template/app typecheck", - "start": "pnpm --filter @effect-template/app start" + "dev": "pnpm --filter @prover-coder-ai/openapi-effect dev", + "lint": "pnpm --filter @prover-coder-ai/openapi-effect lint", + "lint:tests": "pnpm --filter @prover-coder-ai/openapi-effect lint:tests", + "lint:effect": "pnpm --filter @prover-coder-ai/openapi-effect lint:effect", + "test": "pnpm --filter @prover-coder-ai/openapi-effect test", + "typecheck": "pnpm --filter @prover-coder-ai/openapi-effect typecheck", + "start": "pnpm --filter @prover-coder-ai/openapi-effect start" }, "devDependencies": { "@changesets/changelog-github": "^0.5.2", diff --git a/packages/app/package.json b/packages/app/package.json index 8c542ce..392febe 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,14 +1,23 @@ { - "name": "@effect-template/app", + "name": "@prover-coder-ai/openapi-effect", "version": "1.0.16", - "description": "Minimal Vite-powered TypeScript console starter using Effect", - "main": "dist/main.js", - "directories": { - "doc": "doc" + "description": "Drop-in replacement for openapi-fetch with an opt-in Effect API", + "type": "module", + "main": "dist/index.js", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } }, + "files": [ + "dist", + "src" + ], "scripts": { - "build": "vite build --ssr src/app/main.ts", - "dev": "vite build --watch --ssr src/app/main.ts", + "build": "vite build", + "dev": "vite build --watch", "lint": "npx @ton-ai-core/vibecode-linter src/", "lint:tests": "npx @ton-ai-core/vibecode-linter tests/", "lint:effect": "npx eslint --config eslint.effect-ts-check.config.mjs .", @@ -32,7 +41,6 @@ ], "author": "", "license": "ISC", - "type": "module", "bugs": { "url": "https://github.com/ProverCoderAI/effect-template/issues" }, @@ -52,6 +60,7 @@ "@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 b07cca5..cec08a4 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,57 +1,39 @@ -// CHANGE: Main entry point for openapi-effect package with Effect-native error handling -// WHY: Enable default import of createClient function with proper error channel design -// QUOTE(ТЗ): "import createClient from \"openapi-effect\"" -// REF: PR#3 comment from skulidropek about Effect representation +// 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 поведение" // SOURCE: n/a // PURITY: SHELL (re-exports) // COMPLEXITY: O(1) -// High-level API (recommended for most users) -export { createClient as default } from "./shell/api-client/create-client.js" +// 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 { - ClientEffect, - ClientOptions, DispatchersFor, StrictApiClient, StrictApiClientWithDispatchers } 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: -// - ApiSuccess: 2xx responses → success channel -// - ApiFailure: HttpError (4xx, 5xx) + BoundaryError → error channel -export type { - ApiFailure, - ApiSuccess, - BodyFor, - BoundaryError, - ContentTypesFor, - DecodeError, - HttpError, - HttpErrorResponseVariant, - HttpErrorVariants, - OperationFor, - ParseError, - PathsForMethod, - ResponsesFor, - ResponseVariant, - StatusCodes, - SuccessVariants, - TransportError, - UnexpectedContentType, - UnexpectedStatus -} from "./core/api-client/index.js" -// Shell utilities (for custom implementations) export type { Decoder, Dispatcher, RawResponse, StrictClient, StrictRequestInit } from "./shell/api-client/index.js" export { + createClient as createClientStrict, + createClientEffect, createDispatcher, createStrictClient, createUniversalDispatcher, executeRequest, parseJSON, + registerDefaultDispatchers, unexpectedContentType, unexpectedStatus } from "./shell/api-client/index.js" diff --git a/packages/app/src/shell/api-client/create-client-types.ts b/packages/app/src/shell/api-client/create-client-types.ts index 762d90d..34970e7 100644 --- a/packages/app/src/shell/api-client/create-client-types.ts +++ b/packages/app/src/shell/api-client/create-client-types.ts @@ -10,6 +10,7 @@ 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 { @@ -28,12 +29,7 @@ import type { Dispatcher } from "../../core/axioms.js" * * @pure - immutable configuration */ -export type ClientOptions = { - readonly baseUrl: string - readonly credentials?: RequestCredentials - readonly headers?: HeadersInit - readonly fetch?: typeof globalThis.fetch -} +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 diff --git a/packages/app/src/shell/api-client/create-client.ts b/packages/app/src/shell/api-client/create-client.ts index 8344dc5..53571ac 100644 --- a/packages/app/src/shell/api-client/create-client.ts +++ b/packages/app/src/shell/api-client/create-client.ts @@ -83,6 +83,53 @@ const resolveDefaultDispatchers = (): DispatchersFor>(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 +} + +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)) + } + 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 * @@ -96,36 +143,15 @@ const resolveDefaultDispatchers = (): DispatchersFor, query?: Record ): string => { - // Replace path parameters - let url = path - if (params) { - for (const [key, value] of Object.entries(params)) { - url = url.replace(`{${key}}`, encodeURIComponent(String(value))) - } - } - - // Construct full URL - const fullUrl = new URL(url, baseUrl) - - // Add query parameters - if (query) { - for (const [key, value] of Object.entries(query)) { - if (Array.isArray(value)) { - for (const item of value) { - fullUrl.searchParams.append(key, String(item)) - } - } else { - fullUrl.searchParams.set(key, String(value)) - } - } - } - - return fullUrl.toString() + const urlWithParams = applyPathParams(path, params) + const queryString = buildQueryString(query) + const urlWithQuery = appendQueryString(urlWithParams, queryString) + return withBaseUrl(baseUrl, urlWithQuery) } /** @@ -172,22 +198,63 @@ const needsJsonContentType = (body: BodyInit | object | undefined): boolean => && !(body instanceof Blob) && !(body instanceof FormData) +const toHeadersFromRecord = ( + headersInit: Record< + string, + | string + | number + | boolean + | ReadonlyArray + | null + | undefined + > +): Headers => { + const headers = new Headers() + + for (const [key, value] of Object.entries(headersInit)) { + if (value === null || value === undefined) { + continue + } + 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: HeadersInit | undefined, - requestHeaders: HeadersInit | undefined + clientHeaders: ClientOptions["headers"] | undefined, + requestHeaders: ClientOptions["headers"] | undefined ): Headers => { - const headers = new Headers(clientHeaders) - if (requestHeaders) { - const optHeaders = new Headers(requestHeaders) - for (const [key, value] of optHeaders.entries()) { - headers.set(key, value) - } + const headers = toHeaders(clientHeaders) + const optHeaders = toHeaders(requestHeaders) + for (const [key, value] of optHeaders.entries()) { + headers.set(key, value) } return headers } @@ -201,7 +268,7 @@ type MethodHandlerOptions = { params?: Record | undefined query?: Record | undefined body?: BodyInit | object | undefined - headers?: HeadersInit | undefined + headers?: ClientOptions["headers"] | undefined signal?: AbortSignal | undefined } diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index cfd0bb5..249b519 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -1,3 +1,4 @@ +import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" import { defineConfig } from "vite" @@ -6,6 +7,21 @@ import tsconfigPaths from "vite-tsconfig-paths" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +type Pkg = { + dependencies?: Record | undefined + peerDependencies?: Record | undefined +} + +// CHANGE: Build both the library entry (src/index.ts) and the CLI entry (src/app/main.ts). +// WHY: Consumers need a JS entrypoint in dist for `import "openapi-effect"`, while we keep the template CLI working. +// SOURCE: n/a +const pkgPath = path.resolve(__dirname, "package.json") +const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as Pkg +const dependencies = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.peerDependencies ?? {})] + +const isExternal = (id: string): boolean => + dependencies.some((dep) => id === dep || id.startsWith(`${dep}/`)) + export default defineConfig({ plugins: [tsconfigPaths()], publicDir: false, @@ -18,15 +34,17 @@ export default defineConfig({ target: "node20", outDir: "dist", sourcemap: true, - ssr: "src/app/main.ts", rollupOptions: { + preserveEntrySignatures: "exports-only", + input: { + index: path.resolve(__dirname, "src/index.ts"), + main: path.resolve(__dirname, "src/app/main.ts") + }, + external: isExternal, output: { format: "es", - entryFileNames: "main.js" + entryFileNames: "[name].js" } } - }, - ssr: { - target: "node" } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 238f79d..c1ecbb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,9 @@ 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 @@ -2729,6 +2732,12 @@ 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==} @@ -6284,6 +6293,12 @@ 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: