1/**
2 * Builds HTTP clients from `HttpApi` declarations.
3 *
4 * The client methods are derived from the groups and endpoints in an `HttpApi`
5 * and run through an `HttpClient`. They use the same schema-driven contract as
6 * the server: request parts are encoded from endpoint schemas, client
7 * middleware is applied, the HTTP request is executed, and declared success or
8 * error responses are decoded. This module also includes helpers for building a
9 * client for only one group, one endpoint, or only the encoded URL.
10 *
11 * @stability unstable
12 * @since 4.0.0
13 */
14import * as Arr from "../Array.ts"
15import * as Cause from "../Cause.ts"
16import type * as Context from "../Context.ts"
17import * as Effect from "../Effect.ts"
18import * as Sse from "../encoding/Sse.ts"
19import { identity } from "../Function.ts"
20import * as HttpBody from "../http/HttpBody.ts"
21import * as HttpClient from "../http/HttpClient.ts"
22import * as HttpClientError from "../http/HttpClientError.ts"
23import * as HttpClientRequest from "../http/HttpClientRequest.ts"
24import type * as HttpClientResponse from "../http/HttpClientResponse.ts"
25import * as HttpMethod from "../http/HttpMethod.ts"
26import * as UrlParams from "../http/UrlParams.ts"
27import * as InternalRecord from "../internal/record.ts"
28import * as Predicate from "../Predicate.ts"
29import * as Schema from "../Schema.ts"
30import * as SchemaAST from "../SchemaAST.ts"
31import * as SchemaIssue from "../SchemaIssue.ts"
32import * as SchemaTransformation from "../SchemaTransformation.ts"
33import * as Stream from "../Stream.ts"
34import type { Simplify } from "../Types.ts"
35import * as UndefinedOr from "../UndefinedOr.ts"
36import * as HttpApi from "./HttpApi.ts"
37import * as HttpApiEndpoint from "./HttpApiEndpoint.ts"
38import type * as HttpApiGroup from "./HttpApiGroup.ts"
39import type * as HttpApiMiddleware from "./HttpApiMiddleware.ts"
40import * as HttpApiSchema from "./HttpApiSchema.ts"
41import * as MediaType from "./internal/mediaType.ts"
42import * as HttpApiPath from "./internal/path.ts"
43
44/**
45 * The type-safe client shape generated from HTTP API groups, with non-top-level
46 * groups exposed as nested objects and top-level endpoints exposed as methods.
47 *
48 * @stability unstable
49 * @category models
50 * @since 4.0.0
51 */
52export type Client<Groups extends HttpApiGroup.Constraint, E = never, R = never> = Simplify<
53 & {
54 readonly [Group in Extract<Groups, { readonly topLevel: false }> as HttpApiGroup.Identifier<Group>]: Client.Group<
55 Group,
56 E,
57 R
58 >
59 }
60 & Client.TopLevelMethods<Groups, E, R>
61>
62
63/**
64 * Derives the typed client interface for an `HttpApi`, preserving any additional
65 * client error and service requirements supplied by the caller.
66 *
67 * @stability unstable
68 * @category utility types
69 * @since 4.0.0
70 */
71export type ForApi<Api extends HttpApi.Constraint, E = never, R = never> = Api extends
72 HttpApi.HttpApi<infer _Id, infer Groups> ? Client<Groups, E, R> :
73 never
74
75type SuccessType<S> = S extends HttpApiSchema.WithHeaders<
76 infer _Inner,
77 infer _Headers
78> ? HttpApiSchema.withHeaders<SuccessType<_Inner>, _Headers["Type"]>
79 : S extends HttpApiSchema.StreamSse<
80 infer _Events,
81 infer _Error,
82 infer _Value
83 > ? Stream.Stream<
84 _Value,
85 _Error["Type"] | HttpClientError.HttpClientError | Schema.SchemaError | Sse.Retry | Sse.SseError,
86 never
87 >
88 : S extends HttpApiSchema.StreamUint8Array ? Stream.Stream<Uint8Array, HttpClientError.HttpClientError, never>
89 : S extends Schema.Constraint ? S["Type"]
90 : never
91
92type SuccessDecodingServices<S> = S extends HttpApiSchema.StreamSse<
93 infer _Events,
94 infer _Error,
95 infer _Value
96> ?
97 | _Events["DecodingServices"]
98 | _Error["DecodingServices"]
99 : S extends HttpApiSchema.StreamUint8Array ? never
100 : S extends Schema.Constraint ? S["DecodingServices"]
101 : never
102
103/**
104 * Helper types used to describe generated HTTP API clients, including endpoint
105 * methods, response modes, and grouped client shapes.
106 *
107 * @stability unstable
108 * @since 4.0.0
109 */
110export declare namespace Client {
111 /**
112 * The response mode accepted by generated client methods, controlling whether a
113 * call returns the decoded success value, the raw response, or both.
114 *
115 * @stability unstable
116 * @category models
117 * @since 4.0.0
118 */
119 export type ResponseMode = HttpApiEndpoint.ClientResponseMode
120
121 /**
122 * Computes the value returned by a client method for a success type and response
123 * mode.
124 *
125 * @stability unstable
126 * @category models
127 * @since 4.0.0
128 */
129 export type Response<Success, Mode extends ResponseMode> = [Mode] extends ["decoded-and-response"]
130 ? [Success, HttpClientResponse.HttpClientResponse]
131 : [Mode] extends ["response-only"] ? HttpClientResponse.HttpClientResponse
132 : Success
133
134 type GroupByEndpoint<Group extends HttpApiGroup.Constraint, E, R> = Group["endpoints"] extends
135 infer Endpoints extends Readonly<Record<string, HttpApiEndpoint.ConstraintRequest>> ? {
136 readonly [Identifier in keyof Endpoints]: Method<Endpoints[Identifier], E, R>
137 }
138 : {}
139
140 /**
141 * The client object for one API group, mapping each endpoint identifier in that
142 * group to its typed client method.
143 *
144 * @stability unstable
145 * @category models
146 * @since 4.0.0
147 */
148 export type Group<Group extends HttpApiGroup.Constraint, E, R> = GroupByEndpoint<Group, E, R>
149
150 type MethodReturn<
151 Endpoint extends HttpApiEndpoint.ConstraintRequest,
152 E,
153 R,
154 Mode extends ResponseMode
155 > = Effect.Effect<
156 Response<SuccessType<Endpoint["~Success"]>, Mode>,
157 | HttpApiMiddleware.Error<Endpoint["~Middleware"]>
158 | HttpApiMiddleware.ClientError<Endpoint["~Middleware"]>
159 | E
160 | HttpClientError.HttpClientError
161 | ([Mode] extends ["response-only"] ? never : Endpoint["~Error"]["Type"] | Schema.SchemaError),
162 | R
163 | Endpoint["~Params"]["EncodingServices"]
164 | Endpoint["~Query"]["EncodingServices"]
165 | Endpoint["~Payload"]["EncodingServices"]
166 | Endpoint["~Headers"]["EncodingServices"]
167 | ([Mode] extends ["response-only"] ? never
168 :
169 | SuccessDecodingServices<Endpoint["~Success"]>
170 | Endpoint["~Error"]["DecodingServices"])
171 >
172
173 /**
174 * The typed function generated for an endpoint, accepting the endpoint request
175 * shape, including optional per-call SSE decoding options. The returned effect's success,
176 * error, and service channels reflect the endpoint schemas, middleware, and
177 * selected response mode.
178 *
179 * @stability unstable
180 * @category models
181 * @since 4.0.0
182 */
183 export type Method<
184 Endpoint extends HttpApiEndpoint.ConstraintRequest,
185 E,
186 R
187 > = <Mode extends ResponseMode = ResponseMode>(
188 request: Simplify<
189 HttpApiEndpoint.ClientRequest<
190 Endpoint["~Params"],
191 Endpoint["~Query"],
192 Endpoint["~Payload"],
193 Endpoint["~Headers"],
194 Mode
195 >
196 >
197 ) => MethodReturn<Endpoint, E, R, Mode>
198
199 /**
200 * Extracts client methods for endpoints in top-level groups so they can be exposed
201 * directly on the generated client object.
202 *
203 * @stability unstable
204 * @category models
205 * @since 4.0.0
206 */
207 export type TopLevelMethods<Groups extends HttpApiGroup.Constraint, E, R> = {
208 readonly [
209 Endpoint in Extract<
210 HttpApiGroup.Endpoints<Extract<Groups, { readonly topLevel: true }>>,
211 HttpApiEndpoint.ConstraintRequest
212 > as Endpoint["identifier"]
213 ]: Method<Endpoint, E, R>
214 }
215}
216
217type UrlBuilderRequestPart<Key extends string, Value> = [Value] extends [never] ? {}
218 : { readonly [K in Key]: Value }
219
220type UrlBuilderRequest<
221 Endpoint extends HttpApiEndpoint.Constraint,
222 Params = HttpApiEndpoint.Params<Endpoint>["Type"],
223 Query = HttpApiEndpoint.Query<Endpoint>["Type"]
224> = (
225 & UrlBuilderRequestPart<"params", Params>
226 & UrlBuilderRequestPart<"query", Query>
227) extends infer Request ? keyof Request extends never ? void | undefined : Request
228 : never
229
230type UrlBuilderArgs<Request> = [Request] extends [void | undefined] ? [request?: Request]
231 : [request: Request]
232
233/**
234 * The type-safe URL builder shape for an HTTP API, mirroring the generated client
235 * layout while returning URL strings instead of executing requests.
236 *
237 * @stability unstable
238 * @category models
239 * @since 4.0.0
240 */
241export type UrlBuilder<Api extends HttpApi.Constraint> = Api extends HttpApi.HttpApi<infer _ApiId, infer Groups> ?
242 [Extract<Groups, { readonly topLevel: true }>] extends [never] ? UrlBuilderGroups<Groups>
243 : [Extract<Groups, { readonly topLevel: false }>] extends [never] ? UrlBuilderTopLevelMethods<Groups>
244 : Simplify<UrlBuilderGroups<Groups> & UrlBuilderTopLevelMethods<Groups>>
245 : never
246
247type UrlBuilderGroups<Groups extends HttpApiGroup.Constraint> = {
248 readonly [Group in Extract<Groups, { readonly topLevel: false }> as HttpApiGroup.Identifier<Group>]: UrlBuilderGroup<
249 HttpApiGroup.Endpoints<Group>
250 >
251}
252
253type UrlBuilderGroup<Endpoints extends HttpApiEndpoint.Constraint> = {
254 readonly [Endpoint in Endpoints as HttpApiEndpoint.Identifier<Endpoint>]: UrlBuilderMethod<Endpoint>
255}
256
257type UrlBuilderMethod<Endpoint extends HttpApiEndpoint.Constraint> = (
258 ...args: UrlBuilderArgs<UrlBuilderRequest<Endpoint>>
259) => string
260
261type UrlBuilderTopLevelMethods<Groups extends HttpApiGroup.Constraint> = {
262 readonly [
263 Endpoint in HttpApiGroup.Endpoints<Extract<Groups, { readonly topLevel: true }>> as HttpApiEndpoint.Identifier<
264 Endpoint
265 >
266 ]: UrlBuilderMethod<Endpoint>
267}
268
269/**
270 * @internal
271 */
272export const makeClient = <ApiId extends string, Groups extends HttpApiGroup.Constraint, E, R>(
273 api: HttpApi.HttpApi<ApiId, Groups>,
274 options: {
275 readonly httpClient: HttpClient.HttpClient.With<E, R>
276 readonly predicate?: Predicate.Predicate<{
277 readonly endpoint: HttpApiEndpoint.Top
278 readonly group: HttpApiGroup.Top
279 }>
280 readonly onGroup?: (options: {
281 readonly group: HttpApiGroup.Top
282 readonly mergedAnnotations: Context.Context<never>
283 }) => void
284 readonly onEndpoint: (options: {
285 readonly group: HttpApiGroup.Top
286 readonly endpoint: HttpApiEndpoint.Top
287 readonly mergedAnnotations: Context.Context<never>
288 readonly middleware: ReadonlySet<HttpApiMiddleware.AnyService>
289 readonly successes: ReadonlyMap<number, readonly [Schema.Top, ...Array<Schema.Top>]>
290 readonly errors: ReadonlyMap<number, readonly [Schema.Top, ...Array<Schema.Top>]>
291 readonly endpointFn: Function
292 }) => void
293 readonly transformResponse?:
294 | ((effect: Effect.Effect<unknown, unknown, unknown>) => Effect.Effect<unknown, unknown, unknown>)
295 | undefined
296 readonly baseUrl?: URL | string | undefined
297 }
298): Effect.Effect<void> =>
299 Effect.gen(function*() {
300 const services = yield* Effect.context()
301
302 const httpClient = options.httpClient.pipe(
303 options?.baseUrl === undefined
304 ? identity
305 : HttpClient.mapRequest(
306 HttpClientRequest.prependUrl(options.baseUrl.toString())
307 )
308 )
309
310 function executeMiddleware(
311 group: HttpApiGroup.Top,
312 endpoint: HttpApiEndpoint.Top,
313 request: HttpClientRequest.HttpClientRequest,
314 middlewareKeys: ReadonlyArray<string>,
315 index: number
316 ): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError> {
317 if (index === -1) {
318 return httpClient.execute(request) as unknown as Effect.Effect<
319 HttpClientResponse.HttpClientResponse,
320 HttpClientError.HttpClientError
321 >
322 }
323 const middleware = services.mapUnsafe.get(middlewareKeys[index]) as
324 | HttpApiMiddleware.HttpApiMiddlewareClient<any, any, any>
325 | undefined
326 if (middleware === undefined) {
327 return executeMiddleware(group, endpoint, request, middlewareKeys, index - 1)
328 }
329 return middleware({
330 endpoint,
331 group,
332 request,
333 next(request) {
334 return executeMiddleware(group, endpoint, request, middlewareKeys, index - 1)
335 }
336 }) as Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>
337 }
338
339 HttpApi.reflect(api, {
340 predicate: options?.predicate,
341 onGroup(onGroupOptions) {
342 options.onGroup?.(onGroupOptions)
343 },
344 onEndpoint(onEndpointOptions) {
345 const { group, endpoint, errors, successes, mergedAnnotations } = onEndpointOptions
346 const parseOptions = HttpApi.getSlotParseOptions(mergedAnnotations)
347 const makeUrl = compilePath(endpoint.path, endpoint.params)
348 const decodeMap: Record<number | "orElse", ResponseDecoder> = { orElse: statusOrElse }
349 const errorAlternatives = new Map<number, Array<ResponseAlternative>>()
350 for (const [status, schemas] of errors.entries()) {
351 const grouped = groupSchemasByContentType(schemas)
352 for (const [contentType, schemas] of grouped.entries()) {
353 addResponseAlternative(
354 errorAlternatives,
355 status,
356 contentType,
357 schemasToResponse(schemas, parseOptions.error, parseOptions.headers)
358 )
359 }
360 }
361 for (const [status, alternatives] of errorAlternatives.entries()) {
362 const decode = makeResponseDecoder(alternatives)
363 decodeMap[status] = (response) =>
364 Effect.flatMap(
365 Effect.catchCause(decode(response), (cause) =>
366 Effect.failCause(Cause.combine(
367 Cause.fail(
368 new HttpClientError.HttpClientError({
369 reason: new HttpClientError.StatusCodeError({
370 request: response.request,
371 response
372 })
373 })
374 ),
375 cause
376 ))),
377 Effect.fail
378 )
379 }
380
381 const successAlternatives = new Map<number, Array<ResponseAlternative>>()
382 for (const [status, schemas] of successes.entries()) {
383 const grouped = groupSchemasByContentType(schemas)
384 for (const [contentType, schemas] of grouped.entries()) {
385 addResponseAlternative(
386 successAlternatives,
387 status,
388 contentType,
389 schemasToResponse(schemas, parseOptions.success, parseOptions.headers)
390 )
391 }
392 }
393 for (const streamSuccess of getStreamSuccessSchemas(endpoint)) {
394 const streamSchema = isWithHeadersStreamSuccess(streamSuccess) ? streamSuccess.schema : streamSuccess
395 addResponseAlternative(
396 successAlternatives,
397 HttpApiSchema.getStatusSuccessSchema(streamSuccess),
398 streamSchema.contentType,
399 streamToResponse(streamSuccess, parseOptions.success, parseOptions.headers)
400 )
401 }
402 for (const [status, alternatives] of successAlternatives.entries()) {
403 decodeMap[status] = makeResponseDecoder(alternatives)
404 }
405
406 const encodeParams = UndefinedOr.map(
407 endpoint.params,
408 (schema) => Schema.encodeUnknownEffect(schema, parseOptions.params)
409 )
410
411 const payloadSchemas = HttpApiEndpoint.getPayloadSchemas(endpoint)
412 const encodePayload = Arr.isArrayNonEmpty(payloadSchemas) ?
413 Schema.encodeUnknownEffect(
414 HttpMethod.hasBody(endpoint.method)
415 ? getEncodePayloadSchema(payloadSchemas, endpoint.method)
416 : Schema.Union(payloadSchemas),
417 parseOptions.payload
418 ) :
419 undefined
420
421 const encodeHeaders = UndefinedOr.map(
422 endpoint.headers,
423 (schema) => Schema.encodeUnknownEffect(schema, parseOptions.headers)
424 )
425 const encodeQuery = UndefinedOr.map(
426 endpoint.query,
427 (schema) => Schema.encodeUnknownEffect(schema, parseOptions.query)
428 )
429
430 const middlewareKeys = Array.from(onEndpointOptions.middleware, (tag) => `${tag.key}/Client`)
431
432 const endpointFn = Effect.fnUntraced(function*(
433 request: {
434 readonly params: Record<string, string> | undefined
435 readonly query: unknown
436 readonly payload: unknown
437 readonly headers: Record<string, string> | undefined
438 readonly responseMode?: HttpApiEndpoint.ClientResponseMode
439 readonly sseOptions?: Sse.DecodeOptions | undefined
440 } | undefined
441 ) {
442 let httpRequest = HttpClientRequest.make(endpoint.method)(endpoint.path)
443
444 if (request !== undefined) {
445 // params
446 if (encodeParams !== undefined) {
447 const params = (yield* encodeParams(request.params)) as Record<string, string>
448 httpRequest = HttpClientRequest.setUrl(httpRequest, makeUrl(params))
449 }
450
451 // payload
452 if (encodePayload !== undefined) {
453 if (HttpMethod.hasBody(endpoint.method)) {
454 if (request.payload instanceof FormData) {
455 httpRequest = HttpClientRequest.bodyFormData(httpRequest, request.payload)
456 } else {
457 const body = (yield* encodePayload(request.payload)) as HttpBody.HttpBody
458 httpRequest = HttpClientRequest.setBody(httpRequest, body)
459 }
460 } else {
461 const urlParams = (yield* encodePayload(request.payload)) as Record<string, string>
462 httpRequest = HttpClientRequest.appendUrlParams(httpRequest, urlParams)
463 }
464 }
465
466 // headers
467 if (encodeHeaders !== undefined) {
468 const headers = (yield* encodeHeaders(request.headers)) as Record<string, string>
469 httpRequest = HttpClientRequest.setHeaders(httpRequest, headers)
470 }
471
472 // query
473 if (encodeQuery !== undefined) {
474 const query = (yield* encodeQuery(request.query)) as Record<string, string>
475 httpRequest = HttpClientRequest.appendUrlParams(httpRequest, query)
476 }
477 }
478
479 const response = yield* executeMiddleware(
480 group,
481 endpoint,
482 httpRequest,
483 middlewareKeys,
484 middlewareKeys.length - 1
485 )
486
487 if (request?.responseMode === "response-only") {
488 return response
489 }
490
491 const decoded = (decodeMap[response.status] ?? decodeMap.orElse)(response, request?.sseOptions)
492 const value = yield* (options.transformResponse === undefined
493 ? decoded
494 : options.transformResponse(decoded))
495
496 return request?.responseMode === "decoded-and-response" ? [value, response] : value
497 })
498
499 options.onEndpoint({
500 ...onEndpointOptions,
501 endpointFn
502 })
503 }
504 })
505 })
506
507/**
508 * Constructs a type-safe client for an HTTP API using the `HttpClient` service,
509 * endpoint schemas, middleware, and optional client or response transformations.
510 *
511 * @stability unstable
512 * @category constructors
513 * @since 4.0.0
514 */
515export const make = <ApiId extends string, Groups extends HttpApiGroup.Constraint>(
516 api: HttpApi.HttpApi<ApiId, Groups>,
517 options?: {
518 readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined
519 readonly transformResponse?:
520 | ((effect: Effect.Effect<unknown, unknown, unknown>) => Effect.Effect<unknown, unknown, unknown>)
521 | undefined
522 readonly baseUrl?: URL | string | undefined
523 }
524): Effect.Effect<
525 Client<Groups>,
526 never,
527 HttpClient.HttpClient | HttpApiGroup.MiddlewareClient<Groups>
528> =>
529 Effect.flatMap(HttpClient.HttpClient, (httpClient) =>
530 makeWith(api, {
531 ...options,
532 httpClient: options?.transformClient ? options.transformClient(httpClient) : httpClient
533 }))
534
535/**
536 * Constructs a type-safe client for an HTTP API from the supplied `HttpClient`,
537 * using the API metadata to encode requests, execute middleware, and decode
538 * responses.
539 *
540 * @stability unstable
541 * @category constructors
542 * @since 4.0.0
543 */
544export const makeWith = <ApiId extends string, Groups extends HttpApiGroup.Constraint, E, R>(
545 api: HttpApi.HttpApi<ApiId, Groups>,
546 options: {
547 readonly httpClient: HttpClient.HttpClient.With<E, R>
548 readonly transformResponse?:
549 | ((effect: Effect.Effect<unknown, unknown, unknown>) => Effect.Effect<unknown, unknown, unknown>)
550 | undefined
551 readonly baseUrl?: URL | string | undefined
552 }
553): Effect.Effect<
554 Client<Groups, Exclude<E, HttpClientError.HttpClientError>, R>,
555 never,
556 HttpApiGroup.MiddlewareClient<Groups>
557> => {
558 const client: Record<string, Record<string, any>> = {}
559 return makeClient(api, {
560 ...options,
561 onGroup({ group }) {
562 if (group.topLevel) return
563 InternalRecord.assignProperty(client, group.identifier, {})
564 },
565 onEndpoint({ endpoint, endpointFn, group }) {
566 InternalRecord.assignProperty(
567 group.topLevel ? client : client[group.identifier],
568 endpoint.identifier,
569 endpointFn
570 )
571 }
572 }).pipe(Effect.as(client)) as any
573}
574
575/**
576 * Builds a typed client object for a single API group from the supplied
577 * `HttpClient`, filtering the API to that group.
578 *
579 * @stability unstable
580 * @category constructors
581 * @since 4.0.0
582 */
583export const group = <
584 ApiId extends string,
585 Groups extends HttpApiGroup.Constraint,
586 const GroupIdentifier extends HttpApiGroup.Identifier<Groups>,
587 E,
588 R
589>(
590 api: HttpApi.HttpApi<ApiId, Groups>,
591 options: {
592 readonly group: GroupIdentifier
593 readonly httpClient: HttpClient.HttpClient.With<E, R>
594 readonly transformResponse?:
595 | ((effect: Effect.Effect<unknown, unknown, unknown>) => Effect.Effect<unknown, unknown, unknown>)
596 | undefined
597 readonly baseUrl?: URL | string | undefined
598 }
599): Effect.Effect<
600 Client.Group<HttpApiGroup.WithIdentifier<Groups, GroupIdentifier>, E, R>,
601 never,
602 HttpApiGroup.MiddlewareClient<HttpApiGroup.WithIdentifier<Groups, GroupIdentifier>>
603> => {
604 const client: Record<string, any> = {}
605 return makeClient(api, {
606 ...options,
607 predicate: ({ group }) => group.identifier === options.group,
608 onEndpoint({ endpoint, endpointFn }) {
609 InternalRecord.assignProperty(client, endpoint.identifier, endpointFn)
610 }
611 }).pipe(Effect.map(() => client)) as any
612}
613
614type EndpointReturn<
615 Groups extends HttpApiGroup.Constraint,
616 GroupIdentifier extends HttpApiGroup.Identifier<Groups>,
617 EndpointIdentifier extends HttpApiGroup.EndpointsWithIdentifier<Groups, GroupIdentifier>["identifier"],
618 E,
619 R,
620 Endpoint extends HttpApiEndpoint.ConstraintRequest = Extract<
621 HttpApiEndpoint.WithIdentifier<HttpApiGroup.EndpointsWithIdentifier<Groups, GroupIdentifier>, EndpointIdentifier>,
622 HttpApiEndpoint.ConstraintRequest
623 >
624> = Effect.Effect<Client.Method<Endpoint, E, R>, never, HttpApiEndpoint.MiddlewareClient<Endpoint>>
625
626/**
627 * Builds the typed client method for one endpoint in one API group, using the
628 * supplied `HttpClient` and endpoint metadata.
629 *
630 * @stability unstable
631 * @category constructors
632 * @since 4.0.0
633 */
634export const endpoint = <
635 ApiId extends string,
636 Groups extends HttpApiGroup.Constraint,
637 const GroupIdentifier extends HttpApiGroup.Identifier<Groups>,
638 const EndpointIdentifier extends HttpApiGroup.EndpointsWithIdentifier<Groups, GroupIdentifier>["identifier"],
639 E,
640 R
641>(
642 api: HttpApi.HttpApi<ApiId, Groups>,
643 options: {
644 readonly group: GroupIdentifier
645 readonly endpoint: EndpointIdentifier
646 readonly httpClient: HttpClient.HttpClient.With<E, R>
647 readonly transformClient?:
648 | ((client: HttpClient.HttpClient.With<E, R>) => HttpClient.HttpClient.With<E, R>)
649 | undefined
650 readonly transformResponse?:
651 | ((effect: Effect.Effect<unknown, unknown, unknown>) => Effect.Effect<unknown, unknown, unknown>)
652 | undefined
653 readonly baseUrl?: URL | string | undefined
654 }
655): EndpointReturn<Groups, GroupIdentifier, EndpointIdentifier, E, R> => {
656 let client: any = undefined
657 return makeClient(api, {
658 ...options,
659 httpClient: options.transformClient
660 ? options.transformClient(options.httpClient)
661 : options.httpClient,
662 predicate: ({ endpoint, group }) => group.identifier === options.group && endpoint.identifier === options.endpoint,
663 onEndpoint({ endpointFn }) {
664 client = endpointFn
665 }
666 }).pipe(Effect.map(() => client)) as any
667}
668
669/**
670 * Creates a type-safe URL builder that mirrors `HttpApiClient.make`.
671 *
672 * **Example** (Building typed URLs)
673 *
674 * ```ts import.meta.vitest
675 * import { Schema } from "effect"
676 * import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/http-api"
677 *
678 * const Api = HttpApi.make("Api").add(
679 * HttpApiGroup.make("users").add(
680 * HttpApiEndpoint.get("getUser", "/users/:id", {
681 * params: { id: Schema.String }
682 * })
683 * )
684 * )
685 *
686 * const buildUrl = HttpApiClient.urlBuilder(Api, {
687 * baseUrl: "https://api.example.com"
688 * })
689 *
690 * buildUrl.users.getUser({
691 * params: { id: "123" }
692 * }) // => "https://api.example.com/users/123"
693 * ```
694 *
695 * @stability unstable
696 * @category constructors
697 * @since 4.0.0
698 */
699export const urlBuilder = <Api extends HttpApi.Constraint>(api: Api, options?: {
700 readonly baseUrl?: URL | string | undefined
701}): UrlBuilder<Api> => {
702 const builder: Record<string, any> = {}
703
704 HttpApi.reflect(api as unknown as HttpApi.Top, {
705 onGroup({ group }) {
706 if (group.topLevel) return
707 InternalRecord.assignProperty(builder, group.identifier, {})
708 },
709 onEndpoint({ group, endpoint, mergedAnnotations }) {
710 const parseOptions = HttpApi.getSlotParseOptions(mergedAnnotations)
711 const makeUrl = compilePath(endpoint.path, endpoint.params)
712 const encodeParams = endpoint.params === undefined
713 ? undefined
714 : Schema.encodeSync(endpoint.params as unknown as Schema.ConstraintEncoder<unknown>, parseOptions.params)
715 const encodeQuery = endpoint.query === undefined
716 ? undefined
717 : Schema.encodeSync(endpoint.query as unknown as Schema.ConstraintEncoder<unknown>, parseOptions.query)
718
719 const endpointBuilder = (request?: {
720 readonly params?: unknown
721 readonly query?: unknown
722 }) => {
723 const params = request?.params
724 const path = params === undefined
725 ? endpoint.path
726 : makeUrl((encodeParams === undefined ? params : encodeParams(params)) as Record<string, string | undefined>)
727 const queryInput = request?.query === undefined
728 ? undefined
729 : (encodeQuery === undefined ? request.query : encodeQuery(request.query)) as UrlParams.Input
730 const urlParams = queryInput === undefined ? UrlParams.empty : UrlParams.fromInput(queryInput)
731 if (options?.baseUrl === undefined) {
732 const query = UrlParams.toString(urlParams)
733 return query === "" ? path : `${path}?${query}`
734 }
735 const url = new URL(
736 HttpClientRequest.prependUrl(HttpClientRequest.get(path), options.baseUrl.toString()).url
737 )
738 for (const [key, value] of urlParams.params) {
739 if (value !== undefined) {
740 url.searchParams.append(key, value)
741 }
742 }
743 return url.toString()
744 }
745 InternalRecord.assignProperty(
746 group.topLevel ? builder : builder[group.identifier],
747 endpoint.identifier,
748 endpointBuilder
749 )
750 }
751 })
752
753 return builder as UrlBuilder<Api>
754}
755
756// ----------------------------------------------------------------------------
757
758const paramsRegExp = /(\/?):(\w+)(\?)?/g
759
760const compilePath = (path: string, schema: Schema.Top | undefined) => {
761 if (schema === undefined || !path.includes(":")) {
762 return (_: any) => path
763 }
764 const paramNames = HttpApiPath.getParamNames(schema)
765 return (params: Record<string, string | undefined>) => {
766 return path.replace(paramsRegExp, (match, slash: string, key: string, optional: string | undefined) => {
767 if (paramNames !== undefined && !paramNames.has(key)) {
768 return match
769 }
770 const value = params[key]
771 if (value === undefined) {
772 if (optional !== undefined) {
773 return ""
774 }
775 throw new Error(`Missing path parameter: ${key}`)
776 }
777 return `${slash}${encodeURIComponent(value)}`
778 })
779 }
780}
781
782function schemasToResponse(
783 schemas: readonly [Schema.Constraint, ...Array<Schema.Constraint>],
784 options: SchemaAST.ParseOptions | undefined,
785 headersOptions: SchemaAST.ParseOptions | undefined
786): ResponseDecoder {
787 const hasWithHeaders = schemas.some((schema) =>
788 HttpApiSchema.isWithHeaders(schema) || HttpApiSchema.getWithHeadersAnnotation(schema.ast) !== undefined
789 )
790 const codec = hasWithHeaders
791 ? Schema.Union(schemas.map((schema) => toCodecArrayBufferWithHeaders(schema, options, headersOptions)))
792 : toCodecArrayBuffer(schemas)
793 const decode = Schema.decodeUnknownEffect(codec, options)
794 return (response) =>
795 Effect.flatMap(
796 response.arrayBuffer,
797 hasWithHeaders
798 ? (body) => decode({ body, headers: response.headers })
799 : decode
800 )
801}
802
803const ResponsePair = Schema.Struct({ body: Schema.Unknown, headers: Schema.Unknown })
804
805// Decodes the body with `options` and the headers with `headersOptions`, so
806// they are not applied from the outer decode.
807function toCodecArrayBufferWithHeaders(
808 schema: Schema.Constraint,
809 options: SchemaAST.ParseOptions | undefined,
810 headersOptions: SchemaAST.ParseOptions | undefined
811): Schema.Top {
812 const annotation = HttpApiSchema.getWithHeadersAnnotation(schema.ast)
813 const parts: {
814 readonly body: Schema.Constraint
815 readonly headers: Schema.Top
816 readonly toValue: (pair: { readonly body: unknown; readonly headers: unknown }) => unknown
817 } = annotation !== undefined
818 ? { body: annotation.bodyCodec, headers: annotation.headersCodec, toValue: annotation.transformation.decode }
819 : HttpApiSchema.isWithHeaders(schema)
820 ? { body: schema.schema, headers: schema.headers, toValue: HttpApiSchema.withHeaders }
821 : { body: schema, headers: Schema.Unknown, toValue: (pair) => pair.body }
822 const decodeBody = Schema.decodeUnknownEffect(fromArrayBuffer(parts.body).pipe(Schema.decodeTo(parts.body)), options)
823 const decodeHeaders = Schema.decodeUnknownEffect(parts.headers, headersOptions)
824 return ResponsePair.pipe(Schema.decodeTo(
825 HttpApiSchema.isWithHeaders(schema) ? schema : Schema.toType(schema),
826 SchemaTransformation.transformEffect<unknown, typeof ResponsePair.Type, unknown, never>({
827 decode: (pair) =>
828 Effect.all({ body: decodeBody(pair.body), headers: decodeHeaders(pair.headers) }).pipe(
829 Effect.map(parts.toValue),
830 Effect.mapError((error) => error.issue)
831 ),
832 encode: (input, options) =>
833 Effect.fail(new SchemaIssue.Forbidden({ message: "Decode only schema" }, input, options))
834 })
835 ))
836}
837
838type ResponseDecoder = (
839 response: HttpClientResponse.HttpClientResponse,
840 sseOptions?: Sse.DecodeOptions
841) => Effect.Effect<unknown, unknown, unknown>
842
843interface ResponseAlternative {
844 readonly contentType: string
845 readonly decode: ResponseDecoder
846}
847
848function addResponseAlternative(
849 map: Map<number, Array<ResponseAlternative>>,
850 status: number,
851 contentType: string,
852 decode: ResponseDecoder
853) {
854 const normalizedContentType = MediaType.normalize(contentType)
855 const alternatives = map.get(status)
856 if (alternatives === undefined) {
857 map.set(status, [{ contentType: normalizedContentType, decode }])
858 } else {
859 alternatives.push({ contentType: normalizedContentType, decode })
860 }
861}
862
863function makeResponseDecoder(alternatives: ReadonlyArray<ResponseAlternative>): ResponseDecoder {
864 const first = alternatives[0]
865 if (alternatives.length === 1 && first !== undefined) {
866 return first.decode
867 }
868 return (response, sseOptions) => {
869 const contentType = MediaType.normalize(response.headers["content-type"] ?? "")
870 const alternative = alternatives.find((alternative) => alternative.contentType === contentType)
871 return alternative === undefined
872 ? failUnsupportedContentType(response, contentType, alternatives)
873 : alternative.decode(response, sseOptions)
874 }
875}
876
877function groupSchemasByContentType(
878 schemas: Arr.NonEmptyReadonlyArray<Schema.Top>
879): Map<string, Arr.NonEmptyReadonlyArray<Schema.Top>> {
880 const grouped = new Map<string, [Schema.Top, ...Array<Schema.Top>]>()
881 for (const schema of schemas) {
882 const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : schema
883 const contentType = HttpApiSchema.isNoContent(body.ast)
884 ? ""
885 : MediaType.normalize(HttpApiSchema.getResponseEncodingSchema(schema).contentType)
886 const existing = grouped.get(contentType)
887 if (existing === undefined) {
888 grouped.set(contentType, [schema])
889 } else {
890 existing.push(schema)
891 }
892 }
893 return grouped
894}
895
896function failUnsupportedContentType(
897 response: HttpClientResponse.HttpClientResponse,
898 contentType: string,
899 alternatives: ReadonlyArray<ResponseAlternative>
900) {
901 const expected = Array.from(new Set(alternatives.map((alternative) => alternative.contentType))).join(", ")
902 return Effect.fail(
903 new HttpClientError.HttpClientError({
904 reason: new HttpClientError.DecodeError({
905 request: response.request,
906 response,
907 description: `Unsupported response content-type for status ${response.status}: ${
908 contentType || "<missing>"
909 }. Expected one of: ${expected}`
910 })
911 })
912 )
913}
914
915const reservedStreamFailureEvent = "effect/http-api/stream/failure"
916
917type StreamSuccessSchema =
918 | HttpApiSchema.StreamSchema
919 | HttpApiSchema.WithHeaders<HttpApiSchema.StreamSchema, Schema.Top>
920
921const isWithHeadersStreamSuccess = (
922 schema: StreamSuccessSchema
923): schema is HttpApiSchema.WithHeaders<HttpApiSchema.StreamSchema, Schema.Top> => HttpApiSchema.isWithHeaders(schema)
924
925function getStreamSuccessSchemas(endpoint: HttpApiEndpoint.Top): Array<StreamSuccessSchema> {
926 const schemas: Array<StreamSuccessSchema> = []
927 for (const schema of endpoint.success) {
928 const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : schema
929 if (HttpApiSchema.isStreamSchema(body)) {
930 schemas.push(schema as StreamSuccessSchema)
931 }
932 }
933 return schemas
934}
935
936function streamToResponse(
937 successSchema: StreamSuccessSchema,
938 options: SchemaAST.ParseOptions | undefined,
939 headersOptions: SchemaAST.ParseOptions | undefined
940) {
941 const isWithHeaders = isWithHeadersStreamSuccess(successSchema)
942 const streamSchema = isWithHeaders ? successSchema.schema : successSchema
943 const sse = HttpApiSchema.isStreamUint8Array(streamSchema)
944 ? undefined
945 : {
946 declaration: streamSchema,
947 decoder: makeSseDecoder(streamSchema, options)
948 }
949 const toStream: ResponseDecoder = (response, sseOptions) =>
950 Effect.map(Effect.context<never>(), (context) =>
951 Stream.provideContext(
952 sse === undefined ?
953 response.stream :
954 decodeSseStream(response.stream, sse.declaration, sse.decoder(sseOptions)),
955 context as Context.Context<unknown>
956 ))
957 if (!isWithHeaders) return toStream
958
959 const decodeHeaders = Schema.decodeUnknownEffect(successSchema.headers, headersOptions)
960 return (response: HttpClientResponse.HttpClientResponse, sseOptions?: Sse.DecodeOptions) =>
961 Effect.flatMap(
962 decodeHeaders(response.headers),
963 (headers) => Effect.map(toStream(response, sseOptions), (body) => HttpApiSchema.withHeaders({ body, headers }))
964 )
965}
966
967function makeSseDecoder(
968 declaration: HttpApiSchema.StreamSse<Sse.EventCodec, Schema.Constraint, unknown>,
969 parseOptions: SchemaAST.ParseOptions | undefined
970) {
971 const Event = Schema.Union([
972 Schema.Struct({
973 id: Schema.optional(Schema.String),
974 event: Schema.Literal(reservedStreamFailureEvent),
975 data: Schema.fromJsonString(Schema.toCodecJson(Schema.Cause(declaration.error, Schema.Defect())))
976 }),
977 declaration.events
978 ])
979 const defaultDecoder = Sse.decodeSchema(Event, undefined, parseOptions)
980 return (options?: Sse.DecodeOptions) =>
981 options === undefined ? defaultDecoder : Sse.decodeSchema(Event, options, parseOptions)
982}
983
984function decodeSseStream(
985 stream: Stream.Stream<Uint8Array, HttpClientError.HttpClientError>,
986 declaration: HttpApiSchema.StreamSse<Sse.EventCodec, Schema.Constraint, unknown>,
987 decoder: ReturnType<ReturnType<typeof makeSseDecoder>>
988): Stream.Stream<unknown, unknown, unknown> {
989 const events = Stream.transformPull(
990 stream.pipe(
991 Stream.decodeText,
992 Stream.pipeThroughChannel(decoder)
993 ),
994 (pull) =>
995 Effect.sync(() => {
996 let pendingFailureCause: Cause.Cause<unknown> | undefined = undefined
997 return Effect.suspend(() => {
998 if (pendingFailureCause !== undefined) {
999 return Effect.failCause(pendingFailureCause)
1000 }
1001 return Effect.flatMap(pull, (events) => {
1002 for (let i = 0; i < events.length; i++) {
1003 const event = events[i]
1004 if (event.event === reservedStreamFailureEvent && Cause.isCause(event.data)) {
1005 if (i === 0) {
1006 return Effect.failCause(event.data)
1007 }
1008 pendingFailureCause = event.data
1009 events = events.slice(0, i) as any
1010 break
1011 }
1012 }
1013 return Effect.succeed(events)
1014 })
1015 })
1016 })
1017 )
1018 if (declaration.sseMode === "data") {
1019 return Stream.map(events, (event) => event.data)
1020 }
1021 return events
1022}
1023
1024const ArrayBuffer = Schema.instanceOf(globalThis.ArrayBuffer, {
1025 expected: "ArrayBuffer"
1026})
1027
1028// _tag: Uint8Array
1029const Uint8ArrayFromArrayBuffer = ArrayBuffer.pipe(
1030 Schema.decodeTo(
1031 Schema.Uint8Array as Schema.instanceOf<Uint8Array<ArrayBuffer>>,
1032 SchemaTransformation.transform({
1033 decode(fromA) {
1034 return new Uint8Array(fromA)
1035 },
1036 encode(arr) {
1037 return arr.byteLength === arr.buffer.byteLength ?
1038 arr.buffer :
1039 arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength)
1040 }
1041 })
1042 )
1043)
1044
1045// _tag: Text
1046const StringFromArrayBuffer = ArrayBuffer.pipe(
1047 Schema.decodeTo(
1048 Schema.String,
1049 SchemaTransformation.transform({
1050 decode(fromA) {
1051 return new TextDecoder().decode(fromA)
1052 },
1053 encode(toI) {
1054 const arr = new TextEncoder().encode(toI) as Uint8Array<ArrayBuffer>
1055 return arr.byteLength === arr.buffer.byteLength ?
1056 arr.buffer :
1057 arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength)
1058 }
1059 })
1060 )
1061)
1062
1063// _tag: Json
1064const UnknownFromArrayBuffer = StringFromArrayBuffer.pipe(Schema.decodeTo(
1065 Schema.Union([
1066 // Handle No Content
1067 Schema.Literal("").pipe(Schema.decodeTo(
1068 Schema.Undefined,
1069 SchemaTransformation.transform({
1070 decode: () => undefined,
1071 encode: () => ""
1072 })
1073 )),
1074 Schema.UnknownFromJsonString
1075 ])
1076))
1077
1078function toCodecArrayBuffer(schemas: readonly [Schema.Constraint, ...Array<Schema.Constraint>]): Schema.Top {
1079 return Schema.Union(schemas.map(onSchema))
1080
1081 function onSchema(schema: Schema.Constraint) {
1082 return fromArrayBuffer(schema).pipe(Schema.decodeTo(schema))
1083 }
1084}
1085
1086function fromArrayBuffer(schema: Schema.Constraint): Schema.Top {
1087 const encoding = HttpApiSchema.getResponseEncoding(schema.ast)
1088 switch (encoding._tag) {
1089 case "Json": {
1090 // handle json codecs that transform void schemas to null
1091 const encodedIsNull = SchemaAST.isNull(SchemaAST.toEncoded(schema.ast))
1092 return encodedIsNull
1093 ? UnknownFromArrayBuffer.pipe(
1094 Schema.decodeTo(
1095 Schema.Unknown,
1096 SchemaTransformation.transform({
1097 decode: (a) => a === undefined ? null : a,
1098 encode: (a) => a === null ? undefined : a
1099 }) as any
1100 )
1101 )
1102 : UnknownFromArrayBuffer
1103 }
1104 case "FormUrlEncoded":
1105 return StringFromArrayBuffer.pipe(Schema.decodeTo(
1106 Schema.RecordFromUrlParams,
1107 SchemaTransformation.transform({
1108 decode: (text) => UrlParams.fromInput(new URLSearchParams(text)),
1109 encode: UrlParams.toString
1110 })
1111 ))
1112 case "Uint8Array":
1113 return Uint8ArrayFromArrayBuffer
1114 case "Text":
1115 return StringFromArrayBuffer
1116 }
1117}
1118
1119const statusOrElse = (response: HttpClientResponse.HttpClientResponse) =>
1120 Effect.fail(
1121 new HttpClientError.HttpClientError({
1122 reason: new HttpClientError.DecodeError({
1123 request: response.request,
1124 response
1125 })
1126 })
1127 )
1128
1129const $HttpBody = Schema.declare(HttpBody.isHttpBody)
1130
1131function getEncodePayloadSchema(
1132 schemas: readonly [Schema.Constraint, ...Array<Schema.Constraint>],
1133 method: HttpMethod.HttpMethod
1134): Schema.Top {
1135 return Schema.Union(schemas.map((s) => getEncodePayloadSchemaFromBody(s, method)))
1136}
1137
1138const bodyFromPayloadCache = new WeakMap<SchemaAST.AST, Schema.Top>()
1139
1140function getEncodePayloadSchemaFromBody(
1141 schema: Schema.Constraint,
1142 method: HttpMethod.HttpMethod
1143): Schema.Top {
1144 const ast = schema.ast
1145 const cached = bodyFromPayloadCache.get(ast)
1146 if (cached !== undefined) {
1147 return cached
1148 }
1149 const encoding = HttpApiSchema.getPayloadEncoding(ast, method)
1150 const out = $HttpBody.pipe(Schema.decodeTo(
1151 schema,
1152 SchemaTransformation.transformEffect<unknown, HttpBody.HttpBody>({
1153 decode(input, options) {
1154 return Effect.fail(
1155 new SchemaIssue.Forbidden({ message: "Encode only schema" }, input, options)
1156 )
1157 },
1158 encode(t, options) {
1159 switch (encoding._tag) {
1160 case "Multipart":
1161 return Effect.fail(
1162 new SchemaIssue.Forbidden(
1163 { message: "Payload must be a FormData" },
1164 t,
1165 options
1166 )
1167 )
1168 case "Json": {
1169 try {
1170 const body = JSON.stringify(t)
1171 return Effect.succeed(HttpBody.text(body, encoding.contentType))
1172 } catch {
1173 return Effect.fail(
1174 new SchemaIssue.InvalidValue(
1175 { expected: "a JSON-serializable request body" },
1176 t,
1177 options
1178 )
1179 )
1180 }
1181 }
1182 case "Text": {
1183 if (typeof t !== "string") {
1184 return Effect.fail(
1185 new SchemaIssue.InvalidValue({ message: "Expected a string" }, t, options)
1186 )
1187 }
1188 return Effect.succeed(HttpBody.text(t, encoding.contentType))
1189 }
1190 case "FormUrlEncoded": {
1191 if (!Predicate.isObject(t)) {
1192 return Effect.fail(
1193 new SchemaIssue.InvalidValue({ message: "Expected a record" }, t, options)
1194 )
1195 }
1196 return Effect.succeed(HttpBody.urlParams(UrlParams.fromInput(t as any), encoding.contentType))
1197 }
1198 case "Uint8Array": {
1199 if (!(t instanceof Uint8Array)) {
1200 return Effect.fail(
1201 new SchemaIssue.InvalidValue(
1202 { message: "Expected a Uint8Array" },
1203 t,
1204 options
1205 )
1206 )
1207 }
1208 return Effect.succeed(HttpBody.uint8Array(t, encoding.contentType))
1209 }
1210 }
1211 }
1212 })
1213 ))
1214 bodyFromPayloadCache.set(ast, out)
1215 return out
1216}