| 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 | */ |
| 13 | import * as Context from "../Context.ts" |
| 14 | import * as Duration from "../Duration.ts" |
| 15 | import * as Effect from "../Effect.ts" |
| 16 | import type * as Sse from "../encoding/Sse.ts" |
| 17 | import type * as HttpApi from "../http-api/HttpApi.ts" |
| 18 | import * as HttpApiClient from "../http-api/HttpApiClient.ts" |
| 19 | import * as HttpApiEndpoint from "../http-api/HttpApiEndpoint.ts" |
| 20 | import type * as HttpApiGroup from "../http-api/HttpApiGroup.ts" |
| 21 | import type * as HttpApiMiddleware from "../http-api/HttpApiMiddleware.ts" |
| 22 | import type * as HttpClient from "../http/HttpClient.ts" |
| 23 | import * as HttpClientError from "../http/HttpClientError.ts" |
| 24 | import * as Layer from "../Layer.ts" |
| 25 | import type { ReadonlyRecord } from "../Record.ts" |
| 26 | import * as Schema from "../Schema.ts" |
| 27 | import type { SchemaError } from "../Schema.ts" |
| 28 | import type { Mutable, Simplify } from "../Types.ts" |
| 29 | import * as AsyncResult from "./AsyncResult.ts" |
| 30 | import * as Atom from "./Atom.ts" |
| 31 | import * 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 | */ |
| 45 | export 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 | |
| 152 | declare 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 | */ |
| 172 | export 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 | |
| 332 | interface MutationKey { |
| 333 | group: string |
| 334 | endpoint: string |
| 335 | responseMode: HttpApiEndpoint.ClientResponseMode |
| 336 | } |
| 337 | |
| 338 | interface 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 | |
| 352 | type 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 | |
| 362 | type 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"]) |