@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
JavaScript
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 };