1/**
2 * Connects typed `HttpApi` clients to atoms.
3 *
4 * The service created here exposes the generated HTTP API client plus
5 * atom-based query and mutation helpers. Query atoms call endpoints and track
6 * their asynchronous result, while mutations run endpoint calls that can
7 * invalidate reactivity keys after a successful request. Query atoms can also be
8 * cached, serialized for hydration, and kept alive with a time-to-live.
9 *
10 * @stability unstable
11 * @since 4.0.0
12 */
13import * as Context from "../Context.ts"
14import * as Duration from "../Duration.ts"
15import * as Effect from "../Effect.ts"
16import type * as Sse from "../encoding/Sse.ts"
17import type * as HttpApi from "../http-api/HttpApi.ts"
18import * as HttpApiClient from "../http-api/HttpApiClient.ts"
19import * as HttpApiEndpoint from "../http-api/HttpApiEndpoint.ts"
20import type * as HttpApiGroup from "../http-api/HttpApiGroup.ts"
21import type * as HttpApiMiddleware from "../http-api/HttpApiMiddleware.ts"
22import type * as HttpClient from "../http/HttpClient.ts"
23import * as HttpClientError from "../http/HttpClientError.ts"
24import * as Layer from "../Layer.ts"
25import type { ReadonlyRecord } from "../Record.ts"
26import * as Schema from "../Schema.ts"
27import type { SchemaError } from "../Schema.ts"
28import type { Mutable, Simplify } from "../Types.ts"
29import * as AsyncResult from "./AsyncResult.ts"
30import * as Atom from "./Atom.ts"
31import * as Reactivity from "./Reactivity.ts"
32
33/**
34 * A `Context.Service` for an HTTP API client integrated with atom reactivity.
35 *
36 * **Details**
37 *
38 * It exposes the generated HTTP API client, an atom runtime, mutation helpers that
39 * return `AtomResultFn`s, and query helpers that return atoms of endpoint results.
40 *
41 * @stability unstable
42 * @category services
43 * @since 4.0.0
44 */
45export interface AtomHttpApiClient<Self, Id extends string, Groups extends HttpApiGroup.Constraint>
46 extends Context.Service<Self, HttpApiClient.Client<Groups, never, never>>
47{
48 new(_: never): Context.ServiceClass.Shape<Id, HttpApiClient.Client<Groups, never, never>>
49
50 readonly runtime: Atom.AtomRuntime<Self>
51
52 readonly mutation: <
53 GroupIdentifier extends HttpApiGroup.Identifier<Groups>,
54 EndpointIdentifier extends HttpApiEndpoint.Identifier<HttpApiGroup.Endpoints<Group>>,
55 Group extends HttpApiGroup.Constraint = HttpApiGroup.WithIdentifier<Groups, GroupIdentifier>,
56 Endpoint extends HttpApiEndpoint.Constraint = HttpApiEndpoint.WithIdentifier<
57 HttpApiGroup.Endpoints<Group>,
58 EndpointIdentifier
59 >,
60 const ResponseMode extends HttpApiEndpoint.ClientResponseMode = HttpApiEndpoint.ClientResponseMode
61 >(
62 group: GroupIdentifier,
63 endpoint: EndpointIdentifier,
64 options?: {
65 readonly responseMode?: ResponseMode | undefined
66 }
67 ) => [Endpoint] extends [
68 HttpApiEndpoint.HttpApiEndpoint<
69 infer _Identifier,
70 infer _Method,
71 infer _Path,
72 infer _Params,
73 infer _Query,
74 infer _Payload,
75 infer _Headers,
76 infer _Success,
77 infer _Error,
78 infer _Middleware,
79 infer _RE
80 >
81 ] ? Atom.AtomResultFn<
82 Simplify<
83 HttpApiEndpoint.ClientRequest<_Params, _Query, _Payload, _Headers, "decoded-only"> & {
84 readonly reactivityKeys?: ReadonlyArray<unknown> | ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
85 }
86 >,
87 ResponseByMode<Endpoint, ResponseMode>,
88 ErrorByMode<_Error, _Middleware, ResponseMode>
89 >
90 : never
91
92 readonly query: <
93 GroupIdentifier extends HttpApiGroup.Identifier<Groups>,
94 EndpointIdentifier extends HttpApiEndpoint.Identifier<HttpApiGroup.Endpoints<Group>>,
95 Group extends HttpApiGroup.Constraint = HttpApiGroup.WithIdentifier<Groups, GroupIdentifier>,
96 Endpoint extends HttpApiEndpoint.Constraint = HttpApiEndpoint.WithIdentifier<
97 HttpApiGroup.Endpoints<Group>,
98 EndpointIdentifier
99 >,
100 const ResponseMode extends HttpApiEndpoint.ClientResponseMode = "decoded-only"
101 >(
102 group: GroupIdentifier,
103 endpoint: EndpointIdentifier,
104 request: [Endpoint] extends [
105 HttpApiEndpoint.HttpApiEndpoint<
106 infer _Identifier,
107 infer _Method,
108 infer _Path,
109 infer _Params,
110 infer _Query,
111 infer _Payload,
112 infer _Headers,
113 infer _Success,
114 infer _Error,
115 infer _R,
116 infer _RE
117 >
118 ] ? Simplify<
119 HttpApiEndpoint.ClientRequest<_Params, _Query, _Payload, _Headers, ResponseMode> & {
120 readonly reactivityKeys?:
121 | ReadonlyArray<unknown>
122 | ReadonlyRecord<string, ReadonlyArray<unknown>>
123 | undefined
124 readonly timeToLive?: Duration.Input | undefined
125 readonly serializationKey?: string | undefined
126 }
127 >
128 : never
129 ) => [Endpoint] extends [
130 HttpApiEndpoint.HttpApiEndpoint<
131 infer _Identifier,
132 infer _Method,
133 infer _Path,
134 infer _Params,
135 infer _Query,
136 infer _Payload,
137 infer _Headers,
138 infer _Success,
139 infer _Error,
140 infer _Middleware,
141 infer _RE
142 >
143 ] ? Atom.Atom<
144 AsyncResult.AsyncResult<
145 ResponseByMode<Endpoint, ResponseMode>,
146 ErrorByMode<_Error, _Middleware, ResponseMode>
147 >
148 >
149 : never
150}
151
152declare global {
153 interface ErrorConstructor {
154 stackTraceLimit: number
155 }
156}
157
158/**
159 * Creates a `Context.Service` class for an HTTP API client backed by an atom
160 * runtime.
161 *
162 * **Details**
163 *
164 * The options provide the API definition, HTTP client layer, optional client and
165 * response transforms, base URL, and runtime factory used by the query and
166 * mutation helpers.
167 *
168 * @stability unstable
169 * @category constructors
170 * @since 4.0.0
171 */
172export const Service =
173 <Self>() =>
174 <const Id extends string, ApiId extends string, Groups extends HttpApiGroup.Constraint>(
175 id: Id,
176 options: {
177 readonly api: HttpApi.HttpApi<ApiId, Groups>
178 readonly httpClient:
179 | Layer.Layer<
180 | HttpApiGroup.ClientServices<Groups>
181 | HttpClient.HttpClient
182 >
183 | ((get: Atom.AtomContext) => Layer.Layer<
184 | HttpApiGroup.ClientServices<Groups>
185 | HttpClient.HttpClient
186 >)
187 readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined
188 readonly transformResponse?:
189 | ((effect: Effect.Effect<unknown, unknown, unknown>) => Effect.Effect<unknown, unknown, unknown>)
190 | undefined
191 readonly baseUrl?: URL | string | undefined
192 readonly runtime?: Atom.RuntimeFactory | undefined
193 }
194 ): AtomHttpApiClient<Self, Id, Groups> => {
195 const self: Mutable<AtomHttpApiClient<Self, Id, Groups>> = Context.Service<
196 Self,
197 HttpApiClient.Client<Groups, never, never>
198 >()(id) as any
199
200 const layer = Layer.effect(
201 self,
202 HttpApiClient.make(options.api, options)
203 )
204 const runtimeFactory = options.runtime ?? Atom.runtime
205 self.runtime = runtimeFactory(
206 typeof options.httpClient === "function" ?
207 (get) =>
208 Layer.provide(
209 layer,
210 (options.httpClient as (get: Atom.AtomContext) => Layer.Layer<
211 | HttpApiGroup.ClientServices<Groups>
212 | HttpClient.HttpClient
213 >)(get)
214 ) as Layer.Layer<Self> :
215 Layer.provide(layer, options.httpClient) as Layer.Layer<Self>
216 )
217
218 const catchErrors = Effect.catch((e: unknown) =>
219 Schema.isSchemaError(e) || HttpClientError.isHttpClientError(e) ? Effect.die(e) : Effect.fail(e)
220 )
221 const groups = options.api.groups as unknown as Record<string, HttpApiGroup.Top>
222
223 const mutationFamily = Atom.family(({ endpoint, group, responseMode }: MutationKey) => {
224 const atom = self.runtime.fn<{
225 params: any
226 query: any
227 headers: any
228 payload: any
229 sseOptions?: Sse.DecodeOptions | undefined
230 reactivityKeys?: ReadonlyArray<unknown> | ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
231 }>()(
232 Effect.fnUntraced(function*(opts) {
233 const client = (yield* self) as any
234 const groupClient = groups[group].topLevel ? client : client[group]
235 const effect = catchErrors(groupClient[endpoint]({
236 ...opts,
237 responseMode
238 }) as Effect.Effect<any>)
239 return yield* opts.reactivityKeys
240 ? Reactivity.mutation(effect, opts.reactivityKeys)
241 : effect
242 })
243 )
244 if (responseMode === "decoded-only") {
245 const definition = groups[group].endpoints[endpoint]
246 return Atom.serializable(atom, {
247 key: `AtomHttpApi:mutation:${group}:${endpoint}`,
248 schema: AsyncResult.Schema({
249 success: Schema.Union(HttpApiEndpoint.getSuccessSchemas(definition)),
250 error: Schema.Union(HttpApiEndpoint.getErrorSchemas(definition))
251 }) as any
252 })
253 }
254 return atom
255 }) as any
256
257 self.mutation = ((group: string, endpoint: string, options?: {
258 readonly responseMode?: HttpApiEndpoint.ClientResponseMode | undefined
259 }) =>
260 mutationFamily({
261 group,
262 endpoint,
263 responseMode: options?.responseMode ?? "decoded-only"
264 })) as any
265
266 const queryFamily = Atom.family((opts: QueryKey) => {
267 let atom = self.runtime.atom(self.use((client_) => {
268 const client = client_ as any
269 const groupClient = groups[opts.group].topLevel ? client : client[opts.group]
270 return catchErrors(groupClient[opts.endpoint](opts) as Effect.Effect<
271 any,
272 HttpClientError.HttpClientError | SchemaError
273 >)
274 }))
275 if (opts.reactivityKeys) {
276 atom = self.runtime.factory.withReactivity(opts.reactivityKeys)(atom)
277 }
278 if (opts.responseMode === "decoded-only" && opts.serializationKey) {
279 const endpoint = groups[opts.group].endpoints[opts.endpoint]
280 atom = Atom.serializable(atom, {
281 key: `AtomHttpApi:${opts.group}:${opts.endpoint}:${opts.serializationKey}`,
282 schema: AsyncResult.Schema({
283 success: Schema.Union(HttpApiEndpoint.getSuccessSchemas(endpoint)),
284 error: Schema.Union(HttpApiEndpoint.getErrorSchemas(endpoint))
285 }) as any
286 })
287 }
288 if (opts.timeToLive) {
289 atom = Duration.isFinite(opts.timeToLive)
290 ? Atom.setIdleTTL(atom, opts.timeToLive)
291 : Atom.keepAlive(atom)
292 }
293 return atom
294 })
295
296 self.query = ((
297 group: string,
298 endpoint: string,
299 request: {
300 readonly params?: any
301 readonly query?: any
302 readonly payload?: any
303 readonly headers?: any
304 readonly responseMode?: HttpApiEndpoint.ClientResponseMode
305 readonly sseOptions?: Sse.DecodeOptions | undefined
306 readonly reactivityKeys?: ReadonlyArray<unknown> | ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
307 readonly timeToLive?: Duration.Input | undefined
308 readonly serializationKey?: string | undefined
309 }
310 ) => {
311 const key: QueryKey = {
312 group,
313 endpoint,
314 params: request.params,
315 query: request.query,
316 payload: request.payload,
317 headers: request.headers,
318 responseMode: request.responseMode ?? "decoded-only",
319 sseOptions: request.sseOptions,
320 reactivityKeys: request.reactivityKeys,
321 timeToLive: request.timeToLive !== undefined
322 ? Duration.fromInputUnsafe(request.timeToLive)
323 : undefined,
324 serializationKey: request.serializationKey
325 }
326 return queryFamily(key)
327 }) as any
328
329 return self as AtomHttpApiClient<Self, Id, Groups>
330 }
331
332interface MutationKey {
333 group: string
334 endpoint: string
335 responseMode: HttpApiEndpoint.ClientResponseMode
336}
337
338interface QueryKey {
339 group: string
340 endpoint: string
341 params: any
342 query: any
343 headers: any
344 payload: any
345 responseMode: HttpApiEndpoint.ClientResponseMode
346 reactivityKeys: ReadonlyArray<unknown> | ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
347 sseOptions: Sse.DecodeOptions | undefined
348 timeToLive: Duration.Duration | undefined
349 serializationKey: string | undefined
350}
351
352type ResponseByMode<
353 Endpoint extends HttpApiEndpoint.Constraint,
354 ResponseMode extends HttpApiEndpoint.ClientResponseMode
355> = HttpApiClient.Client.Response<
356 Effect.Success<
357 ReturnType<HttpApiClient.Client.Method<Extract<Endpoint, HttpApiEndpoint.ConstraintRequest>, never, never>>
358 >,
359 ResponseMode
360>
361
362type ErrorByMode<
363 Error extends Schema.Constraint,
364 Middleware,
365 ResponseMode extends HttpApiEndpoint.ClientResponseMode
366> =
367 | HttpApiMiddleware.Error<Middleware>
368 | HttpApiMiddleware.ClientError<Middleware>
369 | ([ResponseMode] extends ["response-only"] ? never : Error["Type"])