| /** | |
| * Builds HTTP clients from `HttpApi` declarations. | |
| * | |
| * The client methods are derived from the groups and endpoints in an `HttpApi` | |
| * and run through an `HttpClient`. They use the same schema-driven contract as | |
| * the server: request parts are encoded from endpoint schemas, client | |
| * middleware is applied, the HTTP request is executed, and declared success or | |
| * error responses are decoded. This module also includes helpers for building a | |
| * client for only one group, one endpoint, or only the encoded URL. | |
| * | |
| * @since 4.0.0 | |
| */ | |
| import * as Arr from "../../Array.js"; | |
| import * as Cause from "../../Cause.js"; | |
| import * as Effect from "../../Effect.js"; | |
| import { identity } from "../../Function.js"; | |
| import * as Option from "../../Option.js"; | |
| import * as Predicate from "../../Predicate.js"; | |
| import * as Schema from "../../Schema.js"; | |
| import * as SchemaAST from "../../SchemaAST.js"; | |
| import * as SchemaIssue from "../../SchemaIssue.js"; | |
| import * as SchemaTransformation from "../../SchemaTransformation.js"; | |
| import * as Stream from "../../Stream.js"; | |
| import * as UndefinedOr from "../../UndefinedOr.js"; | |
| import * as Sse from "../encoding/Sse.js"; | |
| import * as HttpBody from "../http/HttpBody.js"; | |
| import * as HttpClient from "../http/HttpClient.js"; | |
| import * as HttpClientError from "../http/HttpClientError.js"; | |
| import * as HttpClientRequest from "../http/HttpClientRequest.js"; | |
| import * as HttpClientResponse from "../http/HttpClientResponse.js"; | |
| import * as HttpMethod from "../http/HttpMethod.js"; | |
| import * as UrlParams from "../http/UrlParams.js"; | |
| import * as HttpApi from "./HttpApi.js"; | |
| import * as HttpApiEndpoint from "./HttpApiEndpoint.js"; | |
| import * as HttpApiSchema from "./HttpApiSchema.js"; | |
| /** @internal */ | |
| export const makeClient = (api, options) => Effect.gen(function* () { | |
| const services = yield* Effect.context(); | |
| const httpClient = options.httpClient.pipe(options?.baseUrl === undefined ? identity : HttpClient.mapRequest(HttpClientRequest.prependUrl(options.baseUrl.toString()))); | |
| function executeMiddleware(group, endpoint, request, middlewareKeys, index) { | |
| if (index === -1) { | |
| return httpClient.execute(request); | |
| } | |
| const middleware = services.mapUnsafe.get(middlewareKeys[index]); | |
| if (middleware === undefined) { | |
| return executeMiddleware(group, endpoint, request, middlewareKeys, index - 1); | |
| } | |
| return middleware({ | |
| endpoint, | |
| group, | |
| request, | |
| next(request) { | |
| return executeMiddleware(group, endpoint, request, middlewareKeys, index - 1); | |
| } | |
| }); | |
| } | |
| HttpApi.reflect(api, { | |
| predicate: options?.predicate, | |
| onGroup(onGroupOptions) { | |
| options.onGroup?.(onGroupOptions); | |
| }, | |
| onEndpoint(onEndpointOptions) { | |
| const { | |
| group, | |
| endpoint, | |
| errors, | |
| successes | |
| } = onEndpointOptions; | |
| const makeUrl = compilePath(endpoint.path); | |
| const decodeMap = { | |
| orElse: statusOrElse | |
| }; | |
| const decodeResponse = HttpClientResponse.matchStatus(decodeMap); | |
| errors.forEach((schemas, status) => { | |
| // decoders | |
| const decode = schemasToResponse(schemas); | |
| decodeMap[status] = response => Effect.flatMap(Effect.catchCause(decode(response), cause => Effect.failCause(Cause.combine(Cause.fail(new HttpClientError.HttpClientError({ | |
| reason: new HttpClientError.StatusCodeError({ | |
| request: response.request, | |
| response | |
| }) | |
| })), cause))), Effect.fail); | |
| }); | |
| const successAlternatives = new Map(); | |
| for (const [status, schemas] of successes.entries()) { | |
| const grouped = groupSchemasByContentType(schemas); | |
| for (const [contentType, schemas] of grouped.entries()) { | |
| addResponseAlternative(successAlternatives, status, contentType, schemasToResponse(schemas)); | |
| } | |
| } | |
| for (const streamSuccess of getStreamSuccessSchemas(endpoint)) { | |
| addResponseAlternative(successAlternatives, HttpApiSchema.getStatusStream(streamSuccess), streamSuccess.contentType, streamToResponse(streamSuccess)); | |
| } | |
| for (const [status, alternatives] of successAlternatives.entries()) { | |
| decodeMap[status] = makeResponseDecoder(alternatives); | |
| } | |
| // encoders | |
| const encodeParams = UndefinedOr.map(endpoint.params, Schema.encodeUnknownEffect); | |
| const payloadSchemas = HttpApiEndpoint.getPayloadSchemas(endpoint); | |
| const encodePayload = Arr.isArrayNonEmpty(payloadSchemas) ? HttpMethod.hasBody(endpoint.method) ? Schema.encodeUnknownEffect(getEncodePayloadSchema(payloadSchemas, endpoint.method)) : Schema.encodeUnknownEffect(Schema.Union(payloadSchemas)) : undefined; | |
| const encodeHeaders = UndefinedOr.map(endpoint.headers, Schema.encodeUnknownEffect); | |
| const encodeQuery = UndefinedOr.map(endpoint.query, Schema.encodeUnknownEffect); | |
| const middlewareKeys = Array.from(onEndpointOptions.middleware, tag => `${tag.key}/Client`); | |
| const endpointFn = Effect.fnUntraced(function* (request) { | |
| let httpRequest = HttpClientRequest.make(endpoint.method)(endpoint.path); | |
| if (request !== undefined) { | |
| // params | |
| if (encodeParams !== undefined) { | |
| const params = yield* encodeParams(request.params); | |
| httpRequest = HttpClientRequest.setUrl(httpRequest, makeUrl(params)); | |
| } | |
| // payload | |
| if (encodePayload !== undefined) { | |
| if (HttpMethod.hasBody(endpoint.method)) { | |
| if (request.payload instanceof FormData) { | |
| httpRequest = HttpClientRequest.bodyFormData(httpRequest, request.payload); | |
| } else { | |
| const body = yield* encodePayload(request.payload); | |
| httpRequest = HttpClientRequest.setBody(httpRequest, body); | |
| } | |
| } else { | |
| const urlParams = yield* encodePayload(request.payload); | |
| httpRequest = HttpClientRequest.appendUrlParams(httpRequest, urlParams); | |
| } | |
| } | |
| // headers | |
| if (encodeHeaders !== undefined) { | |
| const headers = yield* encodeHeaders(request.headers); | |
| httpRequest = HttpClientRequest.setHeaders(httpRequest, headers); | |
| } | |
| // query | |
| if (encodeQuery !== undefined) { | |
| const query = yield* encodeQuery(request.query); | |
| httpRequest = HttpClientRequest.appendUrlParams(httpRequest, query); | |
| } | |
| } | |
| const response = yield* executeMiddleware(group, endpoint, httpRequest, middlewareKeys, middlewareKeys.length - 1); | |
| if (request?.responseMode === "response-only") { | |
| return response; | |
| } | |
| const value = yield* options.transformResponse === undefined ? decodeResponse(response) : options.transformResponse(decodeResponse(response)); | |
| return request?.responseMode === "decoded-and-response" ? [value, response] : value; | |
| }); | |
| options.onEndpoint({ | |
| ...onEndpointOptions, | |
| endpointFn | |
| }); | |
| } | |
| }); | |
| }); | |
| /** | |
| * Constructs a type-safe client for an HTTP API using the `HttpClient` service, | |
| * endpoint schemas, middleware, and optional client or response transformations. | |
| * | |
| * @category constructors | |
| * @since 4.0.0 | |
| */ | |
| export const make = (api, options) => Effect.flatMap(HttpClient.HttpClient, httpClient => makeWith(api, { | |
| ...options, | |
| httpClient: options?.transformClient ? options.transformClient(httpClient) : httpClient | |
| })); | |
| /** | |
| * Constructs a type-safe client for an HTTP API from the supplied `HttpClient`, | |
| * using the API metadata to encode requests, execute middleware, and decode | |
| * responses. | |
| * | |
| * @category constructors | |
| * @since 4.0.0 | |
| */ | |
| export const makeWith = (api, options) => { | |
| const client = {}; | |
| return makeClient(api, { | |
| ...options, | |
| onGroup({ | |
| group | |
| }) { | |
| if (group.topLevel) return; | |
| client[group.identifier] = {}; | |
| }, | |
| onEndpoint({ | |
| endpoint, | |
| endpointFn, | |
| group | |
| }) { | |
| ; | |
| (group.topLevel ? client : client[group.identifier])[endpoint.name] = endpointFn; | |
| } | |
| }).pipe(Effect.as(client)); | |
| }; | |
| /** | |
| * Builds a typed client object for a single API group from the supplied | |
| * `HttpClient`, filtering the API to that group. | |
| * | |
| * @category constructors | |
| * @since 4.0.0 | |
| */ | |
| export const group = (api, options) => { | |
| const client = {}; | |
| return makeClient(api, { | |
| ...options, | |
| predicate: ({ | |
| group | |
| }) => group.identifier === options.group, | |
| onEndpoint({ | |
| endpoint, | |
| endpointFn | |
| }) { | |
| client[endpoint.name] = endpointFn; | |
| } | |
| }).pipe(Effect.map(() => client)); | |
| }; | |
| /** | |
| * Builds the typed client method for one endpoint in one API group, using the | |
| * supplied `HttpClient` and endpoint metadata. | |
| * | |
| * @category constructors | |
| * @since 4.0.0 | |
| */ | |
| export const endpoint = (api, options) => { | |
| let client = undefined; | |
| return makeClient(api, { | |
| ...options, | |
| predicate: ({ | |
| endpoint, | |
| group | |
| }) => group.identifier === options.group && endpoint.name === options.endpoint, | |
| onEndpoint({ | |
| endpointFn | |
| }) { | |
| client = endpointFn; | |
| } | |
| }).pipe(Effect.map(() => client)); | |
| }; | |
| /** | |
| * Creates a type-safe URL builder that mirrors `HttpApiClient.make`. | |
| * | |
| * **Example** (Building typed URLs) | |
| * | |
| * ```ts | |
| * import { Schema } from "effect" | |
| * import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" | |
| * | |
| * const Api = HttpApi.make("Api").add( | |
| * HttpApiGroup.make("users").add( | |
| * HttpApiEndpoint.get("getUser", "/users/:id", { | |
| * params: { id: Schema.String } | |
| * }) | |
| * ) | |
| * ) | |
| * | |
| * const buildUrl = HttpApiClient.urlBuilder(Api, { | |
| * baseUrl: "https://api.example.com" | |
| * }) | |
| * | |
| * buildUrl.users.getUser({ | |
| * params: { id: "123" } | |
| * }) | |
| * //=> "https://api.example.com/users/123" | |
| * ``` | |
| * | |
| * @category constructors | |
| * @since 4.0.0 | |
| */ | |
| export const urlBuilder = (api, options) => { | |
| const builder = {}; | |
| HttpApi.reflect(api, { | |
| onGroup({ | |
| group | |
| }) { | |
| if (group.topLevel) return; | |
| builder[group.identifier] = {}; | |
| }, | |
| onEndpoint({ | |
| group, | |
| endpoint | |
| }) { | |
| const makeUrl = compilePath(endpoint.path); | |
| const encodeParams = endpoint.params === undefined ? undefined : Schema.encodeSync(endpoint.params); | |
| const encodeQuery = endpoint.query === undefined ? undefined : Schema.encodeSync(endpoint.query); | |
| const endpointBuilder = request => { | |
| const params = request?.params; | |
| const path = params === undefined ? endpoint.path : makeUrl(encodeParams === undefined ? params : encodeParams(params)); | |
| const queryInput = request?.query === undefined ? undefined : encodeQuery === undefined ? request.query : encodeQuery(request.query); | |
| const query = queryInput === undefined ? "" : UrlParams.toString(UrlParams.fromInput(queryInput)); | |
| const url = query === "" ? path : `${path}?${query}`; | |
| return options?.baseUrl === undefined ? url : new URL(url, options.baseUrl.toString()).toString(); | |
| }; | |
| (group.topLevel ? builder : builder[group.identifier])[endpoint.name] = endpointBuilder; | |
| } | |
| }); | |
| return builder; | |
| }; | |
| // ---------------------------------------------------------------------------- | |
| const paramsRegExp = /(\/?):(\w+)(\?)?/g; | |
| const compilePath = path => { | |
| if (!paramsRegExp.test(path)) { | |
| return _ => path; | |
| } | |
| paramsRegExp.lastIndex = 0; | |
| return params => { | |
| paramsRegExp.lastIndex = 0; | |
| return path.replace(paramsRegExp, (_, slash, key, optional) => { | |
| const value = params[key]; | |
| if (value === undefined) { | |
| if (optional !== undefined) { | |
| return ""; | |
| } | |
| throw new Error(`Missing path parameter: ${key}`); | |
| } | |
| return `${slash}${encodeURIComponent(value)}`; | |
| }); | |
| }; | |
| }; | |
| function schemasToResponse(schemas) { | |
| const codec = toCodecArrayBuffer(schemas); | |
| const decode = Schema.decodeEffect(codec); | |
| return response => Effect.flatMap(response.arrayBuffer, decode); | |
| } | |
| function addResponseAlternative(map, status, contentType, decode) { | |
| const normalizedContentType = normalizeContentType(contentType); | |
| const alternatives = map.get(status); | |
| if (alternatives === undefined) { | |
| map.set(status, [{ | |
| contentType: normalizedContentType, | |
| decode | |
| }]); | |
| } else { | |
| alternatives.push({ | |
| contentType: normalizedContentType, | |
| decode | |
| }); | |
| } | |
| } | |
| function makeResponseDecoder(alternatives) { | |
| const first = alternatives[0]; | |
| if (alternatives.length === 1 && first !== undefined) { | |
| return first.decode; | |
| } | |
| return response => { | |
| const contentType = normalizeContentType(response.headers["content-type"] ?? ""); | |
| const alternative = alternatives.find(alternative => alternative.contentType === contentType); | |
| return alternative === undefined ? failUnsupportedContentType(response, contentType, alternatives) : alternative.decode(response); | |
| }; | |
| } | |
| function groupSchemasByContentType(schemas) { | |
| const grouped = new Map(); | |
| for (const schema of schemas) { | |
| const contentType = HttpApiSchema.getResponseEncoding(schema.ast).contentType; | |
| const existing = grouped.get(contentType); | |
| if (existing === undefined) { | |
| grouped.set(contentType, [schema]); | |
| } else { | |
| existing.push(schema); | |
| } | |
| } | |
| return grouped; | |
| } | |
| function normalizeContentType(contentType) { | |
| const normalized = contentType.toLowerCase().trim(); | |
| const index = normalized.indexOf(";"); | |
| return index === -1 ? normalized : normalized.slice(0, index).trim(); | |
| } | |
| function failUnsupportedContentType(response, contentType, alternatives) { | |
| const expected = Array.from(new Set(alternatives.map(alternative => alternative.contentType))).join(", "); | |
| return Effect.fail(new HttpClientError.HttpClientError({ | |
| reason: new HttpClientError.DecodeError({ | |
| request: response.request, | |
| response, | |
| description: `Unsupported response content-type for status ${response.status}: ${contentType || "<missing>"}. Expected one of: ${expected}` | |
| }) | |
| })); | |
| } | |
| const reservedStreamFailureEvent = "effect/httpapi/stream/failure"; | |
| function getStreamSuccessSchemas(endpoint) { | |
| const schemas = []; | |
| for (const schema of endpoint.success) { | |
| if (HttpApiSchema.isStreamSchema(schema)) { | |
| schemas.push(schema); | |
| } | |
| } | |
| return schemas; | |
| } | |
| function streamToResponse(streamSchema) { | |
| return response => Effect.map(Effect.context(), context => Stream.provideContext(HttpApiSchema.isStreamUint8Array(streamSchema) ? response.stream : decodeSseStream(response.stream, streamSchema), context)); | |
| } | |
| function decodeSseStream(stream, declaration) { | |
| const Event = Schema.Union([declaration.events, Schema.Struct({ | |
| event: Schema.Literal(reservedStreamFailureEvent), | |
| data: Schema.fromJsonString(Schema.toCodecJson(Schema.Cause(declaration.error, Schema.Defect()))) | |
| })]); | |
| const events = Stream.transformPull(stream.pipe(Stream.decodeText, Stream.pipeThroughChannel(Sse.decodeSchema(Event))), pull => Effect.sync(() => { | |
| let failureCause = undefined; | |
| return Effect.suspend(() => { | |
| if (failureCause) { | |
| return Effect.failCause(failureCause); | |
| } | |
| return Effect.flatMap(pull, events => { | |
| for (let i = 0; i < events.length; i++) { | |
| const event = events[i]; | |
| if (event.event === reservedStreamFailureEvent) { | |
| if (i === 0) { | |
| return Effect.failCause(event.data); | |
| } | |
| failureCause = event.data; | |
| events = events.slice(0, i); | |
| break; | |
| } | |
| } | |
| return Effect.succeed(events); | |
| }); | |
| }); | |
| })); | |
| if (declaration.sseMode === "data") { | |
| return Stream.map(events, event => event.data); | |
| } | |
| return events; | |
| } | |
| const ArrayBuffer = /*#__PURE__*/Schema.instanceOf(globalThis.ArrayBuffer, { | |
| expected: "ArrayBuffer" | |
| }); | |
| // _tag: Uint8Array | |
| const Uint8ArrayFromArrayBuffer = /*#__PURE__*/ArrayBuffer.pipe(/*#__PURE__*/Schema.decodeTo(Schema.Uint8Array, /*#__PURE__*/SchemaTransformation.transform({ | |
| decode(fromA) { | |
| return new Uint8Array(fromA); | |
| }, | |
| encode(arr) { | |
| return arr.byteLength === arr.buffer.byteLength ? arr.buffer : arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength); | |
| } | |
| }))); | |
| // _tag: Text | |
| const StringFromArrayBuffer = /*#__PURE__*/ArrayBuffer.pipe(/*#__PURE__*/Schema.decodeTo(Schema.String, /*#__PURE__*/SchemaTransformation.transform({ | |
| decode(fromA) { | |
| return new TextDecoder().decode(fromA); | |
| }, | |
| encode(toI) { | |
| const arr = new TextEncoder().encode(toI); | |
| return arr.byteLength === arr.buffer.byteLength ? arr.buffer : arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength); | |
| } | |
| }))); | |
| // _tag: Json | |
| const UnknownFromArrayBuffer = /*#__PURE__*/StringFromArrayBuffer.pipe(/*#__PURE__*/Schema.decodeTo(/*#__PURE__*/Schema.Union([ | |
| /*#__PURE__*/ | |
| // Handle No Content | |
| Schema.Literal("").pipe(/*#__PURE__*/Schema.decodeTo(Schema.Undefined, /*#__PURE__*/SchemaTransformation.transform({ | |
| decode: () => undefined, | |
| encode: () => "" | |
| }))), Schema.UnknownFromJsonString]))); | |
| function toCodecArrayBuffer(schemas) { | |
| return Schema.Union(schemas.map(onSchema)); | |
| function onSchema(schema) { | |
| const encoding = HttpApiSchema.getResponseEncoding(schema.ast); | |
| switch (encoding._tag) { | |
| case "Json": | |
| { | |
| // handle json codecs that transform void schemas to null | |
| const encodedIsNull = SchemaAST.isNull(SchemaAST.toEncoded(schema.ast)); | |
| return UnknownFromArrayBuffer.pipe(Schema.decodeTo(schema, encodedIsNull ? SchemaTransformation.transform({ | |
| decode: a => a === undefined ? null : a, | |
| encode: a => a === null ? undefined : a | |
| }) : undefined)); | |
| } | |
| case "FormUrlEncoded": | |
| return StringFromArrayBuffer.pipe(Schema.decodeTo(UrlParams.schemaRecord), Schema.decodeTo(schema)); | |
| case "Uint8Array": | |
| return Uint8ArrayFromArrayBuffer.pipe(Schema.decodeTo(schema)); | |
| case "Text": | |
| return StringFromArrayBuffer.pipe(Schema.decodeTo(schema)); | |
| } | |
| } | |
| } | |
| const statusOrElse = response => Effect.fail(new HttpClientError.HttpClientError({ | |
| reason: new HttpClientError.DecodeError({ | |
| request: response.request, | |
| response | |
| }) | |
| })); | |
| const $HttpBody = /*#__PURE__*/Schema.declare(HttpBody.isHttpBody); | |
| function getEncodePayloadSchema(schemas, method) { | |
| return Schema.Union(schemas.map(s => getEncodePayloadSchemaFromBody(s, method))); | |
| } | |
| const bodyFromPayloadCache = /*#__PURE__*/new WeakMap(); | |
| function getEncodePayloadSchemaFromBody(schema, method) { | |
| const ast = schema.ast; | |
| const cached = bodyFromPayloadCache.get(ast); | |
| if (cached !== undefined) { | |
| return cached; | |
| } | |
| const encoding = HttpApiSchema.getPayloadEncoding(ast, method); | |
| const out = $HttpBody.pipe(Schema.decodeTo(schema, SchemaTransformation.transformOrFail({ | |
| decode(httpBody) { | |
| return Effect.fail(new SchemaIssue.Forbidden(Option.some(httpBody), { | |
| message: "Encode only schema" | |
| })); | |
| }, | |
| encode(t) { | |
| switch (encoding._tag) { | |
| case "Multipart": | |
| return Effect.fail(new SchemaIssue.Forbidden(Option.some(t), { | |
| message: "Payload must be a FormData" | |
| })); | |
| case "Json": | |
| { | |
| try { | |
| const body = JSON.stringify(t); | |
| return Effect.succeed(HttpBody.text(body, encoding.contentType)); | |
| } catch (error) { | |
| return Effect.fail(new SchemaIssue.InvalidValue(Option.some(t), { | |
| message: globalThis.String(error) | |
| })); | |
| } | |
| } | |
| case "Text": | |
| { | |
| if (typeof t !== "string") { | |
| return Effect.fail(new SchemaIssue.InvalidValue(Option.some(t), { | |
| message: "Expected a string" | |
| })); | |
| } | |
| return Effect.succeed(HttpBody.text(t, encoding.contentType)); | |
| } | |
| case "FormUrlEncoded": | |
| { | |
| if (!Predicate.isObject(t)) { | |
| return Effect.fail(new SchemaIssue.InvalidValue(Option.some(t), { | |
| message: "Expected a record" | |
| })); | |
| } | |
| return Effect.succeed(HttpBody.urlParams(UrlParams.fromInput(t))); | |
| } | |
| case "Uint8Array": | |
| { | |
| if (!(t instanceof Uint8Array)) { | |
| return Effect.fail(new SchemaIssue.InvalidValue(Option.some(t), { | |
| message: "Expected a Uint8Array" | |
| })); | |
| } | |
| return Effect.succeed(HttpBody.uint8Array(t, encoding.contentType)); | |
| } | |
| } | |
| } | |
| }))); | |
| bodyFromPayloadCache.set(ast, out); | |
| return out; | |
| } | |
| //# sourceMappingURL=HttpApiClient.js.map |
Xet Storage Details
- Size:
- 20.7 kB
- Xet hash:
- 22e4364fab2e6c9ca8013fdcce14f42fbeedfa4c88ed9ef52b518a69e6086f40
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.