Skip to content

Commit 6210b36

Browse files
skulidropekCodex
andauthored
fix: make openapi-effect a drop-in replacement for openapi-fetch (#9)
* fix: make openapi-effect a drop-in replacement for openapi-fetch * fix(lint): reduce complexity in create-client helpers * chore(pkg): scope package name * chore(dist): stop tracking build output --------- Co-authored-by: Codex <codex@local>
1 parent f5af169 commit 6210b36

File tree

8 files changed

+187
-99
lines changed

8 files changed

+187
-99
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ yarn-debug.log*
88
yarn-error.log*
99
pnpm-debug.log*
1010
reports/
11+

package.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,18 @@
88
"packages/*"
99
],
1010
"scripts": {
11-
"build": "pnpm --filter @effect-template/app build",
12-
"check": "pnpm --filter @effect-template/app check",
11+
"build": "pnpm --filter @prover-coder-ai/openapi-effect build",
12+
"check": "pnpm --filter @prover-coder-ai/openapi-effect check",
1313
"changeset": "changeset",
1414
"changeset-publish": "node -e \"if (!process.env.NPM_TOKEN) { console.log('Skipping publish: NPM_TOKEN is not set'); process.exit(0); }\" && changeset publish",
1515
"changeset-version": "changeset version",
16-
"dev": "pnpm --filter @effect-template/app dev",
17-
"lint": "pnpm --filter @effect-template/app lint",
18-
"lint:tests": "pnpm --filter @effect-template/app lint:tests",
19-
"lint:effect": "pnpm --filter @effect-template/app lint:effect",
20-
"test": "pnpm --filter @effect-template/app test",
21-
"typecheck": "pnpm --filter @effect-template/app typecheck",
22-
"start": "pnpm --filter @effect-template/app start"
16+
"dev": "pnpm --filter @prover-coder-ai/openapi-effect dev",
17+
"lint": "pnpm --filter @prover-coder-ai/openapi-effect lint",
18+
"lint:tests": "pnpm --filter @prover-coder-ai/openapi-effect lint:tests",
19+
"lint:effect": "pnpm --filter @prover-coder-ai/openapi-effect lint:effect",
20+
"test": "pnpm --filter @prover-coder-ai/openapi-effect test",
21+
"typecheck": "pnpm --filter @prover-coder-ai/openapi-effect typecheck",
22+
"start": "pnpm --filter @prover-coder-ai/openapi-effect start"
2323
},
2424
"devDependencies": {
2525
"@changesets/changelog-github": "^0.5.2",

packages/app/package.json

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
{
2-
"name": "@effect-template/app",
2+
"name": "@prover-coder-ai/openapi-effect",
33
"version": "1.0.16",
4-
"description": "Minimal Vite-powered TypeScript console starter using Effect",
5-
"main": "dist/main.js",
6-
"directories": {
7-
"doc": "doc"
4+
"description": "Drop-in replacement for openapi-fetch with an opt-in Effect API",
5+
"type": "module",
6+
"main": "dist/index.js",
7+
"exports": {
8+
".": {
9+
"types": "./src/index.ts",
10+
"import": "./dist/index.js",
11+
"default": "./dist/index.js"
12+
}
813
},
14+
"files": [
15+
"dist",
16+
"src"
17+
],
918
"scripts": {
10-
"build": "vite build --ssr src/app/main.ts",
11-
"dev": "vite build --watch --ssr src/app/main.ts",
19+
"build": "vite build",
20+
"dev": "vite build --watch",
1221
"lint": "npx @ton-ai-core/vibecode-linter src/",
1322
"lint:tests": "npx @ton-ai-core/vibecode-linter tests/",
1423
"lint:effect": "npx eslint --config eslint.effect-ts-check.config.mjs .",
@@ -32,7 +41,6 @@
3241
],
3342
"author": "",
3443
"license": "ISC",
35-
"type": "module",
3644
"bugs": {
3745
"url": "https://github.com/ProverCoderAI/effect-template/issues"
3846
},
@@ -52,6 +60,7 @@
5260
"@effect/typeclass": "^0.38.0",
5361
"@effect/workflow": "^0.16.0",
5462
"effect": "^3.19.16",
63+
"openapi-fetch": "^0.15.2",
5564
"openapi-typescript-helpers": "^0.1.0",
5665
"ts-morph": "^27.0.2"
5766
},

packages/app/src/index.ts

Lines changed: 18 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,39 @@
1-
// CHANGE: Main entry point for openapi-effect package with Effect-native error handling
2-
// WHY: Enable default import of createClient function with proper error channel design
3-
// QUOTE(ТЗ): "import createClient from \"openapi-effect\""
4-
// REF: PR#3 comment from skulidropek about Effect representation
1+
// CHANGE: Make openapi-effect a drop-in replacement for openapi-fetch (Promise API), with an opt-in Effect API.
2+
// WHY: Consumer projects must be able to swap openapi-fetch -> openapi-effect with near-zero code changes.
3+
// QUOTE(ТЗ): "openapi-effect должен почти 1 в 1 заменяться с openapi-fetch" / "Просто добавлять effect поведение"
54
// SOURCE: n/a
65
// PURITY: SHELL (re-exports)
76
// COMPLEXITY: O(1)
87

9-
// High-level API (recommended for most users)
10-
export { createClient as default } from "./shell/api-client/create-client.js"
8+
// Promise-based client (openapi-fetch compatible)
9+
export { default } from "openapi-fetch"
10+
export { default as createClient } from "openapi-fetch"
11+
export * from "openapi-fetch"
12+
13+
// Effect-based client (opt-in)
14+
export * as FetchHttpClient from "@effect/platform/FetchHttpClient"
15+
16+
// Strict Effect client (advanced)
17+
export type * from "./core/api-client/index.js"
18+
export { assertNever } from "./core/api-client/index.js"
19+
1120
export type {
12-
ClientEffect,
13-
ClientOptions,
1421
DispatchersFor,
1522
StrictApiClient,
1623
StrictApiClientWithDispatchers
1724
} from "./shell/api-client/create-client.js"
18-
export { createClientEffect, registerDefaultDispatchers } from "./shell/api-client/create-client.js"
19-
20-
// Core types (for advanced type manipulation)
21-
// Effect Channel Design:
22-
// - ApiSuccess<Responses>: 2xx responses → success channel
23-
// - ApiFailure<Responses>: HttpError (4xx, 5xx) + BoundaryError → error channel
24-
export type {
25-
ApiFailure,
26-
ApiSuccess,
27-
BodyFor,
28-
BoundaryError,
29-
ContentTypesFor,
30-
DecodeError,
31-
HttpError,
32-
HttpErrorResponseVariant,
33-
HttpErrorVariants,
34-
OperationFor,
35-
ParseError,
36-
PathsForMethod,
37-
ResponsesFor,
38-
ResponseVariant,
39-
StatusCodes,
40-
SuccessVariants,
41-
TransportError,
42-
UnexpectedContentType,
43-
UnexpectedStatus
44-
} from "./core/api-client/index.js"
4525

46-
// Shell utilities (for custom implementations)
4726
export type { Decoder, Dispatcher, RawResponse, StrictClient, StrictRequestInit } from "./shell/api-client/index.js"
4827

4928
export {
29+
createClient as createClientStrict,
30+
createClientEffect,
5031
createDispatcher,
5132
createStrictClient,
5233
createUniversalDispatcher,
5334
executeRequest,
5435
parseJSON,
36+
registerDefaultDispatchers,
5537
unexpectedContentType,
5638
unexpectedStatus
5739
} from "./shell/api-client/index.js"

packages/app/src/shell/api-client/create-client-types.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import type * as HttpClient from "@effect/platform/HttpClient"
1212
import type { Effect } from "effect"
13+
import type { ClientOptions as OpenapiFetchClientOptions } from "openapi-fetch"
1314
import type { HttpMethod } from "openapi-typescript-helpers"
1415

1516
import type {
@@ -28,12 +29,7 @@ import type { Dispatcher } from "../../core/axioms.js"
2829
*
2930
* @pure - immutable configuration
3031
*/
31-
export type ClientOptions = {
32-
readonly baseUrl: string
33-
readonly credentials?: RequestCredentials
34-
readonly headers?: HeadersInit
35-
readonly fetch?: typeof globalThis.fetch
36-
}
32+
export type ClientOptions = OpenapiFetchClientOptions
3733

3834
// CHANGE: Add dispatcher map type for auto-dispatching clients
3935
// WHY: Enable creating clients that infer dispatcher from path+method without per-call parameter

packages/app/src/shell/api-client/create-client.ts

Lines changed: 102 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,53 @@ const resolveDefaultDispatchers = <Paths extends object>(): DispatchersFor<Paths
8383
return asDispatchersFor<DispatchersFor<Paths>>(defaultDispatchers)
8484
}
8585

86+
const applyPathParams = (path: string, params?: Record<string, ParamValue>): string => {
87+
if (params === undefined) {
88+
return path
89+
}
90+
91+
let url = path
92+
for (const [key, value] of Object.entries(params)) {
93+
url = url.replace("{" + key + "}", encodeURIComponent(String(value)))
94+
}
95+
return url
96+
}
97+
98+
const buildQueryString = (query?: Record<string, QueryValue>): string => {
99+
if (query === undefined) {
100+
return ""
101+
}
102+
103+
const searchParams = new URLSearchParams()
104+
for (const [key, value] of Object.entries(query)) {
105+
if (Array.isArray(value)) {
106+
for (const item of value) {
107+
searchParams.append(key, String(item))
108+
}
109+
continue
110+
}
111+
searchParams.set(key, String(value))
112+
}
113+
return searchParams.toString()
114+
}
115+
116+
const appendQueryString = (url: string, queryString: string): string => {
117+
if (queryString.length === 0) {
118+
return url
119+
}
120+
return url.includes("?") ? url + "&" + queryString : url + "?" + queryString
121+
}
122+
123+
const withBaseUrl = (baseUrl: string | undefined, url: string): string => {
124+
// If baseUrl is not provided, keep a relative URL (browser-friendly)
125+
if (baseUrl === undefined || baseUrl === "") {
126+
return url
127+
}
128+
129+
// Construct full URL
130+
return new URL(url, baseUrl).toString()
131+
}
132+
86133
/**
87134
* Build URL with path parameters and query string
88135
*
@@ -96,36 +143,15 @@ const resolveDefaultDispatchers = <Paths extends object>(): DispatchersFor<Paths
96143
* @complexity O(n + m) where n = |params|, m = |query|
97144
*/
98145
const buildUrl = (
99-
baseUrl: string,
146+
baseUrl: string | undefined,
100147
path: string,
101148
params?: Record<string, ParamValue>,
102149
query?: Record<string, QueryValue>
103150
): string => {
104-
// Replace path parameters
105-
let url = path
106-
if (params) {
107-
for (const [key, value] of Object.entries(params)) {
108-
url = url.replace(`{${key}}`, encodeURIComponent(String(value)))
109-
}
110-
}
111-
112-
// Construct full URL
113-
const fullUrl = new URL(url, baseUrl)
114-
115-
// Add query parameters
116-
if (query) {
117-
for (const [key, value] of Object.entries(query)) {
118-
if (Array.isArray(value)) {
119-
for (const item of value) {
120-
fullUrl.searchParams.append(key, String(item))
121-
}
122-
} else {
123-
fullUrl.searchParams.set(key, String(value))
124-
}
125-
}
126-
}
127-
128-
return fullUrl.toString()
151+
const urlWithParams = applyPathParams(path, params)
152+
const queryString = buildQueryString(query)
153+
const urlWithQuery = appendQueryString(urlWithParams, queryString)
154+
return withBaseUrl(baseUrl, urlWithQuery)
129155
}
130156

131157
/**
@@ -172,22 +198,63 @@ const needsJsonContentType = (body: BodyInit | object | undefined): boolean =>
172198
&& !(body instanceof Blob)
173199
&& !(body instanceof FormData)
174200

201+
const toHeadersFromRecord = (
202+
headersInit: Record<
203+
string,
204+
| string
205+
| number
206+
| boolean
207+
| ReadonlyArray<string | number | boolean>
208+
| null
209+
| undefined
210+
>
211+
): Headers => {
212+
const headers = new Headers()
213+
214+
for (const [key, value] of Object.entries(headersInit)) {
215+
if (value === null || value === undefined) {
216+
continue
217+
}
218+
if (Array.isArray(value)) {
219+
headers.set(key, value.map(String).join(","))
220+
continue
221+
}
222+
headers.set(key, String(value))
223+
}
224+
225+
return headers
226+
}
227+
175228
/**
176229
* Merge headers from client options and request options
177230
*
178231
* @pure true
179232
* @complexity O(n) where n = number of headers
180233
*/
234+
const toHeaders = (headersInit: ClientOptions["headers"] | undefined): Headers => {
235+
if (headersInit === undefined) {
236+
return new Headers()
237+
}
238+
239+
if (headersInit instanceof Headers) {
240+
return new Headers(headersInit)
241+
}
242+
243+
if (Array.isArray(headersInit)) {
244+
return new Headers(headersInit)
245+
}
246+
247+
return toHeadersFromRecord(headersInit)
248+
}
249+
181250
const mergeHeaders = (
182-
clientHeaders: HeadersInit | undefined,
183-
requestHeaders: HeadersInit | undefined
251+
clientHeaders: ClientOptions["headers"] | undefined,
252+
requestHeaders: ClientOptions["headers"] | undefined
184253
): Headers => {
185-
const headers = new Headers(clientHeaders)
186-
if (requestHeaders) {
187-
const optHeaders = new Headers(requestHeaders)
188-
for (const [key, value] of optHeaders.entries()) {
189-
headers.set(key, value)
190-
}
254+
const headers = toHeaders(clientHeaders)
255+
const optHeaders = toHeaders(requestHeaders)
256+
for (const [key, value] of optHeaders.entries()) {
257+
headers.set(key, value)
191258
}
192259
return headers
193260
}
@@ -201,7 +268,7 @@ type MethodHandlerOptions = {
201268
params?: Record<string, ParamValue> | undefined
202269
query?: Record<string, QueryValue> | undefined
203270
body?: BodyInit | object | undefined
204-
headers?: HeadersInit | undefined
271+
headers?: ClientOptions["headers"] | undefined
205272
signal?: AbortSignal | undefined
206273
}
207274

0 commit comments

Comments
 (0)