feat: allow multipart requests for public api

Adds support for multipart/form-data requests in the public api
allowing documents to be uploaded without having to perform a secondary
request.

Need to rollout further endpoints for envelopes and templates.

Need to change how we store files to not use `putFileServerSide`
This commit is contained in:
Lucas Smith
2025-11-02 23:26:43 +11:00
parent 47bdcd833f
commit c85c0cf610
19 changed files with 1154 additions and 252 deletions

View File

@ -1,10 +1,10 @@
import type { Context } from 'hono'; import type { Context } from 'hono';
import { createOpenApiFetchHandler } from 'trpc-to-openapi';
import { API_V2_BETA_URL } from '@documenso/lib/constants/app'; import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error'; import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
import { createTrpcContext } from '@documenso/trpc/server/context'; import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router'; import { appRouter } from '@documenso/trpc/server/router';
import { createOpenApiFetchHandler } from '@documenso/trpc/utils/openapi-fetch-handler';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler'; import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
export const openApiTrpcServerHandler = async (c: Context) => { export const openApiTrpcServerHandler = async (c: Context) => {

820
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -44,7 +44,7 @@
"@commitlint/cli": "^17.7.1", "@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0", "@commitlint/config-conventional": "^17.7.0",
"@lingui/cli": "^5.2.0", "@lingui/cli": "^5.2.0",
"@prisma/client": "^6.8.2", "@prisma/client": "^6.18.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"eslint": "^8.40.0", "eslint": "^8.40.0",
@ -54,11 +54,21 @@
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"playwright": "1.52.0", "playwright": "1.52.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prisma": "^6.8.2", "prisma": "^6.18.0",
"prisma-extension-kysely": "^3.0.0", "prisma-extension-kysely": "^3.0.0",
"prisma-kysely": "^1.8.0", "prisma-kysely": "^1.8.0",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3", "turbo": "^1.9.3",
"@trpc/client": "11.7.0",
"@trpc/react-query": "11.7.0",
"@trpc/server": "11.7.0",
"superjson": "^2.2.5",
"trpc-to-openapi": "2.4.0",
"zod-openapi": "^4.2.4",
"@ts-rest/core": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@ts-rest/serverless": "^3.52.1",
"zod-prisma-types": "3.3.5",
"vite": "^6.3.5" "vite": "^6.3.5"
}, },
"name": "@documenso/root", "name": "@documenso/root",
@ -76,12 +86,12 @@
"mupdf": "^1.0.0", "mupdf": "^1.0.0",
"react": "^18", "react": "^18",
"typescript": "5.6.2", "typescript": "5.6.2",
"zod": "3.24.1" "zod": "^3.25.76"
}, },
"overrides": { "overrides": {
"zod": "3.24.1" "zod": "^3.25.76"
}, },
"trigger.dev": { "trigger.dev": {
"endpointId": "documenso-app" "endpointId": "documenso-app"
} }
} }

View File

@ -17,14 +17,14 @@
"dependencies": { "dependencies": {
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@ts-rest/core": "^3.30.5", "@ts-rest/core": "^3.52.0",
"@ts-rest/open-api": "^3.33.0", "@ts-rest/open-api": "^3.52.0",
"@ts-rest/serverless": "^3.30.5", "@ts-rest/serverless": "^3.52.0",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"superjson": "^1.13.1", "superjson": "^2.2.5",
"swagger-ui-react": "^5.21.0", "swagger-ui-react": "^5.21.0",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76"
} }
} }

View File

@ -20,6 +20,6 @@
"luxon": "^3.5.0", "luxon": "^3.5.0",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76"
} }
} }

View File

@ -19,6 +19,6 @@
"micro": "^10.0.1", "micro": "^10.0.1",
"react": "^18", "react": "^18",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76"
} }
} }

View File

@ -55,11 +55,11 @@
"skia-canvas": "^3.0.8", "skia-canvas": "^3.0.8",
"stripe": "^12.7.0", "stripe": "^12.7.0",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@playwright/browser-chromium": "1.52.0", "@playwright/browser-chromium": "1.52.0",
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4" "@types/pg": "^8.11.4"
} }
} }

View File

@ -21,14 +21,14 @@
"seed": "tsx ./seed-database.ts" "seed": "tsx ./seed-database.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.8.2", "@prisma/client": "^6.18.0",
"kysely": "0.26.3", "kysely": "0.26.3",
"prisma": "^6.8.2", "prisma": "^6.18.0",
"prisma-extension-kysely": "^3.0.0", "prisma-extension-kysely": "^3.0.0",
"prisma-kysely": "^1.8.0", "prisma-kysely": "^1.8.0",
"prisma-json-types-generator": "^3.2.2", "prisma-json-types-generator": "^3.6.2",
"ts-pattern": "^5.0.6", "ts-pattern": "^5.0.6",
"zod-prisma-types": "3.2.4" "zod-prisma-types": "3.3.5"
}, },
"devDependencies": { "devDependencies": {
"dotenv": "^16.5.0", "dotenv": "^16.5.0",

View File

@ -1,17 +1,23 @@
import { createTRPCClient, httpBatchLink, httpLink, splitLink } from '@trpc/client'; import {
import SuperJSON from 'superjson'; createTRPCClient,
httpBatchLink,
httpLink,
isNonJsonSerializable,
splitLink,
} from '@trpc/client';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
import type { AppRouter } from '../server/router'; import type { AppRouter } from '../server/router';
import { dataTransformer } from '../utils/data-transformer';
export const trpc = createTRPCClient<AppRouter>({ export const trpc = createTRPCClient<AppRouter>({
links: [ links: [
splitLink({ splitLink({
condition: (op) => op.context.skipBatch === true, condition: (op) => op.context.skipBatch === true || isNonJsonSerializable(op.input),
true: httpLink({ true: httpLink({
url: `${getBaseUrl()}/api/trpc`, url: `${getBaseUrl()}/api/trpc`,
transformer: SuperJSON, transformer: dataTransformer,
headers: (opts) => { headers: (opts) => {
if (typeof opts.op.context.teamId === 'string') { if (typeof opts.op.context.teamId === 'string') {
return { return {
@ -24,7 +30,7 @@ export const trpc = createTRPCClient<AppRouter>({
}), }),
false: httpBatchLink({ false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`, url: `${getBaseUrl()}/api/trpc`,
transformer: SuperJSON, transformer: dataTransformer,
headers: (opts) => { headers: (opts) => {
const operationWithTeamId = opts.opList.find( const operationWithTeamId = opts.opList.find(
(op) => op.context.teamId && typeof op.context.teamId === 'string', (op) => op.context.teamId && typeof op.context.teamId === 'string',

View File

@ -12,15 +12,21 @@
"dependencies": { "dependencies": {
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@tanstack/react-query": "5.59.15", "@tanstack/react-query": "5.90.5",
"@trpc/client": "11.0.0-rc.648", "@trpc/client": "11.7.0",
"@trpc/react-query": "11.0.0-rc.648", "@trpc/react-query": "11.7.0",
"@trpc/server": "11.0.0-rc.648", "@trpc/server": "11.7.0",
"@ts-rest/core": "^3.30.5", "@ts-rest/core": "^3.52.0",
"formidable": "^3.5.4",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"superjson": "^1.13.1", "superjson": "^2.2.5",
"trpc-to-openapi": "2.0.4", "trpc-to-openapi": "2.4.0",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76",
"zod-form-data": "^2.0.8",
"zod-openapi": "^4.2.4"
},
"devDependencies": {
"@types/formidable": "^3.4.6"
} }
} }

View File

@ -1,13 +1,13 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, httpLink, splitLink } from '@trpc/client'; import { httpBatchLink, httpLink, isNonJsonSerializable, splitLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query'; import { createTRPCReact } from '@trpc/react-query';
import SuperJSON from 'superjson';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
import type { AppRouter } from '../server/router'; import type { AppRouter } from '../server/router';
import { dataTransformer } from '../utils/data-transformer';
export { getQueryKey } from '@trpc/react-query'; export { getQueryKey } from '@trpc/react-query';
@ -44,16 +44,16 @@ export function TrpcProvider({ children, headers }: TrpcProviderProps) {
trpc.createClient({ trpc.createClient({
links: [ links: [
splitLink({ splitLink({
condition: (op) => op.context.skipBatch === true, condition: (op) => op.context.skipBatch === true || isNonJsonSerializable(op.input),
true: httpLink({ true: httpLink({
url: `${getBaseUrl()}/api/trpc`, url: `${getBaseUrl()}/api/trpc`,
headers, headers,
transformer: SuperJSON, transformer: dataTransformer,
}), }),
false: httpBatchLink({ false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`, url: `${getBaseUrl()}/api/trpc`,
headers, headers,
transformer: SuperJSON, transformer: dataTransformer,
}), }),
}), }),
], ],

View File

@ -0,0 +1,136 @@
import { EnvelopeType } from '@prisma/client';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZCreateDocumentFormDataRequestSchema,
ZCreateDocumentFormDataResponseSchema,
createDocumentFormDataMeta,
} from './create-document-formdata.types';
/**
* Temporary endpoint for V2 Beta until we allow passthrough documents on create.
*
* @public
*/
export const createDocumentFormDataRoute = authenticatedProcedure
.meta(createDocumentFormDataMeta)
.input(ZCreateDocumentFormDataRequestSchema)
.output(ZCreateDocumentFormDataResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { payload, file } = input;
const {
title,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
recipients,
meta,
folderId,
attachments,
} = payload;
const { remaining } = await getServerLimits({ userId: user.id, teamId });
if (remaining.documents <= 0) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'You have reached your document limit for this month. Please upgrade your plan.',
statusCode: 400,
});
}
const documentData = await putPdfFileServerSide(file);
const createdEnvelope = await createEnvelope({
userId: ctx.user.id,
teamId,
normalizePdf: false, // Not normalizing because of presigned URL.
internalVersion: 1,
data: {
type: EnvelopeType.DOCUMENT,
title,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
recipients: (recipients || []).map((recipient) => ({
...recipient,
fields: (recipient.fields || []).map((field) => ({
...field,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
documentDataId: documentData.id,
})),
})),
folderId,
envelopeItems: [
{
// If you ever allow more than 1 in this endpoint, make sure to use `maximumEnvelopeItemCount` to limit it.
documentDataId: documentData.id,
},
],
},
attachments,
meta: {
...meta,
emailSettings: meta?.emailSettings ?? undefined,
},
requestMetadata: ctx.metadata,
});
const envelopeItems = await prisma.envelopeItem.findMany({
where: {
envelopeId: createdEnvelope.id,
},
include: {
documentData: true,
},
});
const legacyDocumentId = mapSecondaryIdToDocumentId(createdEnvelope.secondaryId);
const firstDocumentData = envelopeItems[0].documentData;
if (!firstDocumentData) {
throw new Error('Document data not found');
}
return {
document: {
...createdEnvelope,
envelopeId: createdEnvelope.id,
documentDataId: firstDocumentData.id,
documentData: {
...firstDocumentData,
envelopeItemId: envelopeItems[0].id,
},
documentMeta: {
...createdEnvelope.documentMeta,
documentId: legacyDocumentId,
},
id: legacyDocumentId,
fields: createdEnvelope.fields.map((field) => ({
...field,
documentId: legacyDocumentId,
templateId: null,
})),
recipients: createdEnvelope.recipients.map((recipient) => ({
...recipient,
documentId: legacyDocumentId,
templateId: null,
})),
},
folder: createdEnvelope.folder, // Todo: Remove this prior to api-v2 release.
};
});

View File

@ -0,0 +1,97 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { ZDocumentSchema } from '@documenso/lib/types/document';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
import { ZDocumentMetaCreateSchema } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import {
ZFieldHeightSchema,
ZFieldPageNumberSchema,
ZFieldPageXSchema,
ZFieldPageYSchema,
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { zodFormData } from '../../utils/zod-form-data';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
import {
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
} from './schema';
export const createDocumentFormDataMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/document/create/formdata',
contentTypes: ['multipart/form-data'],
summary: 'Create document',
description: 'Create a document using form data.',
tags: ['Document'],
},
};
const ZCreateDocumentFormDataPayloadRequestSchema = z.object({
title: ZDocumentTitleSchema,
externalId: ZDocumentExternalIdSchema.optional(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
formValues: ZDocumentFormValuesSchema.optional(),
folderId: z
.string()
.describe(
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
)
.optional(),
recipients: z
.array(
ZCreateRecipientSchema.extend({
fields: ZFieldAndMetaSchema.and(
z.object({
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
}),
)
.array()
.optional(),
}),
)
.optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
meta: ZDocumentMetaCreateSchema.optional(),
});
// !: Can't use zfd.formData() here because it receives `undefined`
// !: somewhere in the pipeline of our openapi schema generation and throws
// !: an error.
export const ZCreateDocumentFormDataRequestSchema = zodFormData({
payload: zfd.json(ZCreateDocumentFormDataPayloadRequestSchema),
file: zfd.file(),
});
export const ZCreateDocumentFormDataResponseSchema = z.object({
document: ZDocumentSchema,
});
export type TCreateDocumentFormDataRequest = z.infer<typeof ZCreateDocumentFormDataRequestSchema>;
export type TCreateDocumentFormDataResponse = z.infer<typeof ZCreateDocumentFormDataResponseSchema>;

View File

@ -5,6 +5,7 @@ import { deleteAttachmentRoute } from './attachment/delete-attachment';
import { findAttachmentsRoute } from './attachment/find-attachments'; import { findAttachmentsRoute } from './attachment/find-attachments';
import { updateAttachmentRoute } from './attachment/update-attachment'; import { updateAttachmentRoute } from './attachment/update-attachment';
import { createDocumentRoute } from './create-document'; import { createDocumentRoute } from './create-document';
import { createDocumentFormDataRoute } from './create-document-formdata';
import { createDocumentTemporaryRoute } from './create-document-temporary'; import { createDocumentTemporaryRoute } from './create-document-temporary';
import { deleteDocumentRoute } from './delete-document'; import { deleteDocumentRoute } from './delete-document';
import { distributeDocumentRoute } from './distribute-document'; import { distributeDocumentRoute } from './distribute-document';
@ -40,6 +41,7 @@ export const documentRouter = router({
// Temporary v2 beta routes to be removed once V2 is fully released. // Temporary v2 beta routes to be removed once V2 is fully released.
download: downloadDocumentRoute, download: downloadDocumentRoute,
createDocumentTemporary: createDocumentTemporaryRoute, createDocumentTemporary: createDocumentTemporaryRoute,
createDocumentFormData: createDocumentFormDataRoute,
// Internal document routes for custom frontend requests. // Internal document routes for custom frontend requests.
getDocumentByToken: getDocumentByTokenRoute, getDocumentByToken: getDocumentByTokenRoute,

View File

@ -1,5 +1,4 @@
import { TRPCError, initTRPC } from '@trpc/server'; import { TRPCError, initTRPC } from '@trpc/server';
import SuperJSON from 'superjson';
import type { AnyZodObject } from 'zod'; import type { AnyZodObject } from 'zod';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error'; import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
@ -9,6 +8,7 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { alphaid } from '@documenso/lib/universal/id'; import { alphaid } from '@documenso/lib/universal/id';
import { isAdmin } from '@documenso/lib/utils/is-admin'; import { isAdmin } from '@documenso/lib/utils/is-admin';
import { dataTransformer } from '../utils/data-transformer';
import type { TrpcContext } from './context'; import type { TrpcContext } from './context';
// Can't import type from trpc-to-openapi because it breaks build, not sure why. // Can't import type from trpc-to-openapi because it breaks build, not sure why.
@ -35,7 +35,7 @@ const t = initTRPC
.meta<TrpcRouteMeta>() .meta<TrpcRouteMeta>()
.context<TrpcContext>() .context<TrpcContext>()
.create({ .create({
transformer: SuperJSON, transformer: dataTransformer,
errorFormatter(opts) { errorFormatter(opts) {
const { shape, error } = opts; const { shape, error } = opts;

View File

@ -0,0 +1,15 @@
import type { DataTransformer } from '@trpc/server';
import SuperJSON from 'superjson';
export const dataTransformer: DataTransformer = {
serialize: (data: unknown) => {
if (data instanceof FormData) {
return data;
}
return SuperJSON.serialize(data);
},
deserialize: (data: unknown) => {
return SuperJSON.deserialize(data);
},
};

View 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);
});
};

View 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));
};

View File

@ -78,6 +78,6 @@
"tailwind-merge": "^1.12.0", "tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5", "tailwindcss-animate": "^1.0.5",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76"
} }
} }