Skip to content

Commit f1b607c

Browse files
committed
refactor(openapi): expose shared Effect client runtime
1 parent c4f39e5 commit f1b607c

7 files changed

Lines changed: 386 additions & 248 deletions

File tree

bun.lock

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/app/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@
8181
"@gridland/web": "0.4.3",
8282
"@prover-coder-ai/docker-git-session-sync": "workspace:*",
8383
"effect": "^3.21.3",
84-
"openapi-fetch": "^0.17.0",
8584
"react": "19.2.4",
8685
"react-dom": "19.2.4",
8786
"react-reconciler": "^0.33.0",
Lines changed: 28 additions & 230 deletions
Original file line numberDiff line numberDiff line change
@@ -1,248 +1,46 @@
1-
import * as ParseResult from "@effect/schema/ParseResult"
2-
import type * as Schema from "@effect/schema/Schema"
3-
import * as TreeFormatter from "@effect/schema/TreeFormatter"
4-
import type { paths } from "@prover-coder-ai/docker-git-openapi"
5-
import { Effect, Either, Option } from "effect"
6-
import createClient, { type Client, type Middleware } from "openapi-fetch"
1+
import { makeDockerGitOpenApiRuntime } from "@prover-coder-ai/docker-git-openapi"
72

83
import { resolveApiBaseUrl } from "./api-http.js"
94

10-
type DockerGitOpenApiClient = Client<paths>
11-
12-
type ApiTransportValue =
13-
| undefined
14-
| null
15-
| boolean
16-
| number
17-
| string
18-
| ReadonlyArray<ApiTransportValue>
19-
| { readonly [key: string]: ApiTransportValue }
20-
21-
type ApiTransportError = ApiTransportValue | object
22-
23-
type OpenApiResponse<A> = {
24-
readonly data?: A
25-
readonly error?: ApiTransportError
26-
readonly response: Response
27-
}
28-
29-
type OpenApiRequestResult = PromiseLike<OpenApiResponse<ApiTransportValue>>
30-
31-
type OpenApiRequest = (client: DockerGitOpenApiClient) => OpenApiRequestResult
32-
33-
const noCacheHeaders: Readonly<Record<string, string>> = {
34-
accept: "application/json",
35-
"cache-control": "no-cache, no-store, max-age=0",
36-
pragma: "no-cache"
37-
}
38-
39-
const stringifyJson = (value: ApiTransportError): Effect.Effect<string, null> =>
40-
Effect.try({
41-
try: () => JSON.stringify(value, null, 2),
42-
catch: () => null
43-
})
44-
45-
const safeJson = (value: ApiTransportError): Effect.Effect<string> =>
46-
stringifyJson(value).pipe(
47-
Effect.orElseSucceed(() => "unrenderable response payload")
48-
)
49-
50-
const renderTransportValue = (value: ApiTransportError): Effect.Effect<string> => {
51-
if (typeof value === "string") {
52-
return Effect.succeed(value)
53-
}
54-
if (typeof value === "object" && value !== null && "message" in value) {
55-
const message = value["message"]
56-
if (typeof message === "string") {
57-
return Effect.succeed(message)
58-
}
59-
}
60-
return safeJson(value)
61-
}
62-
63-
const renderOpenApiError = (
64-
response: Response,
65-
error: ApiTransportError | undefined
66-
): Effect.Effect<string> => {
67-
if (response.status === 429) {
68-
return Effect.succeed("HTTP 429: tunnel or proxy rate limited the request. Retry or request a fresh tunnel URL.")
69-
}
70-
return error === undefined ? Effect.succeed(`HTTP ${response.status}`) : renderTransportValue(error)
71-
}
72-
73-
const noCacheGetMiddleware: Middleware = {
74-
onRequest: ({ request }) => {
75-
if (request.method !== "GET") {
76-
return
77-
}
78-
const url = new URL(request.url)
79-
url.searchParams.set("_", String(Date.now()))
80-
return new Request(url, request)
81-
}
82-
}
83-
84-
const makeClient = (baseUrl: string): DockerGitOpenApiClient => {
85-
const client = createClient<paths>({
86-
baseUrl,
87-
headers: noCacheHeaders
88-
})
89-
client.use(noCacheGetMiddleware)
90-
return client
91-
}
92-
93-
// PURITY: SHELL
94-
// LIMITATION: mutable cache for client reuse until frontend API helpers are migrated to an Effect Layer.
95-
// MIGRATION: replace this cache with Context.Tag + Layer once UI call sites provide shared dependencies.
96-
// INVARIANT: cache is keyed only by resolved baseUrl and is invalidated on baseUrl change.
97-
// TESTABILITY: tests that exercise client creation must isolate module state or reset the module between cases.
98-
const clientCache: {
99-
baseUrl: string | null
100-
client: DockerGitOpenApiClient | null
101-
} = {
102-
baseUrl: null,
103-
client: null
104-
}
105-
106-
const getOpenApiClient = (): DockerGitOpenApiClient => {
107-
const baseUrl = resolveApiBaseUrl()
108-
if (clientCache.client === null || clientCache.baseUrl !== baseUrl) {
109-
clientCache.baseUrl = baseUrl
110-
clientCache.client = makeClient(baseUrl)
111-
}
112-
return clientCache.client
113-
}
114-
115-
const runOpenApi = (
116-
request: OpenApiRequest
117-
): Effect.Effect<OpenApiResponse<ApiTransportValue>, string> =>
118-
Effect.tryPromise({
119-
try: () => request(getOpenApiClient()),
120-
catch: String
121-
})
122-
123-
const failRenderedOpenApiError = (
124-
response: Response,
125-
error: ApiTransportError | undefined
126-
): Effect.Effect<never, string> =>
127-
renderOpenApiError(response, error).pipe(
128-
Effect.flatMap((message) => Effect.fail(message))
129-
)
5+
const openApiRuntime = makeDockerGitOpenApiRuntime({
6+
resolveBaseUrl: resolveApiBaseUrl
7+
})
1308

1319
/**
132-
* Executes a typed OpenAPI JSON request through openapi-fetch.
133-
*
134-
* @param request - Deferred typed openapi-fetch request.
135-
* @returns Effect containing raw 2xx response data or a rendered API error.
10+
* Executes a docker-git OpenAPI JSON request against the current browser API base URL.
13611
*
137-
* @pure false - performs browser HTTP IO when the Effect is run.
138-
* @effect Network request via openapi-fetch wrapped by Effect.tryPromise.
139-
* @invariant Promise interop is isolated inside this boundary.
140-
* @precondition request uses a static path from generated OpenAPI paths.
141-
* @postcondition successful Effect contains only the 2xx data branch as a transport value.
142-
* @complexity O(n) local response rendering where n is the error payload size.
12+
* @pure false - performs HTTP IO when the returned Effect is run.
13+
* @effect openapi-fetch request wrapped in Effect.
14+
* @invariant the shared OpenAPI runtime owns transport decoding and error rendering.
15+
* @precondition request uses generated docker-git OpenAPI paths.
16+
* @postcondition success contains the endpoint data branch as a JSON transport value.
17+
* @complexity O(n)/O(n) for error rendering, O(1)/O(1) on local success handling.
14318
* @throws Never; failures are returned in the Effect error channel.
14419
*/
145-
// CHANGE: route generated OpenAPI client calls through Effect.
146-
// WHY: frontend REST calls must remain composable in the Effect error channel.
147-
// QUOTE(ТЗ): "использовать openapi-fetch на фронте для удобства"
148-
// REF: user-message-2026-06-18-openapi-fetch
149-
// SOURCE: https://openapi-ts.dev/openapi-fetch/
150-
// FORMAT THEOREM: response.ok ∧ data defined -> success(transport data); otherwise -> failure(message).
151-
// PURITY: SHELL
152-
// EFFECT: Effect<ApiTransportValue, string, never>
153-
// INVARIANT: no Promise escapes this module.
154-
// COMPLEXITY: O(n)/O(n) for error rendering, O(1)/O(1) on successful local processing.
155-
export const openApiJson = (
156-
request: OpenApiRequest
157-
): Effect.Effect<ApiTransportValue, string> =>
158-
runOpenApi(request).pipe(
159-
Effect.flatMap(({ data, error, response }) =>
160-
Option.match(Option.fromNullable(error), {
161-
onNone: () =>
162-
response.ok
163-
? Option.match(Option.fromNullable(data), {
164-
onNone: () => Effect.fail(`HTTP ${response.status}: empty response`),
165-
onSome: (value) => Effect.succeed(value)
166-
})
167-
: failRenderedOpenApiError(response, error),
168-
onSome: (apiError) => failRenderedOpenApiError(response, apiError)
169-
})
170-
)
171-
)
172-
173-
const decodeSchema = <A, I>(schema: Schema.Schema<A, I>, value: ApiTransportValue): Effect.Effect<A, string> =>
174-
Either.match(ParseResult.decodeUnknownEither(schema)(value), {
175-
onLeft: (error) => Effect.fail(TreeFormatter.formatIssueSync(error)),
176-
onRight: (decoded) => Effect.succeed(decoded)
177-
})
20+
export const openApiJson = openApiRuntime.openApiJson
17821

17922
/**
180-
* Executes an OpenAPI request and decodes the data with an Effect Schema.
23+
* Executes a docker-git OpenAPI request and decodes the response with an Effect Schema.
18124
*
182-
* @param schema - Boundary decoder preserving the existing frontend DTO type.
183-
* @param request - Deferred typed openapi-fetch request.
184-
* @returns Effect containing schema-decoded response data.
185-
*
186-
* @pure false - performs browser HTTP IO and boundary decoding when the Effect is run.
187-
* @effect openapi-fetch request plus synchronous Effect Schema decoding.
188-
* @invariant transport typing comes from OpenAPI; exported data typing comes from Schema.
189-
* @precondition schema matches the endpoint success response documented in DockerGitApi.
190-
* @postcondition no generated optional/default representation leaks into existing UI APIs.
191-
* @complexity O(n) where n is the decoded response size.
25+
* @pure false - performs HTTP IO and boundary decoding when the returned Effect is run.
26+
* @effect openapi-fetch request plus synchronous Schema decoding.
27+
* @invariant generated transport shapes are decoded before leaving the web API boundary.
28+
* @precondition schema matches the endpoint success response contract.
29+
* @postcondition success contains the schema-decoded DTO expected by UI code.
30+
* @complexity O(n)/O(n) where n is the decoded response size.
19231
* @throws Never; failures are returned in the Effect error channel.
19332
*/
194-
// CHANGE: compose generated transport typing with existing Schema DTO boundaries.
195-
// WHY: generated OpenAPI types encode optional/default fields differently than current UI contracts.
196-
// QUOTE(ТЗ): "использовать openapi-fetch на фронте для удобства"
197-
// REF: user-message-2026-06-18-openapi-fetch
198-
// SOURCE: https://openapi-ts.dev/openapi-fetch/
199-
// FORMAT THEOREM: decode(schema, response.data) = success(a) -> exported(a).
200-
// PURITY: SHELL
201-
// EFFECT: Effect<A, string, never>
202-
// INVARIANT: only schema-decoded values leave the API boundary.
203-
// COMPLEXITY: O(n)/O(n)
204-
export const openApiJsonSchema = <A, I>(
205-
schema: Schema.Schema<A, I>,
206-
request: OpenApiRequest
207-
): Effect.Effect<A, string> =>
208-
openApiJson(request).pipe(
209-
Effect.flatMap((data) => decodeSchema(schema, data))
210-
)
33+
export const openApiJsonSchema = openApiRuntime.openApiJsonSchema
21134

21235
/**
213-
* Executes a typed OpenAPI request whose successful response has no body.
214-
*
215-
* @param request - Deferred typed openapi-fetch request.
216-
* @returns Effect that succeeds with void for 2xx/3xx empty responses.
36+
* Executes a docker-git OpenAPI request whose success response has no body.
21737
*
218-
* @pure false - performs browser HTTP IO when the Effect is run.
219-
* @effect Network request via openapi-fetch wrapped by Effect.tryPromise.
220-
* @invariant only response status determines success for empty endpoints.
221-
* @precondition request targets an endpoint whose OpenAPI success response has no content.
222-
* @postcondition successful Effect returns void and never exposes transport details.
223-
* @complexity O(n) local response rendering where n is the error payload size.
38+
* @pure false - performs HTTP IO when the returned Effect is run.
39+
* @effect openapi-fetch request wrapped in Effect.
40+
* @invariant only the HTTP success status determines the void success branch.
41+
* @precondition request targets an endpoint whose successful response has no content.
42+
* @postcondition success returns void without exposing transport details.
43+
* @complexity O(n)/O(n) for error rendering, O(1)/O(1) on local success handling.
22444
* @throws Never; failures are returned in the Effect error channel.
22545
*/
226-
// CHANGE: provide an Effect wrapper for generated empty-response endpoints.
227-
// WHY: old requestText(...).asVoid call sites need a typed OpenAPI equivalent.
228-
// QUOTE(ТЗ): "использовать openapi-fetch на фронте для удобства"
229-
// REF: user-message-2026-06-18-openapi-fetch
230-
// SOURCE: https://openapi-ts.dev/openapi-fetch/
231-
// FORMAT THEOREM: response.ok -> success(void); !response.ok -> failure(message).
232-
// PURITY: SHELL
233-
// EFFECT: Effect<void, string, never>
234-
// INVARIANT: no Promise escapes this module.
235-
// COMPLEXITY: O(n)/O(n) for error rendering, O(1)/O(1) on successful local processing.
236-
export const openApiVoid = (
237-
request: OpenApiRequest
238-
): Effect.Effect<void, string> =>
239-
runOpenApi(request).pipe(
240-
Effect.flatMap(({ error, response }) =>
241-
response.ok
242-
? Option.match(Option.fromNullable(error), {
243-
onNone: () => Effect.void,
244-
onSome: (apiError) => failRenderedOpenApiError(response, apiError)
245-
})
246-
: failRenderedOpenApiError(response, error)
247-
)
248-
)
46+
export const openApiVoid = openApiRuntime.openApiVoid

packages/openapi/package.json

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,16 @@
88
"types": "./src/index.ts",
99
"exports": {
1010
".": {
11-
"types": "./src/index.ts"
11+
"types": "./src/index.ts",
12+
"import": "./src/index.ts"
13+
},
14+
"./client": {
15+
"types": "./src/client.ts",
16+
"import": "./src/client.ts"
1217
},
1318
"./openapi-paths": {
14-
"types": "./src/openapi-paths.ts"
19+
"types": "./src/openapi-paths.ts",
20+
"import": "./src/openapi-paths.ts"
1521
},
1622
"./openapi.json": "./openapi.json"
1723
},
@@ -20,6 +26,11 @@
2026
"generate": "bun ../../scripts/write-openapi.ts && openapi-typescript openapi.json -o src/openapi-paths.ts",
2127
"typecheck": "tsc --noEmit -p tsconfig.json"
2228
},
29+
"dependencies": {
30+
"@effect/schema": "^0.75.5",
31+
"effect": "^3.21.3",
32+
"openapi-fetch": "^0.17.0"
33+
},
2334
"devDependencies": {
2435
"openapi-typescript": "^7.13.0",
2536
"typescript": "^6.0.3"

0 commit comments

Comments
 (0)