@ts-rest/core
Version:
RPC-like experience over a regular REST API, with type safe server implementations 🪄
594 lines (582 loc) • 21.8 kB
JavaScript
import { z } from '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 = z.object({
name: z.literal('ZodError'),
issues: z.array(z
.object({
path: z.array(z.union([z.string(), z.number()])),
message: z.string().optional(),
code: z.nativeEnum(z.ZodIssueCode),
})
// ZodIssuse type are complex and potentially unstable. So we don’t deal with his specific fields other than the common.
.catchall(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}`);
};
export { ContractNoBody, ContractPlainTypeRuntimeSymbol, ResponseValidationError, TsRestResponseError, UnknownStatusError, ZodErrorSchema, checkZodSchema, convertQueryParamsToUrlString, encodeQueryParams, encodeQueryParamsJson, evaluateFetchApiArgs, exhaustiveGuard, extractZodObjectShape, fetchApi, getCompleteUrl, getRouteQuery, getRouteResponses, initClient, initContract, initTsRest, insertParamsIntoPath, isAppRoute, isAppRouteMutation, isAppRouteNoBody, isAppRouteOtherResponse, isAppRouteQuery, isAppRouteResponse, isErrorResponse, isResponse, isSuccessResponse, isUnknownErrorResponse, isUnknownResponse, isUnknownSuccessResponse, isZodObject, isZodObjectStrict, isZodType, parseJsonQueryObject, tsRestFetchApi, validateResponse, zodErrorResponse, zodMerge };