|
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" |
7 | 2 |
|
8 | 3 | import { resolveApiBaseUrl } from "./api-http.js" |
9 | 4 |
|
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 | +}) |
130 | 8 |
|
131 | 9 | /** |
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. |
136 | 11 | * |
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. |
143 | 18 | * @throws Never; failures are returned in the Effect error channel. |
144 | 19 | */ |
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 |
178 | 21 |
|
179 | 22 | /** |
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. |
181 | 24 | * |
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. |
192 | 31 | * @throws Never; failures are returned in the Effect error channel. |
193 | 32 | */ |
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 |
211 | 34 |
|
212 | 35 | /** |
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. |
217 | 37 | * |
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. |
224 | 44 | * @throws Never; failures are returned in the Effect error channel. |
225 | 45 | */ |
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 |
0 commit comments