UNPKG

@orpc/openapi-client

Version:

<div align="center"> <image align="center" src="https://orpc.unnoq.com/logo.webp" width=280 alt="oRPC logo" /> </div>

439 lines (431 loc) • 15.9 kB
import { toHttpPath, getMalformedResponseErrorCode, StandardLink } from '@orpc/client/standard'; import { isObject, NullProtoObj, value, get, isAsyncIteratorObject } from '@orpc/shared'; import { isORPCErrorStatus, isORPCErrorJson, createORPCErrorFromJson, mapEventIterator, toORPCError } from '@orpc/client'; import { isContractProcedure, fallbackContractConfig, ORPCError } from '@orpc/contract'; import { mergeStandardHeaders, ErrorEvent } from '@orpc/standard-server'; class StandardBracketNotationSerializer { maxArrayIndex; constructor(options = {}) { this.maxArrayIndex = options.maxBracketNotationArrayIndex ?? 9999; } serialize(data, segments = [], result = []) { if (Array.isArray(data)) { data.forEach((item, i) => { this.serialize(item, [...segments, i], result); }); } else if (isObject(data)) { for (const key in data) { this.serialize(data[key], [...segments, key], result); } } else { result.push([this.stringifyPath(segments), data]); } return result; } deserialize(serialized) { if (serialized.length === 0) { return {}; } const arrayPushStyles = /* @__PURE__ */ new WeakSet(); const ref = { value: [] }; for (const [path, value] of serialized) { const segments = this.parsePath(path); let currentRef = ref; let nextSegment = "value"; segments.forEach((segment, i) => { if (!Array.isArray(currentRef[nextSegment]) && !isObject(currentRef[nextSegment])) { currentRef[nextSegment] = []; } if (i !== segments.length - 1) { if (Array.isArray(currentRef[nextSegment]) && !isValidArrayIndex(segment, this.maxArrayIndex)) { if (arrayPushStyles.has(currentRef[nextSegment])) { arrayPushStyles.delete(currentRef[nextSegment]); currentRef[nextSegment] = pushStyleArrayToObject(currentRef[nextSegment]); } else { currentRef[nextSegment] = arrayToObject(currentRef[nextSegment]); } } } else { if (Array.isArray(currentRef[nextSegment])) { if (segment === "") { if (currentRef[nextSegment].length && !arrayPushStyles.has(currentRef[nextSegment])) { currentRef[nextSegment] = arrayToObject(currentRef[nextSegment]); } } else { if (arrayPushStyles.has(currentRef[nextSegment])) { arrayPushStyles.delete(currentRef[nextSegment]); currentRef[nextSegment] = pushStyleArrayToObject(currentRef[nextSegment]); } else if (!isValidArrayIndex(segment, this.maxArrayIndex)) { currentRef[nextSegment] = arrayToObject(currentRef[nextSegment]); } } } } currentRef = currentRef[nextSegment]; nextSegment = segment; }); if (Array.isArray(currentRef) && nextSegment === "") { arrayPushStyles.add(currentRef); currentRef.push(value); } else if (nextSegment in currentRef) { if (Array.isArray(currentRef[nextSegment])) { currentRef[nextSegment].push(value); } else { currentRef[nextSegment] = [currentRef[nextSegment], value]; } } else { currentRef[nextSegment] = value; } } return ref.value; } stringifyPath(segments) { return segments.map((segment) => { return segment.toString().replace(/[\\[\]]/g, (match) => { switch (match) { case "\\": return "\\\\"; case "[": return "\\["; case "]": return "\\]"; /* v8 ignore next 2 */ default: return match; } }); }).reduce((result, segment, i) => { if (i === 0) { return segment; } return `${result}[${segment}]`; }, ""); } parsePath(path) { const segments = []; let inBrackets = false; let currentSegment = ""; let backslashCount = 0; for (let i = 0; i < path.length; i++) { const char = path[i]; const nextChar = path[i + 1]; if (inBrackets && char === "]" && (nextChar === void 0 || nextChar === "[") && backslashCount % 2 === 0) { if (nextChar === void 0) { inBrackets = false; } segments.push(currentSegment); currentSegment = ""; i++; } else if (segments.length === 0 && char === "[" && backslashCount % 2 === 0) { inBrackets = true; segments.push(currentSegment); currentSegment = ""; } else if (char === "\\") { backslashCount++; } else { currentSegment += "\\".repeat(backslashCount / 2) + char; backslashCount = 0; } } return inBrackets || segments.length === 0 ? [path] : segments; } } function isValidArrayIndex(value, maxIndex) { return /^0$|^[1-9]\d*$/.test(value) && Number(value) <= maxIndex; } function arrayToObject(array) { const obj = new NullProtoObj(); array.forEach((item, i) => { obj[i] = item; }); return obj; } function pushStyleArrayToObject(array) { const obj = new NullProtoObj(); obj[""] = array.length === 1 ? array[0] : array; return obj; } class StandardOpenAPIJsonSerializer { customSerializers; constructor(options = {}) { this.customSerializers = options.customJsonSerializers ?? []; } serialize(data, hasBlobRef = { value: false }) { for (const custom of this.customSerializers) { if (custom.condition(data)) { const result = this.serialize(custom.serialize(data), hasBlobRef); return result; } } if (data instanceof Blob) { hasBlobRef.value = true; return [data, hasBlobRef.value]; } if (data instanceof Set) { return this.serialize(Array.from(data), hasBlobRef); } if (data instanceof Map) { return this.serialize(Array.from(data.entries()), hasBlobRef); } if (Array.isArray(data)) { const json = data.map((v) => v === void 0 ? null : this.serialize(v, hasBlobRef)[0]); return [json, hasBlobRef.value]; } if (isObject(data)) { const json = {}; for (const k in data) { if (k === "toJSON" && typeof data[k] === "function") { continue; } json[k] = this.serialize(data[k], hasBlobRef)[0]; } return [json, hasBlobRef.value]; } if (typeof data === "bigint" || data instanceof RegExp || data instanceof URL) { return [data.toString(), hasBlobRef.value]; } if (data instanceof Date) { return [Number.isNaN(data.getTime()) ? null : data.toISOString(), hasBlobRef.value]; } if (Number.isNaN(data)) { return [null, hasBlobRef.value]; } return [data, hasBlobRef.value]; } } function standardizeHTTPPath(path) { return `/${path.replace(/\/{2,}/g, "/").replace(/^\/|\/$/g, "")}`; } function getDynamicParams(path) { return path ? standardizeHTTPPath(path).match(/\/\{[^}]+\}/g)?.map((v) => ({ raw: v, name: v.match(/\{\+?([^}]+)\}/)[1] })) : void 0; } class StandardOpenapiLinkCodec { constructor(contract, serializer, options) { this.contract = contract; this.serializer = serializer; this.baseUrl = options.url; this.headers = options.headers ?? {}; } baseUrl; headers; async encode(path, input, options) { const baseUrl = await value(this.baseUrl, options, path, input); let headers = await value(this.headers, options, path, input); if (options.lastEventId !== void 0) { headers = mergeStandardHeaders(headers, { "last-event-id": options.lastEventId }); } const procedure = get(this.contract, path); if (!isContractProcedure(procedure)) { throw new Error(`[StandardOpenapiLinkCodec] expect a contract procedure at ${path.join(".")}`); } const inputStructure = fallbackContractConfig("defaultInputStructure", procedure["~orpc"].route.inputStructure); return inputStructure === "compact" ? this.#encodeCompact(procedure, path, input, options, baseUrl, headers) : this.#encodeDetailed(procedure, path, input, options, baseUrl, headers); } #encodeCompact(procedure, path, input, options, baseUrl, headers) { let httpPath = standardizeHTTPPath(procedure["~orpc"].route.path ?? toHttpPath(path)); let httpBody = input; const dynamicParams = getDynamicParams(httpPath); if (dynamicParams?.length) { if (!isObject(input)) { throw new TypeError(`[StandardOpenapiLinkCodec] Invalid input shape for "compact" structure when has dynamic params at ${path.join(".")}.`); } const body = { ...input }; for (const param of dynamicParams) { const value2 = input[param.name]; httpPath = httpPath.replace(param.raw, `/${encodeURIComponent(`${this.serializer.serialize(value2)}`)}`); delete body[param.name]; } httpBody = Object.keys(body).length ? body : void 0; } const method = fallbackContractConfig("defaultMethod", procedure["~orpc"].route.method); const url = new URL(baseUrl); url.pathname = `${url.pathname.replace(/\/$/, "")}${httpPath}`; if (method === "GET") { const serialized = this.serializer.serialize(httpBody, { outputFormat: "URLSearchParams" }); for (const [key, value2] of serialized) { url.searchParams.append(key, value2); } return { url, method, headers, body: void 0, signal: options.signal }; } return { url, method, headers, body: this.serializer.serialize(httpBody), signal: options.signal }; } #encodeDetailed(procedure, path, input, options, baseUrl, headers) { let httpPath = standardizeHTTPPath(procedure["~orpc"].route.path ?? toHttpPath(path)); const dynamicParams = getDynamicParams(httpPath); if (!isObject(input) && input !== void 0) { throw new TypeError(`[StandardOpenapiLinkCodec] Invalid input shape for "detailed" structure at ${path.join(".")}.`); } if (dynamicParams?.length) { if (!isObject(input?.params)) { throw new TypeError(`[StandardOpenapiLinkCodec] Invalid input.params shape for "detailed" structure when has dynamic params at ${path.join(".")}.`); } for (const param of dynamicParams) { const value2 = input.params[param.name]; httpPath = httpPath.replace(param.raw, `/${encodeURIComponent(`${this.serializer.serialize(value2)}`)}`); } } let mergedHeaders = headers; if (input?.headers !== void 0) { if (!isObject(input.headers)) { throw new TypeError(`[StandardOpenapiLinkCodec] Invalid input.headers shape for "detailed" structure at ${path.join(".")}.`); } mergedHeaders = mergeStandardHeaders(input.headers, headers); } const method = fallbackContractConfig("defaultMethod", procedure["~orpc"].route.method); const url = new URL(baseUrl); url.pathname = `${url.pathname.replace(/\/$/, "")}${httpPath}`; if (input?.query !== void 0) { const query = this.serializer.serialize(input.query, { outputFormat: "URLSearchParams" }); for (const [key, value2] of query) { url.searchParams.append(key, value2); } } if (method === "GET") { return { url, method, headers: mergedHeaders, body: void 0, signal: options.signal }; } return { url, method, headers: mergedHeaders, body: this.serializer.serialize(input?.body), signal: options.signal }; } async decode(response, _options, path) { const isOk = !isORPCErrorStatus(response.status); const deserialized = await (async () => { let isBodyOk = false; try { const body = await response.body(); isBodyOk = true; return this.serializer.deserialize(body); } catch (error) { if (!isBodyOk) { throw new Error("Cannot parse response body, please check the response body and content-type.", { cause: error }); } throw new Error("Invalid OpenAPI response format.", { cause: error }); } })(); if (!isOk) { if (isORPCErrorJson(deserialized)) { throw createORPCErrorFromJson(deserialized); } throw new ORPCError(getMalformedResponseErrorCode(response.status), { status: response.status, data: { ...response, body: deserialized } }); } const procedure = get(this.contract, path); if (!isContractProcedure(procedure)) { throw new Error(`[StandardOpenapiLinkCodec] expect a contract procedure at ${path.join(".")}`); } const outputStructure = fallbackContractConfig("defaultOutputStructure", procedure["~orpc"].route.outputStructure); if (outputStructure === "compact") { return deserialized; } return { status: response.status, headers: response.headers, body: deserialized }; } } class StandardOpenAPISerializer { constructor(jsonSerializer, bracketNotation) { this.jsonSerializer = jsonSerializer; this.bracketNotation = bracketNotation; } serialize(data, options = {}) { if (isAsyncIteratorObject(data) && !options.outputFormat) { return mapEventIterator(data, { value: async (value) => this.#serialize(value, { outputFormat: "plain" }), error: async (e) => { return new ErrorEvent({ data: this.#serialize(toORPCError(e).toJSON(), { outputFormat: "plain" }), cause: e }); } }); } return this.#serialize(data, options); } #serialize(data, options) { const [json, hasBlob] = this.jsonSerializer.serialize(data); if (options.outputFormat === "plain") { return json; } if (options.outputFormat === "URLSearchParams") { const params = new URLSearchParams(); for (const [path, value] of this.bracketNotation.serialize(json)) { if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { params.append(path, value.toString()); } } return params; } if (json instanceof Blob || json === void 0 || !hasBlob) { return json; } const form = new FormData(); for (const [path, value] of this.bracketNotation.serialize(json)) { if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { form.append(path, value.toString()); } else if (value instanceof Blob) { form.append(path, value); } } return form; } deserialize(data) { if (data instanceof URLSearchParams || data instanceof FormData) { return this.bracketNotation.deserialize(Array.from(data.entries())); } if (isAsyncIteratorObject(data)) { return mapEventIterator(data, { value: async (value) => value, error: async (e) => { if (e instanceof ErrorEvent && isORPCErrorJson(e.data)) { return createORPCErrorFromJson(e.data, { cause: e }); } return e; } }); } return data; } } class StandardOpenAPILink extends StandardLink { constructor(contract, linkClient, options) { const jsonSerializer = new StandardOpenAPIJsonSerializer(options); const bracketNotationSerializer = new StandardBracketNotationSerializer({ maxBracketNotationArrayIndex: 4294967294 }); const serializer = new StandardOpenAPISerializer(jsonSerializer, bracketNotationSerializer); const linkCodec = new StandardOpenapiLinkCodec(contract, serializer, options); super(linkCodec, linkClient, options); } } export { StandardBracketNotationSerializer as S, StandardOpenAPIJsonSerializer as a, StandardOpenAPILink as b, StandardOpenapiLinkCodec as c, StandardOpenAPISerializer as d, getDynamicParams as g, standardizeHTTPPath as s };