UNPKG

@ts-rest/core

Version:

RPC-like experience over a regular REST API, with type safe server implementations 🪄

638 lines (624 loc) • 23 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var zod = require('zod'); const isZodType = (obj) => { return typeof (obj === null || obj === void 0 ? void 0 : obj.safeParse) === 'function'; }; const isZodObject = (obj) => { if (typeof (obj === null || obj === void 0 ? void 0 : obj.passthrough) === 'function') { return true; } if (typeof (obj === null || obj === void 0 ? void 0 : obj.innerType) === 'function') { return isZodObject(obj === null || obj === void 0 ? void 0 : obj.innerType()); } return false; }; const isZodObjectStrict = (obj) => { return typeof (obj === null || obj === void 0 ? void 0 : obj.passthrough) === 'function'; }; const extractZodObjectShape = (obj) => { if (!isZodObject(obj)) { throw new Error('Unknown zod object type'); } if ('innerType' in obj) { return extractZodObjectShape(obj.innerType()); } return obj.shape; }; const zodMerge = (objectA, objectB) => { if (isZodObjectStrict(objectA)) { if (isZodObjectStrict(objectB)) { return objectA.merge(objectB); } return objectA; } if (isZodObjectStrict(objectB)) { return objectB; } return Object.assign({}, objectA, objectB); }; const checkZodSchema = (data, schema, { passThroughExtraKeys = false } = {}) => { if (isZodType(schema)) { const result = schema.safeParse(data); if (result.success) { return { success: true, data: passThroughExtraKeys && typeof data === 'object' ? { ...data, ...result.data } : result.data, }; } return { success: false, error: result.error, }; } return { success: true, data: data, }; }; // Convert a ZodError to a plain object because ZodError extends Error and causes problems with NestJS const zodErrorResponse = (error) => { return { name: error.name, issues: error.issues, }; }; const ZodErrorSchema = zod.z.object({ name: zod.z.literal('ZodError'), issues: zod.z.array(zod.z .object({ path: zod.z.array(zod.z.union([zod.z.string(), zod.z.number()])), message: zod.z.string().optional(), code: zod.z.nativeEnum(zod.z.ZodIssueCode), }) // ZodIssuse type are complex and potentially unstable. So we don’t deal with his specific fields other than the common. .catchall(zod.z.any())), }); const ContractNoBody = Symbol('ContractNoBody'); /** * Differentiate between a route and a router * * @param obj * @returns */ const isAppRoute = (obj) => { return 'method' in obj && 'path' in obj; }; const isAppRouteQuery = (route) => { return route.method === 'GET'; }; const isAppRouteMutation = (route) => { return !isAppRouteQuery(route); }; /** * * @deprecated Please use {@link initContract} instead. */ const initTsRest = () => initContract(); const recursivelyApplyOptions = (router, options) => { return Object.fromEntries(Object.entries(router).map(([key, value]) => { var _a, _b, _c; if (isAppRoute(value)) { return [ key, { ...value, path: (options === null || options === void 0 ? void 0 : options.pathPrefix) ? options.pathPrefix + value.path : value.path, headers: zodMerge(options === null || options === void 0 ? void 0 : options.baseHeaders, value.headers), strictStatusCodes: (_a = value.strictStatusCodes) !== null && _a !== void 0 ? _a : options === null || options === void 0 ? void 0 : options.strictStatusCodes, validateResponseOnClient: (_b = value.validateResponseOnClient) !== null && _b !== void 0 ? _b : options === null || options === void 0 ? void 0 : options.validateResponseOnClient, responses: { ...options === null || options === void 0 ? void 0 : options.commonResponses, ...value.responses, }, metadata: (options === null || options === void 0 ? void 0 : options.metadata) ? { ...options === null || options === void 0 ? void 0 : options.metadata, ...((_c = value.metadata) !== null && _c !== void 0 ? _c : {}), } : value.metadata, }, ]; } else { return [key, recursivelyApplyOptions(value, options)]; } })); }; const ContractPlainTypeRuntimeSymbol = Symbol('ContractPlainType'); /** * Instantiate a ts-rest client, primarily to access `router`, `response`, and `body` * * @returns {ContractInstance} */ const initContract = () => { return { // @ts-expect-error - this is a type error, but it's not clear how to fix it router: (endpoints, options) => recursivelyApplyOptions(endpoints, options), query: (args) => args, mutation: (args) => args, responses: (args) => args, response: () => ContractPlainTypeRuntimeSymbol, body: () => ContractPlainTypeRuntimeSymbol, type: () => ContractPlainTypeRuntimeSymbol, otherResponse: ({ contentType, body, }) => ({ contentType, body, }), noBody: () => ContractNoBody, }; }; /** * @param path - The URL e.g. /posts/:id * @param params - The params e.g. `{ id: string }` * @returns - The URL with the params e.g. /posts/123 */ const insertParamsIntoPath = ({ path, params, }) => { const pathParams = params; return path.replace(/\/?:([^/?]+)\??/g, (matched, p) => pathParams[p] ? `${matched.startsWith('/') ? '/' : ''}${pathParams[p]}` : ''); }; /** * * @param query - Any JSON object * @param json - Use JSON.stringify to encode the query values * @returns - The query url segment, using explode array syntax, and deep object syntax */ const convertQueryParamsToUrlString = (query, json = false) => { const queryString = json ? encodeQueryParamsJson(query) : encodeQueryParams(query); return (queryString === null || queryString === void 0 ? void 0 : queryString.length) > 0 ? '?' + queryString : ''; }; const encodeQueryParamsJson = (query) => { if (!query) { return ''; } return Object.entries(query) .filter(([, value]) => value !== undefined) .map(([key, value]) => { let encodedValue; // if value is a string and is not a reserved JSON value or a number, pass it without encoding // this makes strings look nicer in the URL (e.g. ?name=John instead of ?name=%22John%22) // this is also how OpenAPI will pass strings even if they are marked as application/json types if (typeof value === 'string' && !['true', 'false', 'null'].includes(value.trim()) && isNaN(Number(value))) { encodedValue = value; } else { encodedValue = JSON.stringify(value); } return `${encodeURIComponent(key)}=${encodeURIComponent(encodedValue)}`; }) .join('&'); }; const encodeQueryParams = (query) => { if (!query) { return ''; } return (Object.keys(query) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore .flatMap((key) => tokeniseValue(key, query[key])) .map(([key, value]) => { return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; }) .join('&')); }; /** * A recursive function to convert an object/string/number/Date/whatever into an array of key=value pairs * * The output of this should be flatMap-able to a string of key=value pairs which can be * joined with & to form a query string * * This should be fully compatible with the "qs" library, but without the need to add a dependency */ const tokeniseValue = (key, value) => { if (Array.isArray(value)) { return value.flatMap((v, idx) => tokeniseValue(`${key}[${idx}]`, v)); } if (value instanceof Date) { return [[`${key}`, value.toISOString()]]; } if (value === null) { return [[`${key}`, '']]; } if (value === undefined) { return []; } if (typeof value === 'object') { return Object.keys(value).flatMap((k) => // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore tokeniseValue(`${key}[${k}]`, value[k])); } return [[`${key}`, `${value}`]]; }; /** * * @param query - A server-side query object where values have been encoded as JSON strings * @returns - The same object with the JSON strings decoded. Objects that were encoded using toJSON such as Dates will remain as strings */ const parseJsonQueryObject = (query) => { return Object.fromEntries(Object.entries(query).map(([key, value]) => { let parsedValue; // if json parse fails, treat the value as a string // this allows us to pass strings without having to surround them with quotes try { parsedValue = JSON.parse(value); } catch { parsedValue = value; } return [key, parsedValue]; })); }; class UnknownStatusError extends Error { constructor(response, knownResponseStatuses) { const expectedStatuses = knownResponseStatuses.join(','); super(`Server returned unexpected response. Expected one of: ${expectedStatuses} got: ${response.status}`); this.response = response; } } /** * @deprecated Only safe to use on the client-side. Use `ServerInferResponses`/`ClientInferResponses` instead. */ function getRouteResponses(router) { return {}; } /** * Default fetch api implementation: * * Can be used as a reference for implementing your own fetcher, * or used in the "api" field of ClientArgs to allow you to hook * into the request to run custom logic */ const tsRestFetchApi = async ({ route, path, method, headers, body, validateResponse, fetchOptions, }) => { const result = await fetch(path, { ...fetchOptions, method, headers, body, }); const contentType = result.headers.get('content-type'); if ((contentType === null || contentType === void 0 ? void 0 : contentType.includes('application/')) && (contentType === null || contentType === void 0 ? void 0 : contentType.includes('json'))) { const response = { status: result.status, body: await result.json(), headers: result.headers, }; const responseSchema = route.responses[response.status]; if ((validateResponse !== null && validateResponse !== void 0 ? validateResponse : route.validateResponseOnClient) && isZodType(responseSchema)) { return { ...response, body: responseSchema.parse(response.body), }; } return response; } if (contentType === null || contentType === void 0 ? void 0 : contentType.includes('text/')) { return { status: result.status, body: await result.text(), headers: result.headers, }; } return { status: result.status, body: await result.blob(), headers: result.headers, }; }; const createFormData = (body) => { const formData = new FormData(); const appendToFormData = (key, value) => { if (value instanceof File) { formData.append(key, value); } else { formData.append(key, JSON.stringify(value)); } }; Object.entries(body).forEach(([key, value]) => { if (Array.isArray(value)) { for (const item of value) { appendToFormData(key, item); } } else { appendToFormData(key, value); } }); return formData; }; const normalizeHeaders = (headers) => { return Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])); }; const fetchApi = (options) => { const { path, clientArgs, route, body, query, extraInputArgs, headers, fetchOptions, } = options; const apiFetcher = clientArgs.api || tsRestFetchApi; const baseHeaders = clientArgs.baseHeaders && Object.fromEntries(Object.entries(clientArgs.baseHeaders).map(([name, valueOrFunction]) => { if (typeof valueOrFunction === 'function') { return [name, valueOrFunction(options)]; } else { return [name, valueOrFunction]; } })); const combinedHeaders = { ...(baseHeaders && normalizeHeaders(baseHeaders)), ...normalizeHeaders(headers), }; // Remove any headers that are set to undefined Object.keys(combinedHeaders).forEach((key) => { if (combinedHeaders[key] === undefined) { delete combinedHeaders[key]; } }); let fetcherArgs = { route, path, method: route.method, headers: combinedHeaders, body: undefined, rawBody: body, rawQuery: query, contentType: undefined, validateResponse: clientArgs.validateResponse, fetchOptions: { ...(clientArgs.credentials && { credentials: clientArgs.credentials }), ...fetchOptions, }, ...((fetchOptions === null || fetchOptions === void 0 ? void 0 : fetchOptions.signal) && { signal: fetchOptions.signal }), ...((fetchOptions === null || fetchOptions === void 0 ? void 0 : fetchOptions.cache) && { cache: fetchOptions.cache }), ...(fetchOptions && 'next' in fetchOptions && !!(fetchOptions === null || fetchOptions === void 0 ? void 0 : fetchOptions.next) && { next: fetchOptions.next }), }; if (route.method !== 'GET') { if ('contentType' in route && route.contentType === 'multipart/form-data') { fetcherArgs = { ...fetcherArgs, contentType: 'multipart/form-data', body: body instanceof FormData ? body : createFormData(body), }; } else if ('contentType' in route && route.contentType === 'application/x-www-form-urlencoded') { fetcherArgs = { ...fetcherArgs, contentType: 'application/x-www-form-urlencoded', headers: { 'content-type': 'application/x-www-form-urlencoded', ...fetcherArgs.headers, }, body: typeof body === 'string' ? body : new URLSearchParams(body), }; } else if (body !== null && body !== undefined) { fetcherArgs = { ...fetcherArgs, contentType: 'application/json', headers: { 'content-type': 'application/json', ...fetcherArgs.headers, }, body: JSON.stringify(body), }; } } return apiFetcher({ ...fetcherArgs, ...extraInputArgs, }); }; const evaluateFetchApiArgs = (route, clientArgs, inputArgs) => { const { query, params, body, headers, extraHeaders, overrideClientOptions, fetchOptions, // TODO: remove in 4.0 cache, // TODO: remove in 4.0 next, // extra input args ...extraInputArgs } = inputArgs || {}; const overriddenClientArgs = { ...clientArgs, ...overrideClientOptions, }; const completeUrl = getCompleteUrl(query, overriddenClientArgs.baseUrl, params, route, !!overriddenClientArgs.jsonQuery); return { path: completeUrl, clientArgs: overriddenClientArgs, route, body, query, extraInputArgs, fetchOptions: { ...(cache && { cache }), ...(next && { next }), ...fetchOptions, }, headers: { ...extraHeaders, ...headers, }, }; }; /** * @hidden */ const getCompleteUrl = (query, baseUrl, params, route, jsonQuery) => { const path = insertParamsIntoPath({ path: route.path, params: params, }); const queryComponent = convertQueryParamsToUrlString(query, jsonQuery); return `${baseUrl}${path}${queryComponent}`; }; const getRouteQuery = (route, clientArgs) => { const knownResponseStatuses = Object.keys(route.responses); return async (inputArgs) => { const fetchApiArgs = evaluateFetchApiArgs(route, clientArgs, inputArgs); const response = await fetchApi(fetchApiArgs); // TODO: in next major version, throw by default if `strictStatusCode` is enabled if (!clientArgs.throwOnUnknownStatus) { return response; } if (knownResponseStatuses.includes(response.status.toString())) { return response; } throw new UnknownStatusError(response, knownResponseStatuses); }; }; const initClient = (router, args) => { return Object.fromEntries(Object.entries(router).map(([key, subRouter]) => { if (isAppRoute(subRouter)) { return [key, getRouteQuery(subRouter, args)]; } else { return [key, initClient(subRouter, args)]; } })); }; class ResponseValidationError extends Error { constructor(appRoute, cause) { super(`[ts-rest] Response validation failed for ${appRoute.method} ${appRoute.path}: ${cause.message}`); this.appRoute = appRoute; this.cause = cause; } } const isAppRouteResponse = (value) => { return (value != null && typeof value === 'object' && 'status' in value && typeof value.status === 'number'); }; const isAppRouteOtherResponse = (response) => { return (response != null && typeof response === 'object' && 'contentType' in response); }; const isAppRouteNoBody = (response) => { return response === ContractNoBody; }; const validateResponse = ({ appRoute, response, }) => { if (isAppRouteResponse(response)) { const responseType = appRoute.responses[response.status]; const responseSchema = isAppRouteOtherResponse(responseType) ? responseType.body : responseType; const responseValidation = checkZodSchema(response.body, responseSchema); if (!responseValidation.success) { throw new ResponseValidationError(appRoute, responseValidation.error); } return { status: response.status, body: responseValidation.data, }; } return response; }; class TsRestResponseError extends Error { constructor(route, response) { super(); this.statusCode = response.status; this.body = response.body; this.name = this.constructor.name; if (typeof response.body === 'string') { this.message = response.body; } else if (typeof response.body === 'object' && response.body !== null && 'message' in response.body && typeof response.body.message === 'string') { this.message = response.body['message']; } else { this.message = 'Error'; } } } const isResponse = (response, contractEndpoint) => { return (typeof response === 'object' && response !== null && 'status' in response && 'body' in response && typeof response.status === 'number' && response.status >= 200 && response.status < 600 && ((contractEndpoint === null || contractEndpoint === void 0 ? void 0 : contractEndpoint.strictStatusCodes) ? Object.keys(contractEndpoint.responses).includes(response.status.toString()) : true)); }; const isSuccessResponse = (response, contractEndpoint) => { return (isResponse(response, contractEndpoint) && response.status >= 200 && response.status < 300); }; const isErrorResponse = (response, contractEndpoint) => { return (isResponse(response, contractEndpoint) && !isSuccessResponse(response, contractEndpoint)); }; const isUnknownResponse = (response, contractEndpoint) => { return (isResponse(response) && !Object.keys(contractEndpoint.responses).includes(response.status.toString())); }; const isUnknownSuccessResponse = (response, contractEndpoint) => { return (isSuccessResponse(response) && isUnknownResponse(response, contractEndpoint)); }; const isUnknownErrorResponse = (response, contractEndpoint) => { return (isErrorResponse(response) && isUnknownResponse(response, contractEndpoint)); }; const exhaustiveGuard = (response) => { throw new Error(`Unreachable code: Response status is ${response.status}`); }; exports.ContractNoBody = ContractNoBody; exports.ContractPlainTypeRuntimeSymbol = ContractPlainTypeRuntimeSymbol; exports.ResponseValidationError = ResponseValidationError; exports.TsRestResponseError = TsRestResponseError; exports.UnknownStatusError = UnknownStatusError; exports.ZodErrorSchema = ZodErrorSchema; exports.checkZodSchema = checkZodSchema; exports.convertQueryParamsToUrlString = convertQueryParamsToUrlString; exports.encodeQueryParams = encodeQueryParams; exports.encodeQueryParamsJson = encodeQueryParamsJson; exports.evaluateFetchApiArgs = evaluateFetchApiArgs; exports.exhaustiveGuard = exhaustiveGuard; exports.extractZodObjectShape = extractZodObjectShape; exports.fetchApi = fetchApi; exports.getCompleteUrl = getCompleteUrl; exports.getRouteQuery = getRouteQuery; exports.getRouteResponses = getRouteResponses; exports.initClient = initClient; exports.initContract = initContract; exports.initTsRest = initTsRest; exports.insertParamsIntoPath = insertParamsIntoPath; exports.isAppRoute = isAppRoute; exports.isAppRouteMutation = isAppRouteMutation; exports.isAppRouteNoBody = isAppRouteNoBody; exports.isAppRouteOtherResponse = isAppRouteOtherResponse; exports.isAppRouteQuery = isAppRouteQuery; exports.isAppRouteResponse = isAppRouteResponse; exports.isErrorResponse = isErrorResponse; exports.isResponse = isResponse; exports.isSuccessResponse = isSuccessResponse; exports.isUnknownErrorResponse = isUnknownErrorResponse; exports.isUnknownResponse = isUnknownResponse; exports.isUnknownSuccessResponse = isUnknownSuccessResponse; exports.isZodObject = isZodObject; exports.isZodObjectStrict = isZodObjectStrict; exports.isZodType = isZodType; exports.parseJsonQueryObject = parseJsonQueryObject; exports.tsRestFetchApi = tsRestFetchApi; exports.validateResponse = validateResponse; exports.zodErrorResponse = zodErrorResponse; exports.zodMerge = zodMerge;