mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: add envelopes api (#2105)
This commit is contained in:
17
packages/trpc/utils/data-transformer.ts
Normal file
17
packages/trpc/utils/data-transformer.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { DataTransformer } from '@trpc/server';
|
||||
import SuperJSON from 'superjson';
|
||||
|
||||
export const dataTransformer: DataTransformer = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
serialize: (data: any) => {
|
||||
if (data instanceof FormData) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return SuperJSON.serialize(data);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
deserialize: (data: any) => {
|
||||
return SuperJSON.deserialize(data);
|
||||
},
|
||||
};
|
||||
202
packages/trpc/utils/openapi-fetch-handler.ts
Normal file
202
packages/trpc/utils/openapi-fetch-handler.ts
Normal file
@ -0,0 +1,202 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import type { FetchHandlerOptions } from '@trpc/server/adapters/fetch';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import { type OpenApiRouter, createOpenApiNodeHttpHandler } from 'trpc-to-openapi';
|
||||
|
||||
const CONTENT_TYPE_JSON = 'application/json';
|
||||
const CONTENT_TYPE_URLENCODED = 'application/x-www-form-urlencoded';
|
||||
const CONTENT_TYPE_MULTIPART = 'multipart/form-data';
|
||||
|
||||
const getUrlEncodedBody = async (req: Request) => {
|
||||
const params = new URLSearchParams(await req.text());
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
for (const key of params.keys()) {
|
||||
data[key] = params.getAll(key);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getMultipartBody = async (req: Request) => {
|
||||
const formData = await req.formData();
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
for (const key of formData.keys()) {
|
||||
const values = formData.getAll(key);
|
||||
|
||||
// Return array for multiple values, single value otherwise (matches URL-encoded behavior)
|
||||
data[key] = values.length > 1 ? values : values[0];
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
/**
|
||||
* Parses the request body based on its content type.
|
||||
*
|
||||
* Handles JSON, URL-encoded, and multipart/form-data requests.
|
||||
* For multipart requests, converts FormData to a plain object (similar to URL-encoded)
|
||||
* so it can be validated by tRPC schemas. The content-type header is rewritten
|
||||
* later to prevent downstream parsing issues.
|
||||
*/
|
||||
const getRequestBody = async (req: Request) => {
|
||||
try {
|
||||
const contentType = req.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes(CONTENT_TYPE_JSON)) {
|
||||
return {
|
||||
isValid: true,
|
||||
// Use JSON.parse instead of req.json() because req.json() does not throw on invalid JSON
|
||||
data: JSON.parse(await req.text()),
|
||||
};
|
||||
}
|
||||
|
||||
if (contentType.includes(CONTENT_TYPE_URLENCODED)) {
|
||||
return {
|
||||
isValid: true,
|
||||
data: await getUrlEncodedBody(req),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle multipart/form-data by parsing as FormData and converting to a plain object.
|
||||
// This mirrors how URL-encoded data is structured, allowing tRPC to validate it normally.
|
||||
// The content-type header is rewritten to application/json later via the request proxy
|
||||
// because createOpenApiNodeHttpHandler aborts on any bodied request that isn't application/json.
|
||||
if (contentType.includes(CONTENT_TYPE_MULTIPART)) {
|
||||
return {
|
||||
isValid: true,
|
||||
data: await getMultipartBody(req),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
data: req.body,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isValid: false,
|
||||
cause: err,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a proxy around the original Request that intercepts property access
|
||||
* to transform the request for compatibility with the Node HTTP handler.
|
||||
*
|
||||
* Key transformations:
|
||||
* - Parses and provides the body as a plain object (handles multipart/form-data conversion)
|
||||
* - Rewrites content-type header for multipart requests to application/json
|
||||
* (required because createOpenApiNodeHttpHandler aborts on non-JSON bodied requests)
|
||||
*/
|
||||
const createRequestProxy = async (req: Request, url?: string) => {
|
||||
const body = await getRequestBody(req);
|
||||
|
||||
const originalContentType = req.headers.get('content-type') || '';
|
||||
|
||||
const isMultipart = originalContentType.includes(CONTENT_TYPE_MULTIPART);
|
||||
|
||||
return new Proxy(req, {
|
||||
get: (target, prop) => {
|
||||
switch (prop) {
|
||||
case 'url':
|
||||
return url ?? target.url;
|
||||
|
||||
case 'body': {
|
||||
if (!body.isValid) {
|
||||
throw new TRPCError({
|
||||
code: 'PARSE_ERROR',
|
||||
message: 'Failed to parse request body',
|
||||
cause: body.cause,
|
||||
});
|
||||
}
|
||||
|
||||
return body.data;
|
||||
}
|
||||
|
||||
case 'headers': {
|
||||
const headers = new Headers(target.headers);
|
||||
|
||||
// Rewrite content-type header for multipart requests to application/json.
|
||||
// This is necessary because `createOpenApiNodeHttpHandler` aborts on any bodied
|
||||
// request that isn't application/json. Since we've already parsed the multipart
|
||||
// data into a plain object above, this is safe to do.
|
||||
if (isMultipart) {
|
||||
headers.set('content-type', CONTENT_TYPE_JSON);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
return (target as unknown as Record<string | number | symbol, unknown>)[prop];
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export type CreateOpenApiFetchHandlerOptions<TRouter extends OpenApiRouter> = Omit<
|
||||
FetchHandlerOptions<TRouter>,
|
||||
'batching'
|
||||
> & {
|
||||
req: Request;
|
||||
endpoint: `/${string}`;
|
||||
};
|
||||
|
||||
export const createOpenApiFetchHandler = async <TRouter extends OpenApiRouter>(
|
||||
opts: CreateOpenApiFetchHandlerOptions<TRouter>,
|
||||
): Promise<Response> => {
|
||||
const resHeaders = new Headers();
|
||||
const url = new URL(opts.req.url.replace(opts.endpoint, ''));
|
||||
const req: Request = await createRequestProxy(opts.req, url.toString());
|
||||
|
||||
// @ts-expect-error Inherited from original fetch handler in `trpc-to-openapi`
|
||||
const openApiHttpHandler = createOpenApiNodeHttpHandler(opts);
|
||||
|
||||
return new Promise<Response>((resolve) => {
|
||||
let statusCode: number;
|
||||
|
||||
// Create a mock ServerResponse object that bridges Node HTTP APIs with Fetch API Response.
|
||||
// This allows the Node HTTP handler to work with Fetch API Request objects.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const res = {
|
||||
setHeader: (key: string, value: string | readonly string[]) => {
|
||||
if (typeof value === 'string') {
|
||||
resHeaders.set(key, value);
|
||||
} else {
|
||||
for (const v of value) {
|
||||
resHeaders.append(key, v);
|
||||
}
|
||||
}
|
||||
},
|
||||
get statusCode() {
|
||||
return statusCode;
|
||||
},
|
||||
set statusCode(code: number) {
|
||||
statusCode = code;
|
||||
},
|
||||
end: (body: string) => {
|
||||
resolve(
|
||||
new Response(body, {
|
||||
headers: resHeaders,
|
||||
status: statusCode,
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as ServerResponse;
|
||||
|
||||
// Type assertions are necessary here for interop between Fetch API Request/Response
|
||||
// and Node HTTP IncomingMessage/ServerResponse types.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const nodeReq = req as unknown as Parameters<typeof openApiHttpHandler>[0];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const nodeRes = res as unknown as Parameters<typeof openApiHttpHandler>[1];
|
||||
|
||||
void openApiHttpHandler(nodeReq, nodeRes);
|
||||
});
|
||||
};
|
||||
32
packages/trpc/utils/zod-form-data.ts
Normal file
32
packages/trpc/utils/zod-form-data.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { ZodRawShape } from 'zod';
|
||||
import z from 'zod';
|
||||
|
||||
/**
|
||||
* This helper takes the place of the `z.object` at the root of your schema.
|
||||
* It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData`
|
||||
* and transforms it into a regular object.
|
||||
* If the `FormData` contains multiple entries with the same field name,
|
||||
* it will automatically turn that field into an array.
|
||||
*
|
||||
* This is used instead of `zfd.formData()` because it receives `undefined`
|
||||
* somewhere in the pipeline of our openapi schema generation and throws
|
||||
* an error. This provides the same functionality as `zfd.formData()` but
|
||||
* can be considered somewhat safer.
|
||||
*/
|
||||
export const zodFormData = <T extends ZodRawShape>(schema: T) => {
|
||||
return z.preprocess((data) => {
|
||||
if (data instanceof FormData) {
|
||||
const formData: Record<string, unknown> = {};
|
||||
|
||||
for (const key of data.keys()) {
|
||||
const values = data.getAll(key);
|
||||
|
||||
formData[key] = values.length > 1 ? values : values[0];
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
return data;
|
||||
}, z.object(schema));
|
||||
};
|
||||
Reference in New Issue
Block a user