UNPKG

@forklaunch/universal-sdk

Version:

Cross runtime fetch library for forklaunch router sdks

680 lines (671 loc) 21.8 kB
// src/universalSdk.ts import { openApiCompliantPath, safeParse, safeStringify } from "@forklaunch/common"; import Ajv from "ajv"; import addFormats from "ajv-formats"; // src/core/coerceSpecialTypes.ts function base64ToArrayBuffer(base64) { if (typeof atob === "function") { const binaryString = atob(base64); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; ++i) { bytes[i] = binaryString.charCodeAt(i); } return bytes.buffer; } else if (typeof Buffer !== "undefined") { const buf = Buffer.from(base64, "base64"); return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } else { throw new Error("No base64 decoder available in this environment"); } } function handleSpecialString(v, format) { if (typeof v !== "string") return v; if (format === "date-time") { const d = new Date(v); return isNaN(d.getTime()) ? v : d; } if (format === "binary") { try { return base64ToArrayBuffer(v); } catch { throw new Error("Invalid base64 string"); } } return v; } function getType(type) { if (Array.isArray(type)) return type[0]; return type; } function getFormatsFromSchema(def) { const formats = /* @__PURE__ */ new Set(); if (!def) return formats; if (def.format) formats.add(def.format); for (const keyword of ["anyOf", "oneOf"]) { if (Array.isArray(def[keyword])) { for (const sub of def[keyword]) { if (sub && typeof sub === "object") { if (sub.format) formats.add(sub.format); } } } } return formats; } function getTypeFromSchema(def) { if (!def) return void 0; const t = getType(def.type); if (!t) { for (const keyword of ["anyOf", "oneOf"]) { if (Array.isArray(def[keyword])) { for (const sub of def[keyword]) { const subType = getType(sub.type); if (subType) return subType; } } } } return t; } function getSpecialFormat(def) { const formats = getFormatsFromSchema(def); if (formats.has("date-time")) return "date-time"; if (formats.has("binary")) return "binary"; return void 0; } function coerceSpecialTypes(input, schema) { const props = schema.properties || {}; for (const [key, def] of Object.entries(props)) { if (!def) continue; const value = input[key]; const type = getTypeFromSchema(def); if (type === "object" && def.properties && typeof value === "object" && value !== null) { input[key] = coerceSpecialTypes( value, def ); continue; } if (type === "array" && def.items && Array.isArray(value)) { input[key] = value.map((item) => { const itemDef = def.items; const itemType = getTypeFromSchema(itemDef); if (itemType === "object" && itemDef.properties && typeof item === "object" && item !== null) { return coerceSpecialTypes( item, itemDef ); } if (itemType === "string") { const format = getSpecialFormat(itemDef); return handleSpecialString(item, format); } return item; }); continue; } if (type === "string") { const format = getSpecialFormat(def); input[key] = handleSpecialString(value, format); } } return input; } // src/core/mapContentType.ts import { isNever } from "@forklaunch/common"; function mapContentType(contentType) { switch (contentType) { case "json": return "application/json"; case "file": return "application/octet-stream"; case "text": return "text/plain"; case "stream": return "text/event-stream"; case void 0: return "application/json"; default: isNever(contentType); return "application/json"; } } // src/guards/isOpenApiObject.ts function isOpenAPIObject(obj) { return typeof obj === "object" && obj !== null && "openapi" in obj; } // src/core/openApi.ts function getSdkPathMap(registryOpenApiJson) { const sdkPathMap = {}; Object.entries(registryOpenApiJson).forEach(([version, openApi]) => { Object.entries(openApi?.paths || {}).forEach(([path, pathItem]) => { const methods = [ "get", "post", "put", "patch", "delete", "options", "head", "trace" ]; const versionedPath = version === "latest" ? void 0 : version.substring(1); methods.forEach((method) => { if (pathItem[method]?.operationId) { sdkPathMap[`${pathItem[method].operationId}${versionedPath ? `.${versionedPath}` : ""}`] = { method, path, version }; } }); }); }); return sdkPathMap; } async function refreshOpenApi(host, registryOptions, existingRegistryOpenApiHash) { if (existingRegistryOpenApiHash === "static" || "static" in registryOptions && registryOptions.static) { return { updateRequired: false }; } if ("raw" in registryOptions) { const rawVersionedOpenApi = isOpenAPIObject(registryOptions.raw) ? { latest: registryOptions.raw } : registryOptions.raw; return { updateRequired: true, registryOpenApiJson: rawVersionedOpenApi, registryOpenApiHash: "static", sdkPathMap: getSdkPathMap(rawVersionedOpenApi) }; } const registry = "path" in registryOptions ? `${host}/${registryOptions.path}` : "url" in registryOptions ? registryOptions.url : null; if (registry == null) { throw new Error("Raw OpenAPI JSON or registry information not provided"); } const registryOpenApiHashFetch = await fetch(`${registry}-hash`); if (!registryOpenApiHashFetch.ok) { throw new Error( `Failed to fetch OpenAPI registry hash: ${registryOpenApiHashFetch.status} ${registryOpenApiHashFetch.statusText}` ); } const registryOpenApiHash = await registryOpenApiHashFetch.text(); if (existingRegistryOpenApiHash == null || existingRegistryOpenApiHash !== registryOpenApiHash) { const registryOpenApiFetch = await fetch(registry); if (!registryOpenApiFetch.ok) { throw new Error( `Failed to fetch OpenAPI registry: ${registryOpenApiFetch.status} ${registryOpenApiFetch.statusText}` ); } const registryOpenApiJson = await registryOpenApiFetch.json(); return { updateRequired: true, registryOpenApiJson, registryOpenApiHash, sdkPathMap: getSdkPathMap(registryOpenApiJson) }; } return { updateRequired: false }; } // src/core/regex.ts function generateStringFromRegex(regex) { let regexStr = typeof regex === "object" ? regex.source : regex; if (regexStr.startsWith("/")) regexStr = regexStr.slice(1); if (regexStr.endsWith("/g")) regexStr = regexStr.slice(0, -2); if (regexStr.endsWith("/")) regexStr = regexStr.slice(0, -1); let result = ""; let i = 0; while (i < regexStr.length) { const char = regexStr[i]; switch (char) { case "\\": { const nextChar = regexStr[i + 1]; switch (nextChar) { case "b": if (result.length > 0 && /\w/.test(result[result.length - 1])) { result += " "; } break; case "d": result += "0"; break; case "w": result += "a"; break; case "s": result += " "; break; default: result += nextChar; } i += 2; break; } case ".": result += "a"; i++; break; case "[": { const endIdx = regexStr.indexOf("]", i); if (endIdx === -1) { throw new Error("Unmatched ["); } const charClass = regexStr.slice(i + 1, endIdx); result += charClass[0]; i = endIdx + 1; break; } case "(": { const endGroupIdx = regexStr.indexOf(")", i); if (endGroupIdx === -1) { throw new Error("Unmatched ("); } const groupContent = regexStr.slice(i + 1, endGroupIdx); result += generateStringFromRegex(groupContent); i = endGroupIdx + 1; break; } case "{": { const endQuantIdx = regexStr.indexOf("}", i); if (endQuantIdx === -1) { throw new Error("Unmatched {"); } const quantifier = regexStr.slice(i + 1, endQuantIdx); const min = parseInt(quantifier.split(",")[0], 10) || 1; const lastChar = result[result.length - 1]; result += lastChar.repeat(min - 1); i = endQuantIdx + 1; break; } case "*": case "+": case "?": { const prevChar = result[result.length - 1]; if (char === "*") { result += prevChar; } else if (char === "+") { result += prevChar; } i++; break; } default: result += char; i++; break; } } return result; } // src/core/resolvePath.ts function getSdkPath(path) { let sdkPath = path; if (Array.isArray(path)) { sdkPath = path.pop() || path[0]; } if (!sdkPath) { throw new Error("Path is not defined"); } if (sdkPath instanceof RegExp) { sdkPath = generateStringFromRegex(sdkPath); } return sdkPath; } // src/universalSdk.ts var UniversalSdk = class _UniversalSdk { constructor(host, ajv, registryOptions, contentTypeParserMap) { this.host = host; this.ajv = ajv; this.registryOptions = registryOptions; this.contentTypeParserMap = contentTypeParserMap; } registryOpenApiJson; registryOpenApiHash; sdkPathMap; /** * Creates an instance of UniversalSdk. * * @param {string} host - The host URL for the SDK. */ static async create(host, registryOptions, contentTypeParserMap) { const ajv = new Ajv({ coerceTypes: true, allErrors: true, strict: false }); addFormats(ajv); return new _UniversalSdk(host, ajv, registryOptions, contentTypeParserMap); } async executeFetchCall(path, request) { if (!this.host) { throw new Error("Host not initialized, please run .create(..) first"); } const refreshResult = await refreshOpenApi( this.host, this.registryOptions, this.registryOpenApiHash ); if (refreshResult.updateRequired) { this.registryOpenApiJson = refreshResult.registryOpenApiJson; this.registryOpenApiHash = refreshResult.registryOpenApiHash; this.sdkPathMap = refreshResult.sdkPathMap; } return this.execute( path, request?.method ?? "get", request?.version ? `v${request.version}` : "latest", request ); } async executeSdkCall(sdkPath, request) { if (!this.host) { throw new Error("Host not initialized, please run .create(..) first"); } const refreshResult = await refreshOpenApi( this.host, this.registryOptions, this.registryOpenApiHash ); if (refreshResult.updateRequired) { this.registryOpenApiJson = refreshResult.registryOpenApiJson; this.registryOpenApiHash = refreshResult.registryOpenApiHash; this.sdkPathMap = refreshResult.sdkPathMap; } if (this.sdkPathMap == null) { throw new Error( "Sdk path map not initialized, please run .create(..) first" ); } const fullSdkPath = sdkPath.split("."); while (fullSdkPath.length > 0) { if (fullSdkPath.join(".") in this.sdkPathMap) { break; } fullSdkPath.shift(); } if (fullSdkPath.length === 0) { throw new Error(`Sdk path not found: ${sdkPath}`); } const { method, path, version } = this.sdkPathMap?.[fullSdkPath.join(".")] || {}; return this.execute(path, method, version, request); } /** * Executes an HTTP request. * * @param {string} route - The route path for the request. * @param {'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'} method - The HTTP method. * @param {RequestType} [request] - The request object. * @returns {Promise<ResponseType>} - The response object. */ async execute(path, method, version, request) { const { params, body, query, headers } = request || {}; let url = getSdkPath(this.host + path).replace(/([^:]\/)\/+/g, "$1"); if (params) { for (const key in params) { const paramValue = encodeURIComponent(params[key]); url = url.replace(`:${key}`, paramValue); url = url.replace(`{${key}}`, paramValue); } } let defaultContentType = "application/json"; let parsedBody; if (body != null) { if (body instanceof File || body instanceof Blob) { defaultContentType = "application/octet-stream"; parsedBody = body; } else if ("schema" in body && body.schema != null) { defaultContentType = "application/json"; parsedBody = safeStringify(body.schema); } else if ("json" in body && body.json != null) { defaultContentType = "application/json"; parsedBody = safeStringify(body.json); } else if ("text" in body && body.text != null) { defaultContentType = "text/plain"; parsedBody = body.text; } else if ("file" in body && body.file != null) { defaultContentType = "application/octet-stream"; parsedBody = body.file; } else if ("multipartForm" in body && body.multipartForm != null) { defaultContentType = "multipart/form-data"; const formData = new FormData(); for (const key in body.multipartForm) { if (Object.prototype.hasOwnProperty.call(body.multipartForm, key)) { const value = body.multipartForm[key]; if (value instanceof Blob || value instanceof File) { formData.append(key, value); } else if (Array.isArray(value)) { for (const item of value) { formData.append( key, item instanceof Blob || item instanceof File ? item : safeStringify(item) ); } } else { formData.append(key, safeStringify(value)); } } } parsedBody = formData; } else if ("urlEncodedForm" in body && body.urlEncodedForm != null) { defaultContentType = "application/x-www-form-urlencoded"; parsedBody = new URLSearchParams( Object.entries(body.urlEncodedForm).map(([key, value]) => [ key, safeStringify(value) ]) ); } else { parsedBody = safeStringify(body); } } if (query) { const queryString = new URLSearchParams( Object.entries(query).map(([key, value]) => [key, safeStringify(value)]) ).toString(); url += queryString ? `?${queryString}` : ""; } const response = await fetch(encodeURI(url), { method: method?.toUpperCase(), headers: { ...headers, ...defaultContentType != "multipart/form-data" ? { "Content-Type": body?.contentType ?? defaultContentType } : {} }, body: parsedBody }); const responseOpenApi = path != null && method != null ? this.registryOpenApiJson?.[version]?.paths?.[openApiCompliantPath(path)]?.[method?.toLowerCase()]?.responses?.[response.status] : null; if (responseOpenApi == null) { throw new Error( `Response ${response.status} not found in OpenAPI spec for ${path} with method ${method}` ); } const contentType = (response.headers.get("content-type") || response.headers.get("Content-Type"))?.split(";")[0]; const mappedContentType = (contentType != null ? this.contentTypeParserMap != null && contentType in this.contentTypeParserMap ? mapContentType(this.contentTypeParserMap[contentType]) : contentType : "application/json").split(";")[0]; let responseBody; switch (mappedContentType) { case "application/octet-stream": { const contentDisposition = response.headers.get("content-disposition"); let fileName = null; if (contentDisposition) { const match = /filename\*?=(?:UTF-8''|")?([^;\r\n"]+)/i.exec( contentDisposition ); if (match) { fileName = decodeURIComponent(match[1].replace(/['"]/g, "")); } } const blob = await response.blob(); if (fileName == null) { responseBody = blob; } else { responseBody = new File([blob], fileName); } break; } case "text/event-stream": { const ajv = this.ajv; async function* streamEvents(reader) { const decoder = new TextDecoder(); let buffer = ""; let lastEventId; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let newlineIndex; while ((newlineIndex = buffer.indexOf("\n")) >= 0) { const line = buffer.slice(0, newlineIndex).trim(); buffer = buffer.slice(newlineIndex + 1); if (line.startsWith("id:")) { lastEventId = line.slice(3).trim(); } else if (line.startsWith("data:")) { const data = line.slice(5).trim(); const json = { data: safeParse(data), id: lastEventId }; const isValidJson = ajv.validate( responseOpenApi.content?.[contentType || mappedContentType].schema, json ); if (!isValidJson) { throw new Error("Response does not match OpenAPI spec"); } yield coerceSpecialTypes( json, responseOpenApi.content?.[contentType || mappedContentType].schema ); } } } if (buffer.length > 0) { let id; let data; const lines = buffer.trim().split("\n"); for (const l of lines) { const line = l.trim(); if (line.startsWith("id:")) { id = line.slice(3).trim(); } else if (line.startsWith("data:")) { data = line.slice(5).trim(); } } if (data !== void 0) { const json = { data: safeParse(data), id: id ?? lastEventId }; const isValidJson = ajv.validate( responseOpenApi.content?.[contentType || mappedContentType].schema, json ); if (!isValidJson) { throw new Error("Response does not match OpenAPI spec"); } yield coerceSpecialTypes( json, responseOpenApi.content?.[contentType || mappedContentType].schema ); } } } if (!response.body) { throw new Error("No response body for event stream"); } responseBody = streamEvents(response.body.getReader()); break; } case "text/plain": responseBody = await response.text(); break; case "application/json": default: { const json = await response.json(); const isValidJson = this.ajv.validate( responseOpenApi.content?.[contentType || mappedContentType].schema, json ); if (!isValidJson) { throw new Error( `Response does not match OpenAPI spec: ${JSON.stringify(this.ajv.errors)}` ); } responseBody = coerceSpecialTypes( json, responseOpenApi.content?.[contentType || mappedContentType].schema ); break; } } return { code: response.status, response: responseBody, headers: response.headers }; } }; // index.ts var universalSdk = async (options) => { const sdkInternal = await UniversalSdk.create( options.host, options.registryOptions, "contentTypeParserMap" in options ? options.contentTypeParserMap : void 0 ); const proxyInternal = new Proxy(sdkInternal, { get(target, prop) { if (prop === "then" || prop === "catch" || prop === "finally") { return void 0; } if (prop === "fetch") { return sdkInternal.executeFetchCall; } if (typeof prop === "string" && prop in target) { const value = target[prop]; if (typeof value === "function") { return value.bind(target); } return value; } return createSdkProxy([prop]); } }); function createSdkProxy(path) { return new Proxy(() => { }, { get(_target, prop) { if (prop === "then" || prop === "catch" || prop === "finally") { return void 0; } if (prop === "fetch") { return sdkInternal.executeFetchCall; } if (typeof prop === "string" && prop in sdkInternal) { const value = sdkInternal[prop]; if (typeof value === "function") { return value.bind(sdkInternal); } return value; } const newPath = [...path, prop]; if (prop === Symbol.toPrimitive || prop === "valueOf" || prop === "toString") { return () => sdkInternal.executeSdkCall(path.join(".")); } return createSdkProxy(newPath); }, apply(_target, _thisArg, args) { return sdkInternal.executeSdkCall(path.join("."), ...args); } }); } return proxyInternal; }; export { universalSdk };