From 93e7a4cd87522b9ac1d1d2879862b890fb7dae92 Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 27 Dec 2024 18:31:52 +0700 Subject: [PATCH] feat(server)!: fetch handler rewrite and tests with multiple coercers support (#57) * wip * CompositeHandler should not condition * tests * tests * tests * ORPC_PROTOCOL_HEADER -> ORPC_HANDLER_HEADER * wip * openapi matcher * openapi handlers * fixed * coercer * fixed * remove @orpc/transformer * docs & examples * fix types * docs fixed * fix & tests zod coercer * coercers for form actions * fix zod coercer * nuxt playground link to scalar * fix openapi codec on NON GET method * playgrounds prefer workspace packages * improve * applied lint fixes --- .npmrc | 4 +- apps/content/content/docs/index.mdx | 21 +- apps/content/content/docs/server/context.mdx | 28 +- .../content/docs/server/integrations.mdx | 132 +- .../content/docs/server/server-action.mdx | 3 + apps/content/content/home/landing.mdx | 1 + apps/content/examples/contract.ts | 33 +- apps/content/examples/open-api.ts | 4 +- apps/content/examples/server.ts | 24 +- package.json | 2 +- packages/client/package.json | 7 +- .../client/src/procedure-fetch-client.test.ts | 23 +- packages/client/src/procedure-fetch-client.ts | 25 +- packages/client/tests/helpers.ts | 10 +- packages/client/tsconfig.json | 3 +- packages/next/package.json | 1 + packages/next/src/action-form.test.ts | 13 +- packages/next/src/action-form.ts | 23 +- packages/next/tsconfig.json | 1 + packages/openapi/package.json | 10 +- packages/openapi/src/fetch/base-handler.ts | 253 - .../src/fetch}/bracket-notation.test.ts | 8 +- .../src/fetch}/bracket-notation.ts | 0 packages/openapi/src/fetch/index.ts | 12 +- .../openapi/src/fetch/input-builder-full.ts | 14 + .../openapi/src/fetch/input-builder-simple.ts | 21 + .../src/fetch/openapi-handler-server.ts | 10 + .../src/fetch/openapi-handler-serverless.ts | 10 + .../openapi/src/fetch/openapi-handler.test.ts | 223 + packages/openapi/src/fetch/openapi-handler.ts | 135 + .../src/fetch/openapi-payload-codec.test.ts | 111 + .../src/fetch/openapi-payload-codec.ts | 227 + .../fetch/openapi-procedure-matcher.test.ts | 82 + .../src/fetch/openapi-procedure-matcher.ts | 117 + .../openapi/src/fetch/schema-coercer.test.ts | 169 + packages/openapi/src/fetch/schema-coercer.ts | 20 + .../openapi/src/fetch/server-handler.test.ts | 227 - packages/openapi/src/fetch/server-handler.ts | 7 - .../openapi/src/fetch/serverless-handler.ts | 7 - packages/openapi/src/generator.test.ts | 17 + packages/openapi/src/generator.ts | 32 +- packages/openapi/src/utils.ts | 12 +- packages/openapi/tsconfig.json | 6 +- packages/openapi/vitest.setup.ts | 20 - packages/react-query/tests/helpers.tsx | 10 +- packages/react/src/react-utils.ts | 8 +- packages/react/tests/orpc.tsx | 10 +- packages/server/package.json | 6 +- .../src/fetch/composite-handler.test-d.ts | 24 + .../src/fetch/composite-handler.test.ts | 89 + .../server/src/fetch/composite-handler.ts | 23 + .../server/src/fetch/handle-request.test-d.ts | 71 - .../server/src/fetch/handle-request.test.ts | 94 - packages/server/src/fetch/handle-request.ts | 28 - packages/server/src/fetch/index.ts | 4 +- .../server/src/fetch/orpc-handler.test-d.ts | 33 +- .../server/src/fetch/orpc-handler.test.ts | 100 +- packages/server/src/fetch/orpc-handler.ts | 147 +- .../src/fetch/orpc-payload-codec.test.ts | 118 + .../server/src/fetch/orpc-payload-codec.ts | 74 + .../src/fetch/orpc-procedure-matcher.test.ts | 86 + .../src/fetch/orpc-procedure-matcher.ts | 29 + packages/server/src/fetch/super-json.test.ts | 107 + .../orpc => server/src/fetch}/super-json.ts | 11 +- packages/server/src/fetch/types.test-d.ts | 16 + packages/server/src/fetch/types.ts | 41 +- packages/server/src/router-client.ts | 10 +- packages/server/tsconfig.json | 4 +- packages/shared/src/constants.ts | 4 +- packages/transformer/.gitignore | 26 - packages/transformer/README.md | 87 - packages/transformer/package.json | 57 - packages/transformer/src/index.ts | 7 - .../transformer/src/openapi/deserializer.ts | 83 - .../transformer/src/openapi/serializer.ts | 181 - packages/transformer/src/orpc/deserializer.ts | 32 - packages/transformer/src/orpc/serializer.ts | 35 - packages/transformer/src/types.ts | 18 - .../tests/openapi-human-readable.test.ts | 98 - packages/transformer/tests/superjson.bench.ts | 73 - .../transformer/tests/transformer.bench.ts | 195 - .../transformer/tests/types-support.test.ts | 219 - .../transformer/tests/zod-coerce.bench.ts | 101 - packages/transformer/tsconfig.json | 16 - packages/vue-query/tests/helpers.ts | 11 +- packages/zod/package.json | 3 + .../zod-coerce.ts => zod/src/coercer.ts} | 88 +- packages/zod/src/index.ts | 245 +- packages/zod/src/schemas.ts | 243 + packages/zod/tests/coercer.test.ts | 38 + packages/zod/tsconfig.json | 4 +- playgrounds/contract-openapi/package.json | 3 +- playgrounds/contract-openapi/src/main.ts | 28 +- playgrounds/expressjs/package.json | 3 +- playgrounds/expressjs/src/main.ts | 28 +- playgrounds/nextjs/package.json | 3 +- playgrounds/nextjs/src/actions/planet.ts | 3 +- .../nextjs/src/app/api/[...rest]/route.ts | 26 +- playgrounds/nextjs/src/orpc.ts | 1 - playgrounds/nuxt/app.vue | 9 + playgrounds/nuxt/lib/orpc.ts | 2 +- playgrounds/nuxt/package.json | 3 +- playgrounds/nuxt/plugins/vue-query.ts | 4 +- .../nuxt/server/routes/api/[...rest].ts | 29 +- playgrounds/openapi/package.json | 3 +- playgrounds/openapi/src/main.ts | 28 +- pnpm-lock.yaml | 8334 ++++++++++++----- pnpm-workspace.yaml | 1 + tsconfig.json | 1 - vitest.workspace.ts | 7 - 110 files changed, 8739 insertions(+), 4892 deletions(-) delete mode 100644 packages/openapi/src/fetch/base-handler.ts rename packages/{transformer/src => openapi/src/fetch}/bracket-notation.test.ts (98%) rename packages/{transformer/src => openapi/src/fetch}/bracket-notation.ts (100%) create mode 100644 packages/openapi/src/fetch/input-builder-full.ts create mode 100644 packages/openapi/src/fetch/input-builder-simple.ts create mode 100644 packages/openapi/src/fetch/openapi-handler-server.ts create mode 100644 packages/openapi/src/fetch/openapi-handler-serverless.ts create mode 100644 packages/openapi/src/fetch/openapi-handler.test.ts create mode 100644 packages/openapi/src/fetch/openapi-handler.ts create mode 100644 packages/openapi/src/fetch/openapi-payload-codec.test.ts create mode 100644 packages/openapi/src/fetch/openapi-payload-codec.ts create mode 100644 packages/openapi/src/fetch/openapi-procedure-matcher.test.ts create mode 100644 packages/openapi/src/fetch/openapi-procedure-matcher.ts create mode 100644 packages/openapi/src/fetch/schema-coercer.test.ts create mode 100644 packages/openapi/src/fetch/schema-coercer.ts delete mode 100644 packages/openapi/src/fetch/server-handler.test.ts delete mode 100644 packages/openapi/src/fetch/server-handler.ts delete mode 100644 packages/openapi/src/fetch/serverless-handler.ts delete mode 100644 packages/openapi/vitest.setup.ts create mode 100644 packages/server/src/fetch/composite-handler.test-d.ts create mode 100644 packages/server/src/fetch/composite-handler.test.ts create mode 100644 packages/server/src/fetch/composite-handler.ts delete mode 100644 packages/server/src/fetch/handle-request.test-d.ts delete mode 100644 packages/server/src/fetch/handle-request.test.ts delete mode 100644 packages/server/src/fetch/handle-request.ts create mode 100644 packages/server/src/fetch/orpc-payload-codec.test.ts create mode 100644 packages/server/src/fetch/orpc-payload-codec.ts create mode 100644 packages/server/src/fetch/orpc-procedure-matcher.test.ts create mode 100644 packages/server/src/fetch/orpc-procedure-matcher.ts create mode 100644 packages/server/src/fetch/super-json.test.ts rename packages/{transformer/src/orpc => server/src/fetch}/super-json.ts (91%) create mode 100644 packages/server/src/fetch/types.test-d.ts delete mode 100644 packages/transformer/.gitignore delete mode 100644 packages/transformer/README.md delete mode 100644 packages/transformer/package.json delete mode 100644 packages/transformer/src/index.ts delete mode 100644 packages/transformer/src/openapi/deserializer.ts delete mode 100644 packages/transformer/src/openapi/serializer.ts delete mode 100644 packages/transformer/src/orpc/deserializer.ts delete mode 100644 packages/transformer/src/orpc/serializer.ts delete mode 100644 packages/transformer/src/types.ts delete mode 100644 packages/transformer/tests/openapi-human-readable.test.ts delete mode 100644 packages/transformer/tests/superjson.bench.ts delete mode 100644 packages/transformer/tests/transformer.bench.ts delete mode 100644 packages/transformer/tests/types-support.test.ts delete mode 100644 packages/transformer/tests/zod-coerce.bench.ts delete mode 100644 packages/transformer/tsconfig.json rename packages/{transformer/src/openapi/zod-coerce.ts => zod/src/coercer.ts} (82%) create mode 100644 packages/zod/src/schemas.ts create mode 100644 packages/zod/tests/coercer.test.ts diff --git a/.npmrc b/.npmrc index 746dbc70..bd11a2d9 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,5 @@ use-node-version = 22.11.0 auto-install-peers = true -shamefully-hoist = true \ No newline at end of file +shamefully-hoist = true +link-workspace-packages = true +prefer-workspace-packages = true \ No newline at end of file diff --git a/apps/content/content/docs/index.mdx b/apps/content/content/docs/index.mdx index 30a136e2..b72884f0 100644 --- a/apps/content/content/docs/index.mdx +++ b/apps/content/content/docs/index.mdx @@ -144,26 +144,29 @@ In oRPC middleware is very useful and fully typed you can find more info [here]( This example uses [@whatwg-node/server](https://www.npmjs.com/package/@whatwg-node/server) to create a Node server with [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). ```ts twoslash -import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' -import { createOpenAPIServerlessHandler } from '@orpc/openapi/fetch' +import { ORPCHandler, CompositeHandler } from '@orpc/server/fetch' +import { OpenAPIServerlessHandler } from '@orpc/openapi/fetch' import { createServer } from 'node:http' import { createServerAdapter } from '@whatwg-node/server' import { router } from 'examples/server' +import { ZodCoercer } from '@orpc/zod' + +const openapiHandler = new OpenAPIServerlessHandler(router, { + schemaCoercers: [ + new ZodCoercer(), + ], +}) +const orpcHandler = new ORPCHandler(router) +const compositeHandler = new CompositeHandler([openapiHandler, orpcHandler]) const server = createServer( createServerAdapter((request: Request) => { const url = new URL(request.url) if (url.pathname.startsWith('/api')) { - return handleFetchRequest({ - router, - request, + return compositeHandler.fetch(request, { prefix: '/api', context: {}, - handlers: [ - createORPCHandler(), - createOpenAPIServerlessHandler(), - ], }) } diff --git a/apps/content/content/docs/server/context.mdx b/apps/content/content/docs/server/context.mdx index 13c9c4f6..0370bcb7 100644 --- a/apps/content/content/docs/server/context.mdx +++ b/apps/content/content/docs/server/context.mdx @@ -82,19 +82,14 @@ export const router = base.router({ // You can call this procedure directly without manually providing context const output = await router.getting() -import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' -import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' +import { ORPCHandler } from '@orpc/server/fetch' +import { OpenAPIServerlessHandler, OpenAPIServerHandler } from '@orpc/openapi/fetch' + +const orpcHandler = new ORPCHandler(router) export function fetch(request: Request) { // No need to pass context; middleware handles it - return handleFetchRequest({ - router, - request, - handlers: [ - createORPCHandler(), - createOpenAPIServerlessHandler() - ], - }) + return orpcHandler.fetch(request) } ``` @@ -106,8 +101,8 @@ rather than relying on global mechanisms like `headers` or `cookies` in Next.js. ```ts twoslash import { os, ORPCError, createProcedureClient } from '@orpc/server' -import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' -import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' +import { ORPCHandler } from '@orpc/server/fetch' +import { OpenAPIServerlessHandler, OpenAPIServerHandler } from '@orpc/openapi/fetch' type ORPCContext = { user?: { id: string }, db: 'fake-db' } @@ -133,17 +128,14 @@ export const router = base.router({ }), }) +const orpcHandler = new ORPCHandler(router) + export function fetch(request: Request) { // Initialize context explicitly for each request const db = 'fake-db' as const const user = request.headers.get('Authorization') ? { id: 'example' } : undefined - return handleFetchRequest({ - router, - request, - context: { db, user }, - handlers: [createORPCHandler(), createOpenAPIServerlessHandler()], - }) + return orpcHandler.fetch(request, { context: { db, user } }) } // If you want to call this procedure or use as server action diff --git a/apps/content/content/docs/server/integrations.mdx b/apps/content/content/docs/server/integrations.mdx index f27e7110..94677632 100644 --- a/apps/content/content/docs/server/integrations.mdx +++ b/apps/content/content/docs/server/integrations.mdx @@ -13,20 +13,22 @@ Whether you're targeting serverless, edge environments, or traditional backends, ## Quick Example ```ts twoslash -import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' -import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' +import { ORPCHandler, CompositeHandler } from '@orpc/server/fetch' +import { OpenAPIServerlessHandler } from '@orpc/openapi/fetch' import { router } from 'examples/server' +import { ZodCoercer } from '@orpc/zod' + +const openapiHandler = new OpenAPIServerlessHandler(router, { + schemaCoercers: [ + new ZodCoercer(), + ], +}) +const orpcHandler = new ORPCHandler(router) +const compositeHandler = new CompositeHandler([openapiHandler, orpcHandler]) export function fetch(request: Request) { - return handleFetchRequest({ - router, - request, + return compositeHandler.fetch(request, { context: {}, - // prefix: '/api', // Optionally define a route prefix - handlers: [ - createORPCHandler(), - createOpenAPIServerlessHandler(), - ], }) } ``` @@ -37,23 +39,25 @@ Node.js doesn't provide native support for creating server with [Fetch API](http but you can easily use [@whatwg-node/server](https://npmjs.com/package/@whatwg-node/server) as an adapter. ```ts twoslash -import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' -import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' import { createServer } from 'node:http' import { createServerAdapter } from '@whatwg-node/server' +import { ORPCHandler, CompositeHandler } from '@orpc/server/fetch' +import { OpenAPIServerlessHandler } from '@orpc/openapi/fetch' import { router } from 'examples/server' +import { ZodCoercer } from '@orpc/zod' + +const openapiHandler = new OpenAPIServerlessHandler(router, { + schemaCoercers: [ + new ZodCoercer(), + ], +}) +const orpcHandler = new ORPCHandler(router) +const compositeHandler = new CompositeHandler([openapiHandler, orpcHandler]) const server = createServer( createServerAdapter((request: Request) => { - return handleFetchRequest({ - router, - request, + return compositeHandler.fetch(request, { context: {}, - // prefix: '/api', - handlers: [ - createORPCHandler(), - createOpenAPIServerlessHandler(), - ], }) }) ) @@ -66,24 +70,27 @@ server.listen(3000, () => { ## Express.js ```ts twoslash -import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' -import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' -import { createServerAdapter } from '@whatwg-node/server' import express from 'express' +import { createServerAdapter } from '@whatwg-node/server' +import { ORPCHandler, CompositeHandler } from '@orpc/server/fetch' +import { OpenAPIServerlessHandler } from '@orpc/openapi/fetch' import { router } from 'examples/server' +import { ZodCoercer } from '@orpc/zod' + +const openapiHandler = new OpenAPIServerlessHandler(router, { + schemaCoercers: [ + new ZodCoercer(), + ], +}) +const orpcHandler = new ORPCHandler(router) +const compositeHandler = new CompositeHandler([openapiHandler, orpcHandler]) const app = express() app.all('/api/*', createServerAdapter((request: Request) => { - return handleFetchRequest({ - router, - request, + return compositeHandler.fetch(request, { context: {}, prefix: '/api', - handlers: [ - createORPCHandler(), - createOpenAPIServerHandler(), - ], }) })) @@ -96,23 +103,26 @@ app.listen(3000, () => { ```ts twoslash import { Hono } from 'hono' -import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' -import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' +import { ORPCHandler, CompositeHandler } from '@orpc/server/fetch' +import { OpenAPIServerlessHandler } from '@orpc/openapi/fetch' import { router } from 'examples/server' +import { ZodCoercer } from '@orpc/zod' + +const openapiHandler = new OpenAPIServerlessHandler(router, { + schemaCoercers: [ + new ZodCoercer(), + ], +}) +const orpcHandler = new ORPCHandler(router) +const compositeHandler = new CompositeHandler([openapiHandler, orpcHandler]) const app = new Hono() app.get('/api/*', (c) => { - return handleFetchRequest({ - router, - request: c.req.raw, + return compositeHandler.fetch(c.req.raw, { prefix: '/api', context: {}, - handlers: [ - createORPCHandler(), - createOpenAPIServerlessHandler(), - ], - }) + }) }) export default app @@ -121,20 +131,23 @@ export default app ## Next.js ```ts title="app/api/[...orpc]/route.ts" twoslash -import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' -import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' +import { ORPCHandler, CompositeHandler } from '@orpc/server/fetch' +import { OpenAPIServerlessHandler } from '@orpc/openapi/fetch' import { router } from 'examples/server' +import { ZodCoercer } from '@orpc/zod' + +const openapiHandler = new OpenAPIServerlessHandler(router, { + schemaCoercers: [ + new ZodCoercer(), + ], +}) +const orpcHandler = new ORPCHandler(router) +const compositeHandler = new CompositeHandler([openapiHandler, orpcHandler]) export function GET(request: Request) { - return handleFetchRequest({ - router, - request, + return compositeHandler.fetch(request, { prefix: '/api', context: {}, - handlers: [ - createORPCHandler(), - createOpenAPIServerlessHandler(), - ], }) } @@ -147,21 +160,24 @@ export const PATCH = GET ## Cloudflare Workers ```ts twoslash -import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' -import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' +import { ORPCHandler, CompositeHandler } from '@orpc/server/fetch' +import { OpenAPIServerlessHandler } from '@orpc/openapi/fetch' import { router } from 'examples/server' +import { ZodCoercer } from '@orpc/zod' + +const openapiHandler = new OpenAPIServerlessHandler(router, { + schemaCoercers: [ + new ZodCoercer(), + ], +}) +const orpcHandler = new ORPCHandler(router) +const compositeHandler = new CompositeHandler([openapiHandler, orpcHandler]) export default { async fetch(request: Request) { - return handleFetchRequest({ - router, - request, + return compositeHandler.fetch(request, { prefix: '/', context: {}, - handlers: [ - createORPCHandler(), - createOpenAPIServerlessHandler(), - ], }) }, } diff --git a/apps/content/content/docs/server/server-action.mdx b/apps/content/content/docs/server/server-action.mdx index 2cf987fd..9817e193 100644 --- a/apps/content/content/docs/server/server-action.mdx +++ b/apps/content/content/docs/server/server-action.mdx @@ -137,8 +137,11 @@ React forms integrate seamlessly with server actions using `createFormAction`. T // on server import { createFormAction } from '@orpc/next' import { updateUser } from 'examples/server-action' +import { ZodCoercer } from '@orpc/zod' + const updateUserFA = createFormAction({ procedure: updateUser, + schemaCoercers: [new ZodCoercer()], onSuccess: () => { // redirect('/some-where') } diff --git a/apps/content/content/home/landing.mdx b/apps/content/content/home/landing.mdx index 098d4bfa..8eadffc3 100644 --- a/apps/content/content/home/landing.mdx +++ b/apps/content/content/home/landing.mdx @@ -108,6 +108,7 @@ const { execute, isPending, isError, error, output, input, status } = useAction( // use as a form action const gettingFA = createFormAction({ procedure: getting, + schemaCoercers: [new ZodCoercer()], onSuccess: () => redirect('/some-where') }) diff --git a/apps/content/examples/contract.ts b/apps/content/examples/contract.ts index ccd0afd9..81bf55fb 100644 --- a/apps/content/examples/contract.ts +++ b/apps/content/examples/contract.ts @@ -1,11 +1,8 @@ import type { InferContractRouterInputs, InferContractRouterOutputs } from '@orpc/contract' import { oc } from '@orpc/contract' -import { oz } from '@orpc/zod' -import { z } from 'zod' - -// Implement the contract - import { ORPCError, os } from '@orpc/server' +import { oz, ZodCoercer } from '@orpc/zod' +import { z } from 'zod' // Define your contract first // This contract can replace server router in most-case @@ -63,6 +60,8 @@ export const contract = oc.router({ export type Inputs = InferContractRouterInputs export type Outputs = InferContractRouterOutputs +// Implement the contract + export type Context = { user?: { id: string } } export const base = os.context() export const pub /** os with ... */ = base.contract(contract) // Ensure every implement must be match contract @@ -114,27 +113,29 @@ export const router = pub.router({ }, }) -// Expose apis to the internet with fetch handler -import { createOpenAPIServerlessHandler } from '@orpc/openapi/fetch' -import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' // Modern runtime that support fetch api like deno, bun, cloudflare workers, even node can used import { createServer } from 'node:http' +// Expose apis to the internet with fetch handler +import { OpenAPIServerlessHandler } from '@orpc/openapi/fetch' +import { CompositeHandler, ORPCHandler } from '@orpc/server/fetch' import { createServerAdapter } from '@whatwg-node/server' +const openapiHandler = new OpenAPIServerlessHandler(router, { + schemaCoercers: [ + new ZodCoercer(), + ], +}) +const orpcHandler = new ORPCHandler(router) +const compositeHandler = new CompositeHandler([openapiHandler, orpcHandler]) + const server = createServer( createServerAdapter((request: Request) => { const url = new URL(request.url) if (url.pathname.startsWith('/api')) { - return handleFetchRequest({ - router, - request, - prefix: '/api', + return compositeHandler.fetch(request, { context: {}, - handlers: [ - createORPCHandler(), - createOpenAPIServerlessHandler(), - ], + prefix: '/api', }) } diff --git a/apps/content/examples/open-api.ts b/apps/content/examples/open-api.ts index 2366a23d..30e551c4 100644 --- a/apps/content/examples/open-api.ts +++ b/apps/content/examples/open-api.ts @@ -1,8 +1,6 @@ import { generateOpenAPI } from '@orpc/openapi' -import { router } from 'examples/server' - -// or generate from contract import { contract } from 'examples/contract' +import { router } from 'examples/server' export const specFromServerRouter = generateOpenAPI({ router, diff --git a/apps/content/examples/server.ts b/apps/content/examples/server.ts index 33094fe8..ad32f9a9 100644 --- a/apps/content/examples/server.ts +++ b/apps/content/examples/server.ts @@ -1,6 +1,6 @@ import type { InferRouterInputs, InferRouterOutputs } from '@orpc/server' import { ORPCError, os } from '@orpc/server' -import { oz } from '@orpc/zod' +import { oz, ZodCoercer } from '@orpc/zod' import { z } from 'zod' export type Context = { user?: { id: string } } | undefined @@ -88,27 +88,29 @@ export const router = pub.router({ export type Inputs = InferRouterInputs export type Outputs = InferRouterOutputs -// Expose apis to the internet with fetch handler -import { createOpenAPIServerlessHandler } from '@orpc/openapi/fetch' -import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' // Modern runtime that support fetch api like deno, bun, cloudflare workers, even node can used import { createServer } from 'node:http' +// Expose apis to the internet with fetch handler +import { OpenAPIServerlessHandler } from '@orpc/openapi/fetch' +import { CompositeHandler, ORPCHandler } from '@orpc/server/fetch' import { createServerAdapter } from '@whatwg-node/server' +const openapiHandler = new OpenAPIServerlessHandler(router, { + schemaCoercers: [ + new ZodCoercer(), + ], +}) +const orpcHandler = new ORPCHandler(router) +const compositeHandler = new CompositeHandler([openapiHandler, orpcHandler]) + const server = createServer( createServerAdapter((request: Request) => { const url = new URL(request.url) if (url.pathname.startsWith('/api')) { - return handleFetchRequest({ - router, - request, + return compositeHandler.fetch(request, { prefix: '/api', context: {}, - handlers: [ - createORPCHandler(), - createOpenAPIServerlessHandler(), - ], }) } diff --git a/package.json b/package.json index 7182f856..86cb41e9 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "simple-git-hooks": "^2.11.1", "tsup": "^8.3.0", "typescript": "5.7.2", - "vitest": "^2.1.3" + "vitest": "^2.1.8" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" diff --git a/packages/client/package.json b/packages/client/package.json index 34ae5cd9..58519879 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -42,12 +42,11 @@ "type:check": "tsc -b" }, "peerDependencies": { - "@orpc/contract": "workspace:*", - "@orpc/server": "workspace:*" + "@orpc/contract": "workspace:*" }, "dependencies": { - "@orpc/shared": "workspace:*", - "@orpc/transformer": "workspace:*" + "@orpc/server": "workspace:*", + "@orpc/shared": "workspace:*" }, "devDependencies": { "@orpc/openapi": "workspace:*", diff --git a/packages/client/src/procedure-fetch-client.test.ts b/packages/client/src/procedure-fetch-client.test.ts index 46e32fe1..6d7a712e 100644 --- a/packages/client/src/procedure-fetch-client.test.ts +++ b/packages/client/src/procedure-fetch-client.test.ts @@ -1,9 +1,8 @@ -import { ORPCDeserializer, ORPCSerializer } from '@orpc/transformer' +import { ORPCPayloadCodec } from '@orpc/server/fetch' import { createProcedureFetchClient } from './procedure-fetch-client' -vi.mock('@orpc/transformer', () => ({ - ORPCSerializer: vi.fn().mockReturnValue({ serialize: vi.fn() }), - ORPCDeserializer: vi.fn().mockReturnValue({ deserialize: vi.fn() }), +vi.mock('@orpc/server/fetch', () => ({ + ORPCPayloadCodec: vi.fn().mockReturnValue({ encode: vi.fn(), decode: vi.fn() }), })) beforeEach(() => { @@ -11,15 +10,15 @@ beforeEach(() => { }) describe('procedure fetch client', () => { - const serialize = (ORPCSerializer as any)().serialize - const deserialize = (ORPCDeserializer as any)().deserialize + const encode = (ORPCPayloadCodec as any)().encode + const decode = (ORPCPayloadCodec as any)().decode const response = new Response('output') const headers = new Headers({ 'Content-Type': 'application/json' }) const fakeFetch = vi.fn() fakeFetch.mockReturnValue(response) - serialize.mockReturnValue({ body: 'transformed_input', headers }) - deserialize.mockReturnValue('transformed_output') + encode.mockReturnValue({ body: 'transformed_input', headers }) + decode.mockReturnValue('transformed_output') it('works', async () => { const client = createProcedureFetchClient({ @@ -32,8 +31,8 @@ describe('procedure fetch client', () => { expect(output).toBe('transformed_output') - expect(serialize).toBeCalledTimes(1) - expect(serialize).toBeCalledWith('input') + expect(encode).toBeCalledTimes(1) + expect(encode).toBeCalledWith('input') expect(fakeFetch).toBeCalledTimes(1) expect(fakeFetch).toBeCalledWith('http://localhost:3000/orpc/ping', { @@ -42,8 +41,8 @@ describe('procedure fetch client', () => { headers: expect.any(Headers), }) - expect(deserialize).toBeCalledTimes(1) - expect(deserialize).toBeCalledWith(response) + expect(decode).toBeCalledTimes(1) + expect(decode).toBeCalledWith(response) }) it.each([ diff --git a/packages/client/src/procedure-fetch-client.ts b/packages/client/src/procedure-fetch-client.ts index 073b386b..4eaddc8a 100644 --- a/packages/client/src/procedure-fetch-client.ts +++ b/packages/client/src/procedure-fetch-client.ts @@ -1,8 +1,8 @@ import type { ProcedureClient } from '@orpc/server' import type { Promisable } from '@orpc/shared' -import { ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_VALUE, trim } from '@orpc/shared' +import { ORPCPayloadCodec } from '@orpc/server/fetch' +import { ORPC_HANDLER_HEADER, ORPC_HANDLER_VALUE, trim } from '@orpc/shared' import { ORPCError } from '@orpc/shared/error' -import { ORPCDeserializer, ORPCSerializer } from '@orpc/transformer' export interface CreateProcedureClientOptions { /** @@ -28,8 +28,7 @@ export interface CreateProcedureClientOptions { path: string[] } -const serializer = new ORPCSerializer() -const deserializer = new ORPCDeserializer() +const payloadCodec = new ORPCPayloadCodec() export function createProcedureFetchClient( options: CreateProcedureClientOptions, @@ -38,9 +37,11 @@ export function createProcedureFetchClient( const fetchClient = options.fetch ?? fetch const url = `${trim(options.baseURL, '/')}/${options.path.map(encodeURIComponent).join('/')}` - const headers = new Headers({ - [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, - }) + const encoded = payloadCodec.encode(input) + + const headers = new Headers(encoded.headers) + + headers.append(ORPC_HANDLER_HEADER, ORPC_HANDLER_VALUE) let customHeaders = await options.headers?.(input) customHeaders = customHeaders instanceof Headers ? customHeaders : new Headers(customHeaders) @@ -48,22 +49,16 @@ export function createProcedureFetchClient( headers.append(key, value) } - const serialized = serializer.serialize(input) - - for (const [key, value] of serialized.headers.entries()) { - headers.append(key, value) - } - const response = await fetchClient(url, { method: 'POST', headers, - body: serialized.body, + body: encoded.body, signal: callerOptions?.signal, }) const json = await (async () => { try { - return await deserializer.deserialize(response) + return await payloadCodec.decode(response) } catch (e) { throw new ORPCError({ diff --git a/packages/client/tests/helpers.ts b/packages/client/tests/helpers.ts index 0759ca4e..44dc6396 100644 --- a/packages/client/tests/helpers.ts +++ b/packages/client/tests/helpers.ts @@ -1,5 +1,5 @@ import { os } from '@orpc/server' -import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' +import { ORPCHandler } from '@orpc/server/fetch' import { z } from 'zod' import { createORPCFetchClient } from '../src' @@ -96,6 +96,8 @@ export const appRouter = orpcServer.router({ }, }) +const orpcHandler = new ORPCHandler(appRouter) + export const client = createORPCFetchClient({ baseURL: 'http://localhost:3000', @@ -103,10 +105,6 @@ export const client = createORPCFetchClient({ await new Promise(resolve => setTimeout(resolve, 100)) const request = new Request(...args) - return handleFetchRequest({ - router: appRouter, - request, - handlers: [createORPCHandler()], - }) + return orpcHandler.fetch(request) }, }) diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index ed55cc03..ce74d7ff 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -8,8 +8,7 @@ { "path": "../contract" }, { "path": "../openapi" }, { "path": "../server" }, - { "path": "../shared" }, - { "path": "../transformer" } + { "path": "../shared" } ], "include": ["src"], "exclude": [ diff --git a/packages/next/package.json b/packages/next/package.json index 775b5e00..55cda0b2 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -55,6 +55,7 @@ }, "dependencies": { "@orpc/contract": "workspace:*", + "@orpc/openapi": "workspace:*", "@orpc/shared": "workspace:*" }, "devDependencies": { diff --git a/packages/next/src/action-form.test.ts b/packages/next/src/action-form.test.ts index 2c3ad467..20e79e28 100644 --- a/packages/next/src/action-form.test.ts +++ b/packages/next/src/action-form.test.ts @@ -1,4 +1,5 @@ import { os } from '@orpc/server' +import { ZodCoercer } from '@orpc/zod' import { z } from 'zod' import { createFormAction } from './action-form' @@ -9,7 +10,14 @@ describe('createFormAction', () => { it('should accept form data and auto-convert types', async () => { const onSuccess = vi.fn() - const formAction = createFormAction({ procedure, path: ['name'], onSuccess }) + const formAction = createFormAction({ + procedure, + path: ['name'], + onSuccess, + schemaCoercers: [ + new ZodCoercer(), + ], + }) const form = new FormData() form.append('big', '19992') @@ -39,6 +47,9 @@ describe('createFormAction', () => { path: ['name'], onSuccess, onError, + schemaCoercers: [ + new ZodCoercer(), + ], }) const form = new FormData() diff --git a/packages/next/src/action-form.ts b/packages/next/src/action-form.ts index 401892ec..1e526786 100644 --- a/packages/next/src/action-form.ts +++ b/packages/next/src/action-form.ts @@ -1,32 +1,35 @@ import type { Schema, SchemaInput } from '@orpc/contract' import type { Context, CreateProcedureClientOptions } from '@orpc/server' +import { CompositeSchemaCoercer, OpenAPIPayloadCodec, type PublicOpenAPIPayloadCodec, type SchemaCoercer } from '@orpc/openapi/fetch' import { createProcedureClient, ORPCError, unlazy } from '@orpc/server' -import { OpenAPIDeserializer } from '@orpc/transformer' import { forbidden, notFound, unauthorized } from 'next/navigation' export type FormAction = (input: FormData) => Promise +export type CreateFormActionOptions = { + schemaCoercers?: SchemaCoercer[] + payloadCodec?: PublicOpenAPIPayloadCodec +} + export function createFormAction< TContext extends Context, TInputSchema extends Schema, TOutputSchema extends Schema, TFuncOutput extends SchemaInput, ->(opt: CreateProcedureClientOptions): FormAction { +>(opt: CreateProcedureClientOptions & CreateFormActionOptions): FormAction { const caller = createProcedureClient(opt) const formAction = async (input: FormData): Promise => { try { - const { default: procedure } = await unlazy(opt.procedure) + const codec = opt.payloadCodec ?? new OpenAPIPayloadCodec() + const coercer = new CompositeSchemaCoercer(opt.schemaCoercers ?? []) - const inputSchema = procedure['~orpc'].contract['~orpc'].InputSchema - - const deserializer = new OpenAPIDeserializer({ - schema: inputSchema?.['~standard'].vendor === 'zod' ? inputSchema as any : undefined, - }) + const { default: procedure } = await unlazy(opt.procedure) - const deserializedInput = deserializer.deserializeAsFormData(input) + const decodedInput = await codec.decode(input) + const coercedInput = coercer.coerce(procedure['~orpc'].contract['~orpc'].InputSchema, decodedInput) - await caller(deserializedInput as any) + await caller(coercedInput as any) } catch (e) { if (e instanceof ORPCError) { diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json index aaefde20..c75db1de 100644 --- a/packages/next/tsconfig.json +++ b/packages/next/tsconfig.json @@ -5,6 +5,7 @@ }, "references": [ { "path": "../contract" }, + { "path": "../openapi" }, { "path": "../server" }, { "path": "../shared" } ], diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 92ac0955..c25e7a99 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -51,16 +51,18 @@ "@orpc/contract": "workspace:*", "@orpc/server": "workspace:*", "@orpc/shared": "workspace:*", - "@orpc/transformer": "workspace:*", - "@orpc/zod": "workspace:*", "@standard-schema/spec": "1.0.0-beta.4", + "@types/content-disposition": "^0.5.8", + "content-disposition": "^0.5.4", "escape-string-regexp": "^5.0.0", + "fast-content-type-parse": "^2.0.0", + "hono": "^4.6.12", "json-schema-typed": "^8.0.1", - "openapi3-ts": "^4.4.0" + "openapi3-ts": "^4.4.0", + "wildcard-match": "^5.1.3" }, "devDependencies": { "@readme/openapi-parser": "^2.6.0", - "hono": "^4.6.12", "zod": "^3.24.1" } } diff --git a/packages/openapi/src/fetch/base-handler.ts b/packages/openapi/src/fetch/base-handler.ts deleted file mode 100644 index 1792d048..00000000 --- a/packages/openapi/src/fetch/base-handler.ts +++ /dev/null @@ -1,253 +0,0 @@ -/// - -import type { HTTPPath } from '@orpc/contract' -import type { ANY_PROCEDURE, ANY_ROUTER } from '@orpc/server' -import type { FetchHandler } from '@orpc/server/fetch' -import type { Router as HonoRouter } from 'hono/router' -import type { EachContractLeafResultItem, EachLeafOptions } from '../utils' -import { createProcedureClient, getLazyRouterPrefix, getRouterChild, isProcedure, LAZY_LOADER_SYMBOL, ORPCError, unlazy } from '@orpc/server' -import { executeWithHooks, isPlainObject, mapValues, ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_VALUE, trim, value } from '@orpc/shared' -import { OpenAPIDeserializer, OpenAPISerializer, zodCoerce } from '@orpc/transformer' -import { eachContractProcedureLeaf, standardizeHTTPPath } from '../utils' - -export type ResolveRouter = (router: ANY_ROUTER, method: string, pathname: string) => Promise<{ - path: string[] - procedure: ANY_PROCEDURE - params: Record -} | undefined> - -type Routing = HonoRouter - -export function createOpenAPIHandler(createHonoRouter: () => Routing): FetchHandler { - const resolveRouter = createResolveRouter(createHonoRouter) - - return async (options) => { - if (options.request.headers.get(ORPC_PROTOCOL_HEADER)?.includes(ORPC_PROTOCOL_VALUE)) { - return undefined - } - - const context = await value(options.context) - const accept = options.request.headers.get('Accept') || undefined - const serializer = new OpenAPISerializer({ accept }) - - const handler = async () => { - const url = new URL(options.request.url) - const pathname = `/${trim(url.pathname.replace(options.prefix ?? '', ''), '/')}` - const customMethod - = options.request.method === 'POST' - ? url.searchParams.get('method')?.toUpperCase() - : undefined - const method = customMethod || options.request.method - - const match = await resolveRouter(options.router, method, pathname) - - if (!match) { - throw new ORPCError({ code: 'NOT_FOUND', message: 'Not found' }) - } - - const { path, procedure } = match - - const params = procedure['~orpc'].contract['~orpc'].InputSchema - ? zodCoerce( - procedure['~orpc'].contract['~orpc'].InputSchema, - match.params, - { bracketNotation: true }, - ) as Record - : match.params - - const input = await deserializeInput(options.request, procedure) - const mergedInput = mergeParamsAndInput(params, input) - - const caller = createProcedureClient({ - context, - procedure, - path, - }) - - const output = await caller(mergedInput, { signal: options.signal }) - - const { body, headers } = serializer.serialize(output) - - return new Response(body, { - status: 200, - headers, - }) - } - - try { - return await executeWithHooks({ - context: context as any, - hooks: options, - execute: handler, - input: options.request, - meta: { - signal: options.signal, - }, - }) - } - catch (e) { - const error = toORPCError(e) - - try { - const { body, headers } = serializer.serialize(error.toJSON()) - - return new Response(body, { - status: error.status, - headers, - }) - } - catch (e) { - const error = toORPCError(e) - - // fallback to OpenAPI serializer (without accept) when expected serializer has failed - const { body, headers } = new OpenAPISerializer().serialize( - error.toJSON(), - ) - - return new Response(body, { - status: error.status, - headers, - }) - } - } - } -} - -const routingCache = new Map() -const pendingCache = new Map () - -export function createResolveRouter(createHonoRouter: () => Routing): ResolveRouter { - const addRoutes = (routing: Routing, pending: { ref: EachContractLeafResultItem[] }, options: EachLeafOptions) => { - const lazies = eachContractProcedureLeaf(options, ({ path, contract }) => { - const method = contract['~orpc'].route?.method ?? 'POST' - const httpPath = contract['~orpc'].route?.path - ? openAPIPathToRouterPath(contract['~orpc'].route?.path) - : `/${path.map(encodeURIComponent).join('/')}` - - routing.add(method, httpPath, path) - }) - - pending.ref.push(...lazies) - } - - return async (router: ANY_ROUTER, method: string, pathname: string) => { - const pending = (() => { - let pending = pendingCache.get(router) - if (!pending) { - pending = { ref: [] } - pendingCache.set(router, pending) - } - - return pending - })() - - const routing = (() => { - let routing = routingCache.get(router) - - if (!routing) { - routing = createHonoRouter() - routingCache.set(router, routing) - addRoutes(routing, pending, { router, path: [] }) - } - - return routing - })() - - const newPending = [] - - for (const item of pending.ref) { - const lazyPrefix = getLazyRouterPrefix(item.lazy) - - if ( - lazyPrefix - && !pathname.startsWith(lazyPrefix) - && !pathname.startsWith(`/${item.path.map(encodeURIComponent).join('/')}`) - ) { - newPending.push(item) - continue - } - - const router = (await item.lazy[LAZY_LOADER_SYMBOL]()).default - - addRoutes(routing, pending, { path: item.path, router }) - } - - pending.ref = newPending - - const [matches, params_] = routing.match(method, pathname) - - const [match] = matches.sort((a, b) => { - return Object.keys(a[1]).length - Object.keys(b[1]).length - }) - - if (!match) { - return undefined - } - - const path = match[0] - const params = params_ - ? mapValues( - (match as any)[1]!, - v => params_[v as number]!, - ) - : match[1] as Record - - const { default: maybeProcedure } = await unlazy(getRouterChild(router, ...path)) - - if (!isProcedure(maybeProcedure)) { - return undefined - } - - return { - path, - procedure: maybeProcedure, - params: { ...params }, // params from hono not a normal object, so we need spread here - } - } -} - -function mergeParamsAndInput(coercedParams: Record, input: unknown) { - if (Object.keys(coercedParams).length === 0) { - return input - } - - if (!isPlainObject(input)) { - return coercedParams - } - - return { - ...coercedParams, - ...input, - } -} - -async function deserializeInput(request: Request, procedure: ANY_PROCEDURE): Promise { - const deserializer = new OpenAPIDeserializer({ - schema: procedure['~orpc'].contract['~orpc'].InputSchema, - }) - - try { - return await deserializer.deserialize(request) - } - catch (e) { - throw new ORPCError({ - code: 'BAD_REQUEST', - message: 'Cannot parse request. Please check the request body and Content-Type header.', - cause: e, - }) - } -} - -function toORPCError(e: unknown): ORPCError { - return e instanceof ORPCError - ? e - : new ORPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Internal server error', - cause: e, - }) -} - -function openAPIPathToRouterPath(path: HTTPPath): string { - return standardizeHTTPPath(path).replace(/\{([^}]+)\}/g, ':$1') -} diff --git a/packages/transformer/src/bracket-notation.test.ts b/packages/openapi/src/fetch/bracket-notation.test.ts similarity index 98% rename from packages/transformer/src/bracket-notation.test.ts rename to packages/openapi/src/fetch/bracket-notation.test.ts index 09057cbc..18d3c7ba 100644 --- a/packages/transformer/src/bracket-notation.test.ts +++ b/packages/openapi/src/fetch/bracket-notation.test.ts @@ -1,10 +1,4 @@ -import { describe, expect, it } from 'vitest' -import { - deserialize, - parsePath, - serialize, - stringifyPath, -} from './bracket-notation' +import { deserialize, parsePath, serialize, stringifyPath } from './bracket-notation' describe('stringifyPath', () => { it('should convert simple path segments to bracket notation', () => { diff --git a/packages/transformer/src/bracket-notation.ts b/packages/openapi/src/fetch/bracket-notation.ts similarity index 100% rename from packages/transformer/src/bracket-notation.ts rename to packages/openapi/src/fetch/bracket-notation.ts diff --git a/packages/openapi/src/fetch/index.ts b/packages/openapi/src/fetch/index.ts index 18c20ca5..1a7ae107 100644 --- a/packages/openapi/src/fetch/index.ts +++ b/packages/openapi/src/fetch/index.ts @@ -1,3 +1,9 @@ -export * from './base-handler' -export * from './server-handler' -export * from './serverless-handler' +export * from './bracket-notation' +export * from './input-builder-full' +export * from './input-builder-simple' +export * from './openapi-handler' +export * from './openapi-handler-server' +export * from './openapi-handler-serverless' +export * from './openapi-payload-codec' +export * from './openapi-procedure-matcher' +export * from './schema-coercer' diff --git a/packages/openapi/src/fetch/input-builder-full.ts b/packages/openapi/src/fetch/input-builder-full.ts new file mode 100644 index 00000000..7a30d023 --- /dev/null +++ b/packages/openapi/src/fetch/input-builder-full.ts @@ -0,0 +1,14 @@ +import type { Params } from 'hono/router' + +export class InputBuilderFull { + build(params: Params, query: unknown, headers: unknown, body: unknown): { params: Params, query: unknown, headers: unknown, body: unknown } { + return { + params, + query, + headers, + body, + } + } +} + +export type PublicInputBuilderFull = Pick diff --git a/packages/openapi/src/fetch/input-builder-simple.ts b/packages/openapi/src/fetch/input-builder-simple.ts new file mode 100644 index 00000000..2c06f505 --- /dev/null +++ b/packages/openapi/src/fetch/input-builder-simple.ts @@ -0,0 +1,21 @@ +import type { Params } from 'hono/router' +import { isPlainObject } from '@orpc/shared' + +export class InputBuilderSimple { + build(params: Params, payload: unknown): unknown { + if (Object.keys(params).length === 0) { + return payload + } + + if (!isPlainObject(payload)) { + return params + } + + return { + ...params, + ...payload, + } + } +} + +export type PublicInputBuilderSimple = Pick diff --git a/packages/openapi/src/fetch/openapi-handler-server.ts b/packages/openapi/src/fetch/openapi-handler-server.ts new file mode 100644 index 00000000..34b568c0 --- /dev/null +++ b/packages/openapi/src/fetch/openapi-handler-server.ts @@ -0,0 +1,10 @@ +import type { Context, Router } from '@orpc/server' +import type { OpenAPIHandlerOptions } from './openapi-handler' +import { TrieRouter } from 'hono/router/trie-router' +import { OpenAPIHandler } from './openapi-handler' + +export class OpenAPIServerHandler extends OpenAPIHandler { + constructor(router: Router, options?: NoInfer>) { + super(new TrieRouter(), router, options) + } +} diff --git a/packages/openapi/src/fetch/openapi-handler-serverless.ts b/packages/openapi/src/fetch/openapi-handler-serverless.ts new file mode 100644 index 00000000..943f8776 --- /dev/null +++ b/packages/openapi/src/fetch/openapi-handler-serverless.ts @@ -0,0 +1,10 @@ +import type { Context, Router } from '@orpc/server' +import type { OpenAPIHandlerOptions } from './openapi-handler' +import { LinearRouter } from 'hono/router/linear-router' +import { OpenAPIHandler } from './openapi-handler' + +export class OpenAPIServerlessHandler extends OpenAPIHandler { + constructor(router: Router, options?: NoInfer>) { + super(new LinearRouter(), router, options) + } +} diff --git a/packages/openapi/src/fetch/openapi-handler.test.ts b/packages/openapi/src/fetch/openapi-handler.test.ts new file mode 100644 index 00000000..9ce67df5 --- /dev/null +++ b/packages/openapi/src/fetch/openapi-handler.test.ts @@ -0,0 +1,223 @@ +import { ContractProcedure } from '@orpc/contract' +import { createProcedureClient, lazy, Procedure } from '@orpc/server' +import { ORPC_HANDLER_HEADER, ORPC_HANDLER_VALUE } from '@orpc/shared' +import { LinearRouter } from 'hono/router/linear-router' +import { PatternRouter } from 'hono/router/pattern-router' +import { TrieRouter } from 'hono/router/trie-router' +import { OpenAPIHandler } from './openapi-handler' + +vi.mock('@orpc/server', async original => ({ + ...await original(), + createProcedureClient: vi.fn(() => vi.fn()), +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +const hono = [ + ['LinearRouter', new LinearRouter()], + // ['RegExpRouter', new RegExpRouter()], + ['TrieRouter', new TrieRouter()], + ['PatternRouter', new PatternRouter()], +] as const + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe.each(hono)('openAPIHandler: %s', (_, hono) => { + const ping = new Procedure({ + contract: new ContractProcedure({ + route: { + method: 'GET', + path: '/ping', + }, + InputSchema: undefined, + OutputSchema: undefined, + }), + func: vi.fn(), + }) + const pong = new Procedure({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + route: { + method: 'POST', + path: '/pong/{name}', + }, + }), + func: vi.fn(), + }) + + const router = { + ping: lazy(() => Promise.resolve({ default: ping })), + pong, + nested: lazy(() => Promise.resolve({ + default: { + ping, + pong: lazy(() => Promise.resolve({ default: pong })), + }, + })), + } + + it('should return a 404 response if no matching procedure is found', async () => { + const handler = new OpenAPIHandler(hono, router) + + const mockRequest = new Request('https://example.com/not_found', { + headers: new Headers({}), + }) + + const response = await handler.fetch(mockRequest) + + expect(response?.status).toBe(404) + + const body = await response?.text() + expect(body).toContain('Not found') + }) + + it('should return a 200 response with serialized output if procedure is resolved successfully', async () => { + const handler = new OpenAPIHandler(hono, router) + + const caller = vi.fn().mockReturnValueOnce('__mocked__') + vi.mocked(createProcedureClient).mockReturnValue(caller) + + const mockRequest = new Request('https://example.com/ping?value=123', { + headers: new Headers({}), + }) + + const response = await handler.fetch(mockRequest) + + expect(response?.status).toBe(200) + + const body = await response?.json() + expect(body).toEqual('__mocked__') + + expect(caller).toBeCalledTimes(1) + expect(caller).toBeCalledWith({ value: '123' }, { signal: undefined }) + }) + + it('support params', async () => { + const handler = new OpenAPIHandler(hono, router) + + const caller = vi.fn().mockReturnValueOnce('__mocked__') + vi.mocked(createProcedureClient).mockReturnValue(caller) + + const mockRequest = new Request('https://example.com/pong/unnoq', { + method: 'POST', + body: new Blob([JSON.stringify({ value: '123' })], { type: 'application/json' }), + }) + + const response = await handler.fetch(mockRequest) + + expect(response?.status).toBe(200) + + const body = await response?.json() + expect(body).toEqual('__mocked__') + + expect(caller).toBeCalledTimes(1) + expect(caller).toBeCalledWith({ value: '123', name: 'unnoq' }, { signal: undefined }) + }) + + it('should handle unexpected errors and return a 500 response', async () => { + const handler = new OpenAPIHandler(hono, router) + + vi.mocked(createProcedureClient).mockImplementationOnce(() => { + throw new Error('Unexpected error') + }) + + const mockRequest = new Request('https://example.com/ping') + + const response = await handler.fetch(mockRequest) + + expect(response?.status).toBe(500) + + const body = await response?.text() + expect(body).toContain('Internal server error') + }) + + it('support signal', async () => { + const handler = new OpenAPIHandler(hono, router) + + const caller = vi.fn().mockReturnValueOnce('__mocked__') + vi.mocked(createProcedureClient).mockReturnValue(caller) + + const mockRequest = new Request('https://example.com/ping?value=123', { + headers: new Headers({}), + }) + + const controller = new AbortController() + const signal = controller.signal + + const response = await handler.fetch(mockRequest, { signal }) + + expect(response?.status).toBe(200) + + const body = await response?.json() + expect(body).toEqual('__mocked__') + + expect(caller).toBeCalledTimes(1) + expect(caller).toBeCalledWith({ value: '123' }, { signal }) + }) + + it('hooks', async () => { + const mockRequest = new Request('https://example.com/not_found', { + headers: new Headers({}), + method: 'POST', + body: JSON.stringify({ data: { value: '123' }, meta: [] }), + }) + + const onStart = vi.fn() + const onSuccess = vi.fn() + const onError = vi.fn() + + const handler = new OpenAPIHandler(hono, router, { + onStart, + onSuccess, + onError, + }) + + const response = await handler.fetch(mockRequest) + + expect(response?.status).toBe(404) + + expect(onStart).toBeCalledTimes(1) + expect(onSuccess).toBeCalledTimes(0) + expect(onError).toBeCalledTimes(1) + }) + + it('conditions', () => { + const handler = new OpenAPIHandler(hono, router) + + expect(handler.condition(new Request('https://example.com'))).toBe(true) + expect(handler.condition(new Request('https://example.com', { + headers: new Headers({ [ORPC_HANDLER_HEADER]: ORPC_HANDLER_VALUE }), + }))).toBe(false) + }) + + it('schema coercer', async () => { + const coerce = vi.fn().mockReturnValue('__mocked__') + + const handler = new OpenAPIHandler(hono, router, { + schemaCoercers: [ + { + coerce, + }, + ], + }) + + const mockRequest = new Request('https://example.com/ping?value=123', { + headers: new Headers({}), + }) + + const response = await handler.fetch(mockRequest) + + expect(response?.status).toBe(200) + + expect(coerce).toBeCalledTimes(1) + expect(coerce).toBeCalledWith(undefined, { value: '123' }) + expect(createProcedureClient).toBeCalledTimes(1) + expect(vi.mocked(createProcedureClient).mock.results[0]?.value).toBeCalledTimes(1) + expect(vi.mocked(createProcedureClient).mock.results[0]?.value).toBeCalledWith('__mocked__', { signal: undefined }) + }) +}) diff --git a/packages/openapi/src/fetch/openapi-handler.ts b/packages/openapi/src/fetch/openapi-handler.ts new file mode 100644 index 00000000..c8ed8e40 --- /dev/null +++ b/packages/openapi/src/fetch/openapi-handler.ts @@ -0,0 +1,135 @@ +import type { ConditionalFetchHandler, FetchOptions } from '@orpc/server/fetch' +import type { PublicInputBuilderSimple } from './input-builder-simple' +import { type Context, createProcedureClient, ORPCError, type Router, type WithSignal } from '@orpc/server' +import { executeWithHooks, type Hooks, ORPC_HANDLER_HEADER, trim } from '@orpc/shared' +import { InputBuilderFull, type PublicInputBuilderFull } from './input-builder-full' +import { InputBuilderSimple } from './input-builder-simple' +import { OpenAPIPayloadCodec, type PublicOpenAPIPayloadCodec } from './openapi-payload-codec' +import { type Hono, OpenAPIProcedureMatcher, type PublicOpenAPIProcedureMatcher } from './openapi-procedure-matcher' +import { CompositeSchemaCoercer, type SchemaCoercer } from './schema-coercer' + +export type OpenAPIHandlerOptions = + & Hooks + & { + procedureMatcher?: PublicOpenAPIProcedureMatcher + payloadCodec?: PublicOpenAPIPayloadCodec + inputBuilderSimple?: PublicInputBuilderSimple + inputBuilderFull?: PublicInputBuilderFull + schemaCoercers?: SchemaCoercer[] + } + +export class OpenAPIHandler implements ConditionalFetchHandler { + private readonly procedureMatcher: PublicOpenAPIProcedureMatcher + private readonly payloadCodec: PublicOpenAPIPayloadCodec + private readonly inputBuilderSimple: PublicInputBuilderSimple + private readonly inputBuilderFull: PublicInputBuilderFull + private readonly compositeSchemaCoercer: SchemaCoercer + + constructor( + hono: Hono, + router: Router, + private readonly options?: NoInfer>, + ) { + this.procedureMatcher = options?.procedureMatcher ?? new OpenAPIProcedureMatcher(hono, router) + this.payloadCodec = options?.payloadCodec ?? new OpenAPIPayloadCodec() + this.inputBuilderSimple = options?.inputBuilderSimple ?? new InputBuilderSimple() + this.inputBuilderFull = options?.inputBuilderFull ?? new InputBuilderFull() + this.compositeSchemaCoercer = new CompositeSchemaCoercer(options?.schemaCoercers ?? []) + } + + condition(request: Request): boolean { + return request.headers.get(ORPC_HANDLER_HEADER) === null + } + + async fetch( + request: Request, + ...[options]: [options: FetchOptions] | (undefined extends T ? [] : never) + ): Promise { + const context = options?.context as T + const headers = request.headers + const accept = headers.get('Accept') || undefined + + const execute = async () => { + const url = new URL(request.url) + const pathname = `/${trim(url.pathname.replace(options?.prefix ?? '', ''), '/')}` + const query = url.searchParams + const customMethod = request.method === 'POST' ? query.get('method')?.toUpperCase() : undefined + const method = customMethod || request.method + + const match = await this.procedureMatcher.match(method, pathname) + + if (!match) { + throw new ORPCError({ code: 'NOT_FOUND', message: 'Not found' }) + } + + // TODO: handle input-builder-full + // const decodedHeaders = await this.payloadCodec.decode(headers) + // const decodedQuery = await this.payloadCodec.decode(query) + const decodedPayload = request.method === 'GET' + ? await this.payloadCodec.decode(query) + : await this.payloadCodec.decode(request) + + const input = this.inputBuilderSimple.build(match.params, decodedPayload) + + const coercedInput = this.compositeSchemaCoercer.coerce(match.procedure['~orpc'].contract['~orpc'].InputSchema, input) + + const client = createProcedureClient({ + context, + procedure: match.procedure, + path: match.path, + }) + + const output = await client(coercedInput, { signal: options?.signal }) + + const { body, headers } = this.payloadCodec.encode(output) + + return new Response(body, { headers }) + } + + try { + return await executeWithHooks({ + context, + execute, + input: request, + hooks: this.options, + meta: { + signal: options?.signal, + }, + }) + } + catch (e) { + const error = this.convertToORPCError(e) + + try { + const { body, headers } = this.payloadCodec.encode(error.toJSON(), accept) + return new Response(body, { + status: error.status, + headers, + }) + } + catch (e) { + /** + * This catch usually happens when the `Accept` header is not supported. + */ + + const error = this.convertToORPCError(e) + + const { body, headers } = this.payloadCodec.encode(error.toJSON()) + return new Response(body, { + status: error.status, + headers, + }) + } + } + } + + private convertToORPCError(e: unknown): ORPCError { + return e instanceof ORPCError + ? e + : new ORPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal server error', + cause: e, + }) + } +} diff --git a/packages/openapi/src/fetch/openapi-payload-codec.test.ts b/packages/openapi/src/fetch/openapi-payload-codec.test.ts new file mode 100644 index 00000000..9e4d6ff1 --- /dev/null +++ b/packages/openapi/src/fetch/openapi-payload-codec.test.ts @@ -0,0 +1,111 @@ +import { OpenAPIPayloadCodec } from './openapi-payload-codec' + +describe('openAPIPayloadCodec', () => { + const codec = new OpenAPIPayloadCodec() + + describe('encode', () => { + it('should encode JSON data when accept header is application/json', async () => { + const payload = { name: 'test', age: 25 } + const result = codec.encode(payload, 'application/json') as any + + expect(JSON.parse(result?.body as string)).toEqual(payload) + expect(result?.headers?.get('Content-Type')).toBe('application/json') + }) + + it('should handle undefined payload for JSON', () => { + const result = codec.encode(undefined, 'application/json') + expect(result.body).toBeUndefined() + expect(result.headers?.get('Content-Type')).toBe('application/json') + }) + + it('should encode FormData when accept header is multipart/form-data', () => { + const payload = { + name: 'test', + file: new Blob(['test content'], { type: 'text/plain' }), + } + const result = codec.encode(payload, 'multipart/form-data') + const formData = result.body as FormData + expect(formData).toBeInstanceOf(FormData) + expect(formData.get('name')).toBe('test') + expect(formData.get('file')).toBeInstanceOf(Blob) + }) + + it('should encode URL params when accept header is application/x-www-form-urlencoded', async () => { + const payload = { name: 'test', age: 25 } + const result = codec.encode(payload, 'application/x-www-form-urlencoded') + + expect(result.body).toBe('name=test&age=25') + expect(result.headers?.get('Content-Type')).toBe('application/x-www-form-urlencoded') + }) + + it('should throw NOT_ACCEPTABLE error for unsupported content type', () => { + expect(() => codec.encode({ test: 'data' }, 'invalid/type')).toThrow('Unsupported content-type: invalid/type') + }) + }) + + describe('decode', () => { + it('should decode JSON response', async () => { + const payload = { name: 'test', age: 25 } + const response = new Response(JSON.stringify(payload), { + headers: { 'Content-Type': 'application/json' }, + }) + + const result = await codec.decode(response) + expect(result).toEqual(payload) + }) + + it('should decode form-urlencoded data', async () => { + const params = new URLSearchParams() + params.append('name', 'test') + params.append('age', '25') + + const result = await codec.decode(params) + expect(result).toEqual({ name: 'test', age: '25' }) + }) + + it('should decode multipart form data', async () => { + const formData = new FormData() + formData.append('name', 'test') + formData.append('file', new Blob(['content'], { type: 'text/plain' })) + + const response = new Response(formData) + + const result = await codec.decode(response) + expect(result).toHaveProperty('name', 'test') + expect(result).toHaveProperty('file') + }) + + it('should handle file downloads with Content-Disposition', async () => { + const blob = new Blob(['test content'], { type: 'text/plain' }) + const response = new Response(blob, { + headers: { + 'Content-Type': 'text/plain', + 'Content-Disposition': 'attachment; filename="test.txt"', + }, + }) + + const result = await codec.decode(response) as File + expect(result).toBeInstanceOf(File) + expect(result.name).toBe('test.txt') + expect(result.type).toBe('text/plain') + }) + + it('should decode plain text response', async () => { + const response = new Response('test content', { + headers: { 'Content-Type': 'text/plain' }, + }) + + const result = await codec.decode(response) + expect(result).toBe('test content') + }) + + it('should handle empty response body', async () => { + const response = new Response(null, { + headers: { 'Content-Type': 'application/json' }, + }) + + const result = await codec.decode(response) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/packages/openapi/src/fetch/openapi-payload-codec.ts b/packages/openapi/src/fetch/openapi-payload-codec.ts new file mode 100644 index 00000000..ed682247 --- /dev/null +++ b/packages/openapi/src/fetch/openapi-payload-codec.ts @@ -0,0 +1,227 @@ +import { ORPCError } from '@orpc/server' +import { findDeepMatches, isPlainObject } from '@orpc/shared' +import cd from 'content-disposition' +import { safeParse } from 'fast-content-type-parse' +import wcmatch from 'wildcard-match' +import * as BracketNotation from './bracket-notation' + +export class OpenAPIPayloadCodec { + encode(payload: unknown, accept?: string): { body: FormData | Blob | string | undefined, headers?: Headers } { + const typeMatchers = ( + accept?.split(',').map(safeParse) ?? [{ type: '*/*' }] + ).map(({ type }) => wcmatch(type)) + + if (payload instanceof Blob) { + const contentType = payload.type || 'application/octet-stream' + + if (typeMatchers.some(isMatch => isMatch(contentType))) { + const headers = new Headers({ + 'Content-Type': contentType, + }) + + if (payload instanceof File && payload.name) { + headers.append('Content-Disposition', cd(payload.name)) + } + + return { + body: payload, + headers, + } + } + } + + const handledPayload = this.serialize(payload) + const hasBlobs = findDeepMatches(v => v instanceof Blob, handledPayload).values.length > 0 + + const isExpectedMultipartFormData = typeMatchers.some(isMatch => + isMatch('multipart/form-data'), + ) + + if (hasBlobs && isExpectedMultipartFormData) { + return this.encodeAsFormData(handledPayload) + } + + if (typeMatchers.some(isMatch => isMatch('application/json'))) { + return this.encodeAsJSON(handledPayload) + } + + if ( + typeMatchers.some(isMatch => + isMatch('application/x-www-form-urlencoded'), + ) + ) { + return this.encodeAsURLSearchParams(handledPayload) + } + + if (isExpectedMultipartFormData) { + return this.encodeAsFormData(handledPayload) + } + + throw new ORPCError({ + code: 'NOT_ACCEPTABLE', + message: `Unsupported content-type: ${accept}`, + }) + } + + private encodeAsJSON(payload: unknown) { + if (payload === undefined) { + return { + body: undefined, + headers: new Headers({ + 'content-type': 'application/json', + }), + } + } + + return { + body: JSON.stringify(payload), + headers: new Headers({ + 'content-type': 'application/json', + }), + } + } + + private encodeAsFormData(payload: unknown) { + const form = new FormData() + + for (const [path, value] of BracketNotation.serialize(payload)) { + if ( + typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean' + ) { + form.append(path, value.toString()) + } + else if (value === null) { + form.append(path, 'null') + } + else if (value instanceof Date) { + form.append( + path, + Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString(), + ) + } + else if (value instanceof Blob) { + form.append(path, value) + } + } + + return { + body: form, + } + } + + private encodeAsURLSearchParams(payload: unknown) { + const params = new URLSearchParams() + + for (const [path, value] of BracketNotation.serialize(payload)) { + if ( + typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean' + ) { + params.append(path, value.toString()) + } + else if (value === null) { + params.append(path, 'null') + } + else if (value instanceof Date) { + params.append( + path, + Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString(), + ) + } + } + + return { + body: params.toString(), + headers: new Headers({ + 'content-type': 'application/x-www-form-urlencoded', + }), + } + } + + serialize(payload: unknown): unknown { + if (payload instanceof Set) + return this.serialize([...payload]) + if (payload instanceof Map) + return this.serialize([...payload.entries()]) + if (Array.isArray(payload)) { + return payload.map(v => (v === undefined ? 'undefined' : this.serialize(v))) + } + if (Number.isNaN(payload)) + return 'NaN' + if (typeof payload === 'bigint') + return payload.toString() + if (payload instanceof Date && Number.isNaN(payload.getTime())) { + return 'Invalid Date' + } + if (payload instanceof RegExp) + return payload.toString() + if (payload instanceof URL) + return payload.toString() + if (!isPlainObject(payload)) + return payload + return Object.keys(payload).reduce( + (carry, key) => { + const val = payload[key] + carry[key] = this.serialize(val) + return carry + }, + {} as Record, + ) + } + + async decode(re: Request | Response | Headers | URLSearchParams | FormData): Promise { + if ( + re instanceof Headers + || re instanceof URLSearchParams + || re instanceof FormData + ) { + return BracketNotation.deserialize([...re.entries()]) + } + + const contentType = re.headers.get('content-type') + const contentDisposition = re.headers.get('content-disposition') + const fileName = contentDisposition ? cd.parse(contentDisposition).parameters.filename : undefined + + if (fileName) { + const blob = await re.blob() + const file = new File([blob], fileName, { + type: blob.type, + }) + + return file + } + + if (!contentType || contentType.startsWith('application/json')) { + if (!re.body) { + return undefined + } + + return await re.json() + } + + if (contentType.startsWith('application/x-www-form-urlencoded')) { + const params = new URLSearchParams(await re.text()) + return this.decode(params) + } + + if (contentType.startsWith('text/')) { + const text = await re.text() + return text + } + + if (contentType.startsWith('multipart/form-data')) { + const form = await re.formData() + return this.decode(form) + } + + const blob = await re.blob() + return new File([blob], 'blob', { + type: blob.type, + }) + } +} + +export type PublicOpenAPIPayloadCodec = Pick diff --git a/packages/openapi/src/fetch/openapi-procedure-matcher.test.ts b/packages/openapi/src/fetch/openapi-procedure-matcher.test.ts new file mode 100644 index 00000000..ab65f62d --- /dev/null +++ b/packages/openapi/src/fetch/openapi-procedure-matcher.test.ts @@ -0,0 +1,82 @@ +import { os } from '@orpc/server' +import { LinearRouter } from 'hono/router/linear-router' +import { PatternRouter } from 'hono/router/pattern-router' +import { TrieRouter } from 'hono/router/trie-router' +import { OpenAPIProcedureMatcher } from './openapi-procedure-matcher' + +const hono = [ + ['LinearRouter', new LinearRouter()], + // ['RegExpRouter', new RegExpRouter()], + ['TrieRouter', new TrieRouter()], + ['PatternRouter', new PatternRouter()], +] as const + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe.each(hono)('openAPIProcedureMatcher: %s', (_, hono) => { + const ping = os.route({ path: '/ping', method: 'GET' }).func(() => 'pong') + const pong = os.route({ path: '/pong/{name}' }).func(() => 'pong') + + const pingLazyLoader = vi.fn(() => Promise.resolve({ default: ping })) + const pongLazyLoader = vi.fn(() => Promise.resolve({ default: pong })) + + const lazyRouterLoader = vi.fn(() => Promise.resolve({ default: { ping, pong: os.lazy(pongLazyLoader) } })) + + const router = os.router({ + ping: os.lazy(pingLazyLoader), + pong, + nested: os.prefix('/nested').lazy(lazyRouterLoader), + }) + + const matcher = new OpenAPIProcedureMatcher(hono, router) + + it('lazy load nested router has prefix', async () => { + const r1 = await matcher.match('GET', '/not-found') + expect(r1).toBeUndefined() + + expect(pingLazyLoader).toBeCalledTimes(1) + expect(pongLazyLoader).toBeCalledTimes(0) + expect(lazyRouterLoader).toBeCalledTimes(0) + + vi.clearAllMocks() + const r2 = await matcher.match('GET', '/ping') + expect(r2?.path).toEqual(['ping']) + expect(r2?.procedure['~orpc'].func).toBe(ping['~orpc'].func) + + expect(pingLazyLoader).toBeCalledTimes(1) + expect(pongLazyLoader).toBeCalledTimes(0) + expect(lazyRouterLoader).toBeCalledTimes(0) + + vi.clearAllMocks() + const r3 = await matcher.match('GET', '/nested') + expect(r3).toBeUndefined() + + expect(pingLazyLoader).toBeCalledTimes(0) + expect(pongLazyLoader).toBeCalledTimes(1) + expect(lazyRouterLoader).toBeCalledTimes(1) + + vi.clearAllMocks() + const r4 = await matcher.match('GET', '/nested/ping') + expect(r4?.path).toEqual(['nested', 'ping']) + expect(r4?.procedure['~orpc'].func).toBe(ping['~orpc'].func) + + expect(pingLazyLoader).toBeCalledTimes(0) + expect(pongLazyLoader).toBeCalledTimes(0) + expect(lazyRouterLoader).toBeCalledTimes(1) + }) + + it('match and params', async () => { + const r1 = await matcher.match('POST', '/pong/unnoq') + + expect(r1?.path).toEqual(['pong']) + expect(r1?.params).toEqual({ name: 'unnoq' }) + expect(r1?.procedure['~orpc'].func).toBe(pong['~orpc'].func) + }) + + it('not found', async () => { + const r1 = await matcher.match('GET', '/not-found') + expect(r1).toBeUndefined() + }) +}) diff --git a/packages/openapi/src/fetch/openapi-procedure-matcher.ts b/packages/openapi/src/fetch/openapi-procedure-matcher.ts new file mode 100644 index 00000000..4e4f152c --- /dev/null +++ b/packages/openapi/src/fetch/openapi-procedure-matcher.ts @@ -0,0 +1,117 @@ +import type { HTTPPath } from '@orpc/contract' +import type { Router as BaseHono, ParamIndexMap, Params } from 'hono/router' +import { type ANY_PROCEDURE, type ANY_ROUTER, getLazyRouterPrefix, getRouterChild, isProcedure, unlazy } from '@orpc/server' +import { mapValues } from '@orpc/shared' +import { forEachContractProcedure, standardizeHTTPPath } from '../utils' + +export type Hono = BaseHono<[string, string[]]> + +type PendingRouter = { path: string[], router: ANY_ROUTER } + +export class OpenAPIProcedureMatcher { + private pendingRouters: PendingRouter[] + + constructor( + private readonly hono: Hono, + private readonly router: ANY_ROUTER, + ) { + this.pendingRouters = [{ path: [], router }] + } + + async match( + method: string, + pathname: string, + ): Promise<{ path: string[], procedure: ANY_PROCEDURE, params: Params } | undefined> { + await this.handlePendingRouters(pathname) + + const [matches, paramStash] = this.hono.match(method, pathname) + + const [match] = matches.sort((a, b) => { + const slashCountA = a[0][0].split('/').length + const slashCountB = b[0][0].split('/').length + + if (slashCountA !== slashCountB) { + /** + * More slashes in the path means it's more specific. + * So, we want push the path with more slashes to the start of the array. + */ + return slashCountB - slashCountA + } + + const paramsCountA = Object.keys(a[1]).length + const paramsCountB = Object.keys(b[1]).length + + /** + * More params in the path mean it's less specific. + * So, we want to push the path with fewer params to the start of the array. + */ + return paramsCountA - paramsCountB + }) + + if (!match) { + return undefined + } + + const path = match[0][1] + const params = paramStash + ? mapValues( + match[1] as ParamIndexMap, // if paramStash is defined, then match[1] is ParamIndexMap + v => paramStash[v]!, + ) + : match[1] as Params // if paramStash is undefined, then match[1] is Params + + const { default: maybeProcedure } = await unlazy(getRouterChild(this.router, ...path)) + + if (!isProcedure(maybeProcedure)) { + return undefined + } + + return { + path, + procedure: maybeProcedure, + params: { ...params }, // normalize params from hono + } + } + + private add(path: string[], router: ANY_ROUTER): void { + const lazies = forEachContractProcedure({ path, router }, ({ path, contract }) => { + const method = contract['~orpc'].route?.method ?? 'POST' + const httpPath = contract['~orpc'].route?.path + ? this.convertOpenAPIPathToRouterPath(contract['~orpc'].route?.path) + : `/${path.map(encodeURIComponent).join('/')}` + + this.hono.add(method, httpPath, [httpPath, path]) + }) + + this.pendingRouters.push(...lazies) + } + + private async handlePendingRouters(pathname: string): Promise { + const newPendingLazyRouters: PendingRouter[] = [] + + for (const item of this.pendingRouters) { + const lazyPrefix = getLazyRouterPrefix(item.router) + + if ( + lazyPrefix + && !pathname.startsWith(lazyPrefix) + && !pathname.startsWith(`/${item.path.map(encodeURIComponent).join('/')}`) + ) { + newPendingLazyRouters.push(item) + continue + } + + const { default: router } = await unlazy(item.router) + + this.add(item.path, router) + } + + this.pendingRouters = newPendingLazyRouters + } + + private convertOpenAPIPathToRouterPath(path: HTTPPath): string { + return standardizeHTTPPath(path).replace(/\{([^}]+)\}/g, ':$1') + } +} + +export type PublicOpenAPIProcedureMatcher = Pick diff --git a/packages/openapi/src/fetch/schema-coercer.test.ts b/packages/openapi/src/fetch/schema-coercer.test.ts new file mode 100644 index 00000000..f3d6eae1 --- /dev/null +++ b/packages/openapi/src/fetch/schema-coercer.test.ts @@ -0,0 +1,169 @@ +import type { Schema } from '@orpc/contract' +import { z } from 'zod' +import { CompositeSchemaCoercer, type SchemaCoercer } from './schema-coercer' + +// Mock implementation of SchemaCoercer for testing +class MockSchemaCoercer implements SchemaCoercer { + constructor(private readonly transform: (value: unknown) => unknown) { } + + coerce(schema: Schema, value: unknown): unknown { + return this.transform(value) + } +} + +describe('compositeSchemaCoercer', () => { + describe('coerce', () => { + it('should apply coercers in sequence with number schema', () => { + const addOneCoercer = new MockSchemaCoercer(value => (typeof value === 'number' ? value + 1 : value)) + const multiplyByTwoCoercer = new MockSchemaCoercer(value => (typeof value === 'number' ? value * 2 : value)) + + const composite = new CompositeSchemaCoercer([addOneCoercer, multiplyByTwoCoercer]) + const schema = z.number() + + const result = composite.coerce(schema, 5) + + // First coercer adds 1 (5 -> 6), then second coercer multiplies by 2 (6 -> 12) + expect(result).toBe(12) + }) + + it('should handle string to number coercion', () => { + const stringToNumberCoercer = new MockSchemaCoercer(value => + typeof value === 'string' ? Number.parseInt(value, 10) : value, + ) + + const composite = new CompositeSchemaCoercer([stringToNumberCoercer]) + const schema = z.number() + + const result = composite.coerce(schema, '123') + + expect(result).toBe(123) + expect(typeof result).toBe('number') + }) + + it('should handle empty coercer array', () => { + const composite = new CompositeSchemaCoercer([]) + const schema = z.string() + const value = 'test' + + const result = composite.coerce(schema, value) + + expect(result).toBe(value) + }) + + it('should pass schema to each coercer', () => { + const schema = z.string().regex(/^test/) + const mockCoercer = { + coerce: vi.fn().mockImplementation((_, value) => value), + } + + const composite = new CompositeSchemaCoercer([mockCoercer]) + composite.coerce(schema, 'test') + + expect(mockCoercer.coerce).toHaveBeenCalledWith(schema, 'test') + }) + + it('should handle complex object schemas', () => { + const schema = z.object({ + name: z.string(), + age: z.number(), + isActive: z.boolean(), + }) + + const objectCoercer = new MockSchemaCoercer((value: any) => { + if (typeof value !== 'object' || value === null) + return value + return { + ...value, + age: typeof value.age === 'string' ? Number.parseInt(value.age, 10) : value.age, + } + }) + + const composite = new CompositeSchemaCoercer([objectCoercer]) + + const result = composite.coerce(schema, { + name: 'John', + age: '30', + isActive: true, + }) + + expect(result).toEqual({ + name: 'John', + age: 30, + isActive: true, + }) + }) + + it('should handle array schemas', () => { + const schema = z.array(z.number()) + const arrayCoercer = new MockSchemaCoercer((value) => { + if (!Array.isArray(value)) + return value + return value.map(item => typeof item === 'string' ? Number.parseInt(item, 10) : item) + }) + + const composite = new CompositeSchemaCoercer([arrayCoercer]) + + const result = composite.coerce(schema, ['1', '2', '3']) + + expect(result).toEqual([1, 2, 3]) + }) + + it('should maintain coercer order with complex transformations', () => { + const transforms: unknown[] = [] + const schema = z.any() + + const firstCoercer = new MockSchemaCoercer((value) => { + transforms.push(1) + return value + }) + + const secondCoercer = new MockSchemaCoercer((value) => { + transforms.push(2) + return value + }) + + const thirdCoercer = new MockSchemaCoercer((value) => { + transforms.push(3) + return value + }) + + const composite = new CompositeSchemaCoercer([firstCoercer, secondCoercer, thirdCoercer]) + + composite.coerce(schema, 'test') + + expect(transforms).toEqual([1, 2, 3]) + }) + + it('should handle optional fields in object schemas', () => { + const schema = z.object({ + required: z.string(), + optional: z.number().optional(), + }) + + const objectCoercer = new MockSchemaCoercer((value: any) => { + if (typeof value !== 'object' || value === null) + return value + return { + ...value, + optional: value.optional !== undefined + ? typeof value.optional === 'string' + ? Number.parseInt(value.optional, 10) + : value.optional + : undefined, + } + }) + + const composite = new CompositeSchemaCoercer([objectCoercer]) + + const result = composite.coerce(schema, { + required: 'test', + optional: '42', + }) + + expect(result).toEqual({ + required: 'test', + optional: 42, + }) + }) + }) +}) diff --git a/packages/openapi/src/fetch/schema-coercer.ts b/packages/openapi/src/fetch/schema-coercer.ts new file mode 100644 index 00000000..0ccb4237 --- /dev/null +++ b/packages/openapi/src/fetch/schema-coercer.ts @@ -0,0 +1,20 @@ +import type { Schema } from '@orpc/contract' + +export interface SchemaCoercer { + coerce: (schema: Schema, value: unknown) => unknown +} + +export class CompositeSchemaCoercer implements SchemaCoercer { + constructor( + private readonly coercers: SchemaCoercer[], + ) {} + + coerce(schema: Schema, value: unknown): unknown { + let current = value + for (const coercer of this.coercers) { + current = coercer.coerce(schema, current) + } + + return current + } +} diff --git a/packages/openapi/src/fetch/server-handler.test.ts b/packages/openapi/src/fetch/server-handler.test.ts deleted file mode 100644 index 4a0e945b..00000000 --- a/packages/openapi/src/fetch/server-handler.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { os } from '@orpc/server' -import { z } from 'zod' -import { createOpenAPIServerHandler } from './server-handler' -import { createOpenAPIServerlessHandler } from './serverless-handler' - -const handlers = [createOpenAPIServerHandler(), createOpenAPIServerlessHandler()] -describe.each(handlers)('openAPIServerHandler', (handler) => { - const ping = os.input(z.object({ value: z.string() })).output(z.string()).func((input) => { - return input.value - }) - const pong = os.func(() => 'pong') - - const lazyRouter = os.lazy(() => Promise.resolve({ - default: { - ping: os.lazy(() => Promise.resolve({ default: ping })), - pong, - lazyRouter: os.lazy(() => Promise.resolve({ default: { ping, pong } })), - }, - })) - - const router = os.router({ - ping, - pong, - lazyRouter, - }) - - it('should handle request', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/ping', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ value: '123' }), - }), - }) - - expect(response?.status).toEqual(200) - expect(await response?.json()).toEqual('123') - }) - - it('should handle request - lazy', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/lazyRouter/ping', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ value: '123' }), - }), - }) - - expect(response?.status).toEqual(200) - expect(await response?.json()).toEqual('123') - }) - - it('should handle request - lazy - lazy', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/lazyRouter/lazyRouter/ping', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ value: '123' }), - }), - }) - - expect(response?.status).toEqual(200) - expect(await response?.json()).toEqual('123') - }) - - it('should throw error - not found', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/pingp', { - method: 'POST', - headers: { - }, - body: JSON.stringify({ value: '123' }), - }), - }) - - expect(response?.status).toEqual(404) - expect(await response?.json()).toEqual({ code: 'NOT_FOUND', message: 'Not found', status: 404 }) - }) - - it('should throw error - not found - lazy', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/lazyRouter/not_found', { - method: 'POST', - headers: { - }, - body: JSON.stringify({ value: '123' }), - }), - }) - - expect(response?.status).toEqual(404) - expect(await response?.json()).toEqual({ code: 'NOT_FOUND', message: 'Not found', status: 404 }) - }) - - it('should throw error - invalid input', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/ping', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ value: 123 }), - }), - }) - - expect(response?.status).toEqual(400) - expect(await response?.json()).toEqual({ - code: 'BAD_REQUEST', - status: 400, - message: 'Input validation failed', - issues: [ - { - code: 'invalid_type', - expected: 'string', - received: 'number', - path: [ - 'value', - ], - message: 'Expected string, received number', - }, - ], - }) - }) - - it('should throw error - invalid input - lazy', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/lazyRouter/ping', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ value: 123 }), - }), - }) - - expect(response?.status).toEqual(400) - expect(await response?.json()).toEqual({ - code: 'BAD_REQUEST', - status: 400, - message: 'Input validation failed', - issues: [ - { - code: 'invalid_type', - expected: 'string', - received: 'number', - path: [ - 'value', - ], - message: 'Expected string, received number', - }, - ], - }) - }) - - it('should throw error - invalid input - lazy - lazy', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/lazyRouter/lazyRouter/ping', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ value: 123 }), - }), - }) - - expect(response?.status).toEqual(400) - expect(await response?.json()).toEqual({ - code: 'BAD_REQUEST', - status: 400, - message: 'Input validation failed', - issues: [ - { - code: 'invalid_type', - expected: 'string', - received: 'number', - path: [ - 'value', - ], - message: 'Expected string, received number', - }, - ], - }) - }) - - it('abort signal', async () => { - const controller = new AbortController() - const signal = controller.signal - - const func = vi.fn() - const onSuccess = vi.fn() - - const ping = os.func(func) - - const response = await handler({ - router: { ping }, - request: new Request('http://localhost/ping', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ }), - }), - signal, - onSuccess, - }) - - expect(response?.status).toEqual(200) - - expect(func).toBeCalledTimes(1) - expect(func.mock.calls[0]![2].signal).toBe(signal) - expect(onSuccess).toBeCalledTimes(1) - expect(func.mock.calls[0]![2].signal).toBe(signal) - }) -}) diff --git a/packages/openapi/src/fetch/server-handler.ts b/packages/openapi/src/fetch/server-handler.ts deleted file mode 100644 index 687688d8..00000000 --- a/packages/openapi/src/fetch/server-handler.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { FetchHandler } from '@orpc/server/fetch' -import { RegExpRouter } from 'hono/router/reg-exp-router' -import { createOpenAPIHandler } from './base-handler' - -export function createOpenAPIServerHandler(): FetchHandler { - return createOpenAPIHandler(() => new RegExpRouter()) -} diff --git a/packages/openapi/src/fetch/serverless-handler.ts b/packages/openapi/src/fetch/serverless-handler.ts deleted file mode 100644 index 784c45a9..00000000 --- a/packages/openapi/src/fetch/serverless-handler.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { FetchHandler } from '@orpc/server/fetch' -import { LinearRouter } from 'hono/router/linear-router' -import { createOpenAPIHandler } from './base-handler' - -export function createOpenAPIServerlessHandler(): FetchHandler { - return createOpenAPIHandler(() => new LinearRouter()) -} diff --git a/packages/openapi/src/generator.test.ts b/packages/openapi/src/generator.test.ts index a1708a18..552f8488 100644 --- a/packages/openapi/src/generator.test.ts +++ b/packages/openapi/src/generator.test.ts @@ -2,6 +2,7 @@ import type { OpenAPIObject } from 'openapi3-ts/oas31' import { oc } from '@orpc/contract' import { os } from '@orpc/server' import { oz } from '@orpc/zod' +import OpenAPIParser from '@readme/openapi-parser' import { z } from 'zod' import { generateOpenAPI } from './generator' @@ -27,6 +28,8 @@ it('works', async () => { }, }) + await OpenAPIParser.validate(spec as any) + expect(spec).toMatchObject({ openapi: '3.1.0', info: { @@ -118,6 +121,8 @@ it('throwOnMissingTagDefinition option', async () => { { throwOnMissingTagDefinition: true }, ) + await OpenAPIParser.validate(spec as any) + expect(spec).toMatchObject({ openapi: '3.1.0', info: { @@ -213,6 +218,8 @@ it('support single file upload', async () => { }, }) + await OpenAPIParser.validate(spec as any) + expect(spec).toMatchObject({ paths: { '/upload': { @@ -364,6 +371,8 @@ it('work with example', async () => { }, }) + await OpenAPIParser.validate(spec as any) + expect(spec).toMatchObject({ paths: { '/upload': { @@ -488,6 +497,8 @@ it('should remove params on body', async () => { }, }) + await OpenAPIParser.validate(spec as any) + expect(spec).toEqual({ info: { title: 'test', version: '1.0.0' }, openapi: '3.1.0', @@ -574,6 +585,8 @@ it('should remove params on query', async () => { }, }) + await OpenAPIParser.validate(spec as any) + expect(spec).toEqual({ info: { title: 'test', version: '1.0.0' }, openapi: '3.1.0', @@ -658,6 +671,8 @@ it('works with lazy', async () => { }, }) + await OpenAPIParser.validate(spec as any) + expect(spec).toMatchObject({ openapi: '3.1.0', info: { @@ -718,6 +733,8 @@ it('works will use contract instead of implemented', async () => { }, }) + await OpenAPIParser.validate(spec as any) + expect(spec).toMatchObject({ openapi: '3.1.0', info: { diff --git a/packages/openapi/src/generator.ts b/packages/openapi/src/generator.ts index b7e80694..83b011c7 100644 --- a/packages/openapi/src/generator.ts +++ b/packages/openapi/src/generator.ts @@ -1,9 +1,9 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12' +import type { PublicOpenAPIPayloadCodec } from './fetch' import type { EachLeafOptions } from './utils' import { type ContractRouter, isContractProcedure } from '@orpc/contract' import { type ANY_ROUTER, unlazy } from '@orpc/server' import { findDeepMatches, isPlainObject, omit } from '@orpc/shared' -import { preSerialize } from '@orpc/transformer' import { type MediaTypeObject, OpenApiBuilder, @@ -13,7 +13,8 @@ import { type RequestBodyObject, type ResponseObject, } from 'openapi3-ts/oas31' -import { eachContractProcedureLeaf, standardizeHTTPPath } from './utils' +import { OpenAPIPayloadCodec } from './fetch' +import { forEachContractProcedure, standardizeHTTPPath } from './utils' import { extractJSONSchema, UNSUPPORTED_JSON_SCHEMA, @@ -39,6 +40,8 @@ export interface GenerateOpenAPIOptions { * @default false */ ignoreUndefinedPathProcedures?: boolean + + payloadCodec?: PublicOpenAPIPayloadCodec } export async function generateOpenAPI( @@ -51,6 +54,7 @@ export async function generateOpenAPI( = options?.throwOnMissingTagDefinition ?? false const ignoreUndefinedPathProcedures = options?.ignoreUndefinedPathProcedures ?? false + const payloadCodec = options?.payloadCodec ?? new OpenAPIPayloadCodec() const builder = new OpenApiBuilder({ ...omit(opts, ['router']), @@ -65,7 +69,7 @@ export async function generateOpenAPI( }] for (const item of pending) { - const lazies = eachContractProcedureLeaf(item, ({ contract, path }) => { + const lazies = forEachContractProcedure(item, ({ contract, path }) => { if (!isContractProcedure(contract)) { return } @@ -141,15 +145,15 @@ export async function generateOpenAPI( ...inputSchema, properties: inputSchema.properties ? Object.entries(inputSchema.properties).reduce( - (acc, [key, value]) => { - if (key !== name) { - acc[key] = value - } - - return acc - }, - {} as Record, - ) + (acc, [key, value]) => { + if (key !== name) { + acc[key] = value + } + + return acc + }, + {} as Record, + ) : undefined, required: inputSchema.required?.filter(v => v !== name), examples: inputSchema.examples?.map((example) => { @@ -344,7 +348,7 @@ export async function generateOpenAPI( }) for (const lazy of lazies) { - const { default: router } = await unlazy(lazy.lazy) + const { default: router } = await unlazy(lazy.router) pending.push({ path: lazy.path, @@ -353,7 +357,7 @@ export async function generateOpenAPI( } } - return preSerialize(builder.getSpec()) as OpenAPIObject + return payloadCodec.serialize(builder.getSpec()) as OpenAPIObject } function isFileSchema(schema: unknown) { diff --git a/packages/openapi/src/utils.ts b/packages/openapi/src/utils.ts index ec916729..44efc0bd 100644 --- a/packages/openapi/src/utils.ts +++ b/packages/openapi/src/utils.ts @@ -1,7 +1,7 @@ import type { ContractRouter, HTTPPath, WELL_CONTRACT_PROCEDURE } from '@orpc/contract' import type { ANY_PROCEDURE, ANY_ROUTER, Lazy } from '@orpc/server' import { isContractProcedure } from '@orpc/contract' -import { flatLazy, getRouterContract, isLazy, isProcedure } from '@orpc/server' +import { getRouterContract, isLazy, isProcedure } from '@orpc/server' export interface EachLeafOptions { router: ContractRouter | ANY_ROUTER @@ -14,11 +14,11 @@ export interface EachLeafCallbackOptions { } export interface EachContractLeafResultItem { - lazy: Lazy | Lazy> + router: Lazy | Lazy | ANY_PROCEDURE> path: string[] } -export function eachContractProcedureLeaf( +export function forEachContractProcedure( options: EachLeafOptions, callback: (options: EachLeafCallbackOptions) => void, result: EachContractLeafResultItem[] = [], @@ -27,7 +27,7 @@ export function eachContractProcedureLeaf( const hiddenContract = getRouterContract(options.router) if (!isCurrentRouterContract && hiddenContract) { - return eachContractProcedureLeaf( + return forEachContractProcedure( { path: options.path, router: hiddenContract, @@ -40,7 +40,7 @@ export function eachContractProcedureLeaf( if (isLazy(options.router)) { result.push({ - lazy: flatLazy(options.router), + router: options.router, path: options.path, }) } @@ -64,7 +64,7 @@ export function eachContractProcedureLeaf( // else { for (const key in options.router) { - eachContractProcedureLeaf( + forEachContractProcedure( { router: (options.router as any)[key], path: [...options.path, key], diff --git a/packages/openapi/tsconfig.json b/packages/openapi/tsconfig.json index ed820b91..3976c48e 100644 --- a/packages/openapi/tsconfig.json +++ b/packages/openapi/tsconfig.json @@ -1,17 +1,17 @@ { "extends": "../../tsconfig.lib.json", "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": [] }, "references": [ { "path": "../contract" }, { "path": "../server" }, - { "path": "../shared" }, - { "path": "../transformer" }, - { "path": "../zod" } + { "path": "../shared" } ], "include": ["src"], "exclude": [ + "**/*.bench.*", "**/*.test.*", "**/*.test-d.ts", "**/__tests__/**", diff --git a/packages/openapi/vitest.setup.ts b/packages/openapi/vitest.setup.ts deleted file mode 100644 index 68722adb..00000000 --- a/packages/openapi/vitest.setup.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { generateOpenAPI } from './src/generator' -import OpenAPIParser from '@readme/openapi-parser' -import { expect, vi } from 'vitest' - -// eslint-disable-next-line antfu/no-top-level-await -const generator = await vi.importActual('./src/generator') - -vi.mock('./src/generator', () => ({ - generateOpenAPI: vi.fn(async (...args) => { - // @ts-expect-error - untyped - const spec = await generator.generateOpenAPI(...args) - expect( - (async () => { - await OpenAPIParser.validate(spec) - return true - })(), - ).resolves.toBe(true) - return spec - }) as typeof generateOpenAPI, -})) diff --git a/packages/react-query/tests/helpers.tsx b/packages/react-query/tests/helpers.tsx index fa232965..3214cce3 100644 --- a/packages/react-query/tests/helpers.tsx +++ b/packages/react-query/tests/helpers.tsx @@ -1,6 +1,6 @@ import { createORPCFetchClient } from '@orpc/client' import { os } from '@orpc/server' -import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' +import { ORPCHandler } from '@orpc/server/fetch' import { QueryClient } from '@tanstack/react-query' import { z } from 'zod' import { createORPCReactQueryUtils } from '../src' @@ -91,6 +91,8 @@ export const appRouter = orpcServer.router({ }, }) +const orpcHandler = new ORPCHandler(appRouter) + export const orpcClient = createORPCFetchClient({ baseURL: 'http://localhost:3000', @@ -98,11 +100,7 @@ export const orpcClient = createORPCFetchClient({ await new Promise(resolve => setTimeout(resolve, 100)) const request = new Request(...args) - return handleFetchRequest({ - router: appRouter, - request, - handlers: [createORPCHandler()], - }) + return orpcHandler.fetch(request) }, }) diff --git a/packages/react/src/react-utils.ts b/packages/react/src/react-utils.ts index d4372057..ebf36ed7 100644 --- a/packages/react/src/react-utils.ts +++ b/packages/react/src/react-utils.ts @@ -35,10 +35,10 @@ export function createORPCUtils>( // for sure root is not procedure, so do not it procedure utils on root const procedureUtils = path.length ? createProcedureUtils({ - client, - queryClient: options.contextValue.queryClient, - path, - }) + client, + queryClient: options.contextValue.queryClient, + path, + }) : {} return new Proxy( diff --git a/packages/react/tests/orpc.tsx b/packages/react/tests/orpc.tsx index e45b9a60..5f7501a7 100644 --- a/packages/react/tests/orpc.tsx +++ b/packages/react/tests/orpc.tsx @@ -1,7 +1,7 @@ import type { RouterClient } from '@orpc/server' import { createORPCFetchClient } from '@orpc/client' import { os } from '@orpc/server' -import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' +import { ORPCHandler } from '@orpc/server/fetch' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import React, { Suspense } from 'react' import { z } from 'zod' @@ -93,6 +93,8 @@ export const appRouter = orpcServer.router({ }, }) +const orpcHandler = new ORPCHandler(appRouter) + export const orpcClient = createORPCFetchClient({ baseURL: 'http://localhost:3000', @@ -100,11 +102,7 @@ export const orpcClient = createORPCFetchClient({ await new Promise(resolve => setTimeout(resolve, 100)) const request = new Request(...args) - return handleFetchRequest({ - router: appRouter, - request, - handlers: [createORPCHandler()], - }) + return orpcHandler.fetch(request) }, }) diff --git a/packages/server/package.json b/packages/server/package.json index c8681f4a..f00cd293 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -47,13 +47,9 @@ "build:watch": "pnpm run build --watch", "type:check": "tsc -b" }, - "peerDependencies": { - "@orpc/zod": "workspace:*" - }, "dependencies": { "@orpc/contract": "workspace:*", - "@orpc/shared": "workspace:*", - "@orpc/transformer": "workspace:*" + "@orpc/shared": "workspace:*" }, "devDependencies": { "zod": "^3.24.1" diff --git a/packages/server/src/fetch/composite-handler.test-d.ts b/packages/server/src/fetch/composite-handler.test-d.ts new file mode 100644 index 00000000..08061954 --- /dev/null +++ b/packages/server/src/fetch/composite-handler.test-d.ts @@ -0,0 +1,24 @@ +import type { ConditionalFetchHandler, FetchHandler } from './types' +import { CompositeHandler } from './composite-handler' + +describe('CompositeHandler - Type Tests', () => { + it('requires ConditionalFetchHandler', () => { + void new CompositeHandler([ + {} as ConditionalFetchHandler, + // @ts-expect-error -- should be ConditionalFetchHandler + {} as FetchHandler, + // @ts-expect-error -- should be ConditionalFetchHandler + {}, + ]) + }) + + it('infers context type', () => { + const handler = new CompositeHandler([ + {} as ConditionalFetchHandler<{ auth: boolean }>, + ]) + + handler.fetch(new Request('https://example.com'), { context: { auth: true } }) + // @ts-expect-error -- invalid context type + handler.fetch(new Request('https://example.com'), { context: { auth: 'invalid' } }) + }) +}) diff --git a/packages/server/src/fetch/composite-handler.test.ts b/packages/server/src/fetch/composite-handler.test.ts new file mode 100644 index 00000000..4aad656e --- /dev/null +++ b/packages/server/src/fetch/composite-handler.test.ts @@ -0,0 +1,89 @@ +import type { ConditionalFetchHandler, FetchOptions } from './types' +import { CompositeHandler } from './composite-handler' + +// Mock a basic handler implementation +function createMockHandler( + condition: (request: Request) => boolean, + fetch: (request: Request, options?: FetchOptions) => Promise, +): ConditionalFetchHandler { + return { + condition, + fetch, + } +} + +describe('compositeHandler', () => { + it('should call the fetch method of the first handler that matches the condition', async () => { + const mockHandler1 = createMockHandler( + request => request.url.includes('handler1'), + vi.fn(() => Promise.resolve(new Response('Handler 1 response'))), + ) + + const mockHandler2 = createMockHandler( + request => request.url.includes('handler2'), + vi.fn(() => Promise.resolve(new Response('Handler 2 response'))), + ) + + const compositeHandler = new CompositeHandler([mockHandler1, mockHandler2]) + + const request = new Request('https://example.com/handler1') + const response = await compositeHandler.fetch(request) + + expect(mockHandler1.fetch).toHaveBeenCalledTimes(1) + expect(mockHandler2.fetch).toHaveBeenCalledTimes(0) + expect(await response.text()).toBe('Handler 1 response') + }) + + it('should return a 404 response when no handler matches the condition', async () => { + const mockHandler1 = createMockHandler( + request => request.url.includes('handler1'), + vi.fn(() => Promise.resolve(new Response('Handler 1 response'))), + ) + + const mockHandler2 = createMockHandler( + request => request.url.includes('handler2'), + vi.fn(() => Promise.resolve(new Response('Handler 2 response'))), + ) + + const compositeHandler = new CompositeHandler([mockHandler1, mockHandler2]) + + const request = new Request('https://example.com/unknown') + const response = await compositeHandler.fetch(request) + + expect(mockHandler1.fetch).toHaveBeenCalledTimes(0) + expect(mockHandler2.fetch).toHaveBeenCalledTimes(0) + expect(response.status).toBe(404) + expect(await response.text()).toBe('None of the handlers can handle the request.') + }) + + it('should handle an empty handlers array', async () => { + const compositeHandler = new CompositeHandler([]) + + const request = new Request('https://example.com/unknown') + const response = await compositeHandler.fetch(request) + + expect(response.status).toBe(404) + expect(await response.text()).toBe('None of the handlers can handle the request.') + }) + + it('should handle multiple handlers, but only call the first matching handler', async () => { + const mockHandler1 = createMockHandler( + request => request.url.includes('handler1'), + vi.fn(() => Promise.resolve(new Response('Handler 1 response'))), + ) + + const mockHandler2 = createMockHandler( + request => request.url.includes('handler1'), + vi.fn(() => Promise.resolve(new Response('Handler 2 response'))), + ) + + const compositeHandler = new CompositeHandler([mockHandler1, mockHandler2]) + + const request = new Request('https://example.com/handler1') + const response = await compositeHandler.fetch(request) + + expect(mockHandler1.fetch).toHaveBeenCalledTimes(1) + expect(mockHandler2.fetch).toHaveBeenCalledTimes(0) + expect(await response.text()).toBe('Handler 1 response') + }) +}) diff --git a/packages/server/src/fetch/composite-handler.ts b/packages/server/src/fetch/composite-handler.ts new file mode 100644 index 00000000..446d8800 --- /dev/null +++ b/packages/server/src/fetch/composite-handler.ts @@ -0,0 +1,23 @@ +import type { Context } from '../types' +import type { ConditionalFetchHandler, FetchHandler, FetchOptions } from './types' + +export class CompositeHandler implements FetchHandler { + constructor( + private readonly handlers: ConditionalFetchHandler[], + ) {} + + async fetch( + request: Request, + ...opt: [options: FetchOptions] | (undefined extends T ? [] : never) + ): Promise { + for (const handler of this.handlers) { + if (handler.condition(request)) { + return handler.fetch(request, ...opt) + } + } + + return new Response('None of the handlers can handle the request.', { + status: 404, + }) + } +} diff --git a/packages/server/src/fetch/handle-request.test-d.ts b/packages/server/src/fetch/handle-request.test-d.ts deleted file mode 100644 index cff1d0c2..00000000 --- a/packages/server/src/fetch/handle-request.test-d.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Procedure } from '../procedure' -import type { WELL_CONTEXT, WithSignal } from '../types' -import { lazy } from '../lazy' -import { handleFetchRequest } from './handle-request' - -describe('handleFetchRequest', () => { - const ping = {} as Procedure<{ auth: boolean }, { db: string }, undefined, undefined, undefined> - const pong = {} as Procedure - - const router = { - ping: lazy(() => Promise.resolve({ default: ping })), - pong, - nested: lazy(() => Promise.resolve({ default: { - ping, - pong: lazy(() => Promise.resolve({ default: pong })), - } })), - } - - it('infer correct context', () => { - handleFetchRequest({ - request: {} as Request, - router, - handlers: [vi.fn()], - context: { auth: true }, - }) - - handleFetchRequest({ - request: {} as Request, - router, - handlers: [vi.fn()], - context: () => ({ auth: true }), - }) - - handleFetchRequest({ - request: {} as Request, - router, - handlers: [vi.fn()], - context: async () => ({ auth: true }), - }) - - handleFetchRequest({ - request: {} as Request, - router, - handlers: [vi.fn()], - // @ts-expect-error --- invalid context - context: { auth: 123 }, - }) - - // @ts-expect-error --- missing context - handleFetchRequest({ - request: {} as Request, - router, - handlers: [vi.fn()], - }) - }) - - it('hooks', () => { - handleFetchRequest({ - request: {} as Request, - router, - handlers: [vi.fn()], - context: { auth: true }, - onSuccess: ({ output, input }, context, meta) => { - expectTypeOf(output).toEqualTypeOf() - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf() - }, - }) - }) -}) diff --git a/packages/server/src/fetch/handle-request.test.ts b/packages/server/src/fetch/handle-request.test.ts deleted file mode 100644 index f28e7bee..00000000 --- a/packages/server/src/fetch/handle-request.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { Procedure } from '../procedure' -import type { WELL_CONTEXT } from '../types' -import { lazy } from '../lazy' -import { handleFetchRequest } from './handle-request' - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('handleFetchRequest', () => { - const ping = {} as Procedure<{ auth: boolean }, { db: string }, undefined, undefined, undefined> - const pong = {} as Procedure - - const router = { - ping: lazy(() => Promise.resolve({ default: ping })), - pong, - nested: lazy(() => Promise.resolve({ default: { - ping, - pong: lazy(() => Promise.resolve({ default: pong })), - } })), - } - - const handler1 = vi.fn() - - it('forward request to handlers', async () => { - const options = { - request: {} as Request, - router, - handlers: [handler1], - context: { auth: true }, - } as const - - const mockedResponse = new Response('__mocked__') - handler1.mockReturnValueOnce(mockedResponse) - - const response = await handleFetchRequest(options) - - expect(response).toBe(mockedResponse) - - expect(handler1).toBeCalledTimes(1) - expect(handler1).toBeCalledWith(options) - }) - - it('try all handlers utils return response', async () => { - const handler2 = vi.fn() - const handler3 = vi.fn() - - const options = { - request: {} as Request, - router, - handlers: [handler1, handler2, handler3], - context: { auth: true }, - } as const - - const mockedResponse = new Response('__mocked__') - handler2.mockReturnValueOnce(mockedResponse) - - const response = await handleFetchRequest(options) - - expect(response).toBe(mockedResponse) - - expect(handler1).toBeCalledTimes(1) - expect(handler1).toBeCalledWith(options) - expect(handler2).toBeCalledTimes(1) - expect(handler2).toBeCalledWith(options) - expect(handler3).toBeCalledTimes(0) - }) - - it('fallback 404 if no handler return response', async () => { - const handler2 = vi.fn() - const handler3 = vi.fn() - - const options = { - request: {} as Request, - router, - handlers: [handler1, handler2, handler3], - context: { auth: true }, - } as const - - const response = await handleFetchRequest(options) - - expect(handler1).toBeCalledTimes(1) - expect(handler2).toBeCalledTimes(1) - expect(handler3).toBeCalledTimes(1) - - expect(response).toBeInstanceOf(Response) - expect(response.status).toBe(404) - expect(await response.json()).toEqual({ - code: 'NOT_FOUND', - message: 'Not found', - status: 404, - }) - }) -}) diff --git a/packages/server/src/fetch/handle-request.ts b/packages/server/src/fetch/handle-request.ts deleted file mode 100644 index 238b7918..00000000 --- a/packages/server/src/fetch/handle-request.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Context } from '../types' -import type { FetchHandler, FetchHandlerOptions } from './types' -import { ORPCError } from '@orpc/shared/error' - -export type HandleFetchRequestOptions = FetchHandlerOptions & { - handlers: readonly [FetchHandler, ...FetchHandler[]] -} - -export async function handleFetchRequest( - options: HandleFetchRequestOptions, -) { - for (const handler of options.handlers) { - const response = await handler(options) - - if (response) { - return response - } - } - - const error = new ORPCError({ code: 'NOT_FOUND', message: 'Not found' }) - - return new Response(JSON.stringify(error.toJSON()), { - status: error.status, - headers: { - 'Content-Type': 'application/json', - }, - }) -} diff --git a/packages/server/src/fetch/index.ts b/packages/server/src/fetch/index.ts index 11433a67..56c6f3a1 100644 --- a/packages/server/src/fetch/index.ts +++ b/packages/server/src/fetch/index.ts @@ -1,3 +1,5 @@ -export * from './handle-request' +export * from './composite-handler' export * from './orpc-handler' +export * from './orpc-payload-codec' +export * from './orpc-procedure-matcher' export * from './types' diff --git a/packages/server/src/fetch/orpc-handler.test-d.ts b/packages/server/src/fetch/orpc-handler.test-d.ts index e9544444..6e4ba215 100644 --- a/packages/server/src/fetch/orpc-handler.test-d.ts +++ b/packages/server/src/fetch/orpc-handler.test-d.ts @@ -1,21 +1,20 @@ -import { handleFetchRequest } from './handle-request' -import { createORPCHandler } from './orpc-handler' +import type { WithSignal } from '..' +import { os } from '..' +import { ORPCHandler } from './orpc-handler' -it('assignable to handlers', () => { - handleFetchRequest({ - request: new Request('https://example.com', {}), - router: {}, - handlers: [ - createORPCHandler(), - ], - }) +describe('oRPCHandler', () => { + it('hooks', () => { + const router = { + ping: os.context<{ userId?: string }>().func(() => 'pong'), + } - handleFetchRequest({ - request: new Request('https://example.com', {}), - router: {}, - handlers: [ - // @ts-expect-error - invalid handler - createORPCHandler, - ], + const handler = new ORPCHandler(router, { + onSuccess(state, context, meta) { + expectTypeOf(state.input).toEqualTypeOf() + expectTypeOf(state.output).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ userId?: string }>() + expectTypeOf(meta).toEqualTypeOf() + }, + }) }) }) diff --git a/packages/server/src/fetch/orpc-handler.test.ts b/packages/server/src/fetch/orpc-handler.test.ts index 6916e571..13b4cf53 100644 --- a/packages/server/src/fetch/orpc-handler.test.ts +++ b/packages/server/src/fetch/orpc-handler.test.ts @@ -1,16 +1,16 @@ import { ContractProcedure } from '@orpc/contract' -import { ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_VALUE } from '@orpc/shared' +import { ORPC_HANDLER_HEADER, ORPC_HANDLER_VALUE } from '@orpc/shared' import { describe, expect, it, vi } from 'vitest' import { lazy } from '../lazy' import { Procedure } from '../procedure' import { createProcedureClient } from '../procedure-client' -import { createORPCHandler } from './orpc-handler' +import { ORPCHandler } from './orpc-handler' vi.mock('../procedure-client', () => ({ createProcedureClient: vi.fn(() => vi.fn()), })) -describe('createORPCHandler', () => { +describe('oRPCHandler', () => { const ping = new Procedure({ contract: new ContractProcedure({ InputSchema: undefined, @@ -29,40 +29,22 @@ describe('createORPCHandler', () => { const router = { ping: lazy(() => Promise.resolve({ default: ping })), pong, - nested: lazy(() => Promise.resolve({ default: { - ping, - pong: lazy(() => Promise.resolve({ default: pong })), - } })), + nested: lazy(() => Promise.resolve({ + default: { + ping, + pong: lazy(() => Promise.resolve({ default: pong })), + }, + })), } - it('should return undefined if the protocol header is missing or incorrect', async () => { - const handler = createORPCHandler() - - const response = await handler({ - request: new Request('https://example.com', { - headers: new Headers({}), - }), - router, - context: undefined, - signal: undefined, - }) - - expect(response).toBeUndefined() - }) - it('should return a 404 response if no matching procedure is found', async () => { - const handler = createORPCHandler() + const handler = new ORPCHandler(router) const mockRequest = new Request('https://example.com/not_found', { - headers: new Headers({ [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE }), + headers: new Headers({ }), }) - const response = await handler({ - request: mockRequest, - router, - context: undefined, - signal: undefined, - }) + const response = await handler.fetch(mockRequest) expect(response?.status).toBe(404) @@ -71,21 +53,18 @@ describe('createORPCHandler', () => { }) it('should return a 200 response with serialized output if procedure is resolved successfully', async () => { - const handler = createORPCHandler() + const handler = new ORPCHandler(router) const caller = vi.fn().mockReturnValueOnce('__mocked__') vi.mocked(createProcedureClient).mockReturnValue(caller) const mockRequest = new Request('https://example.com/ping', { - headers: new Headers({ [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE }), + headers: new Headers({ }), method: 'POST', body: JSON.stringify({ data: { value: '123' }, meta: [] }), }) - const response = await handler({ - request: mockRequest, - router, - }) + const response = await handler.fetch(mockRequest) expect(response?.status).toBe(200) @@ -97,18 +76,15 @@ describe('createORPCHandler', () => { }) it('should handle deserialization errors and return a 400 response', async () => { - const handler = createORPCHandler() + const handler = new ORPCHandler(router) const mockRequest = new Request('https://example.com/ping', { method: 'POST', - headers: new Headers({ [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, 'Content-Type': 'application/json' }), + headers: new Headers({ 'Content-Type': 'application/json' }), body: '{ invalid json', }) - const response = await handler({ - request: mockRequest, - router, - }) + const response = await handler.fetch(mockRequest) expect(response?.status).toBe(400) @@ -117,24 +93,19 @@ describe('createORPCHandler', () => { }) it('should handle unexpected errors and return a 500 response', async () => { - const handler = createORPCHandler() + const handler = new ORPCHandler(router) vi.mocked(createProcedureClient).mockImplementationOnce(() => { throw new Error('Unexpected error') }) const mockRequest = new Request('https://example.com/ping', { - headers: new Headers({ [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE }), + headers: new Headers({ }), method: 'POST', body: JSON.stringify({ data: { value: '123' }, meta: [] }), }) - const response = await handler({ - request: mockRequest, - router, - context: undefined, - signal: undefined, - }) + const response = await handler.fetch(mockRequest) expect(response?.status).toBe(500) @@ -143,13 +114,13 @@ describe('createORPCHandler', () => { }) it('support signal', async () => { - const handler = createORPCHandler() + const handler = new ORPCHandler(router) const caller = vi.fn().mockReturnValueOnce('__mocked__') vi.mocked(createProcedureClient).mockReturnValue(caller) const mockRequest = new Request('https://example.com/ping', { - headers: new Headers({ [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE }), + headers: new Headers({ }), method: 'POST', body: JSON.stringify({ data: { value: '123' }, meta: [] }), }) @@ -157,11 +128,7 @@ describe('createORPCHandler', () => { const controller = new AbortController() const signal = controller.signal - const response = await handler({ - request: mockRequest, - router, - signal, - }) + const response = await handler.fetch(mockRequest, { signal }) expect(response?.status).toBe(200) @@ -173,10 +140,8 @@ describe('createORPCHandler', () => { }) it('hooks', async () => { - const handler = createORPCHandler() - const mockRequest = new Request('https://example.com/not_found', { - headers: new Headers({ [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE }), + headers: new Headers({ }), method: 'POST', body: JSON.stringify({ data: { value: '123' }, meta: [] }), }) @@ -185,18 +150,27 @@ describe('createORPCHandler', () => { const onSuccess = vi.fn() const onError = vi.fn() - const response = await handler({ - request: mockRequest, - router, + const handler = new ORPCHandler(router, { onStart, onSuccess, onError, }) + const response = await handler.fetch(mockRequest) + expect(response?.status).toBe(404) expect(onStart).toBeCalledTimes(1) expect(onSuccess).toBeCalledTimes(0) expect(onError).toBeCalledTimes(1) }) + + it('conditions', () => { + const handler = new ORPCHandler(router) + + expect(handler.condition(new Request('https://example.com'))).toBe(false) + expect(handler.condition(new Request('https://example.com', { + headers: new Headers({ [ORPC_HANDLER_HEADER]: ORPC_HANDLER_VALUE }), + }))).toBe(true) + }) }) diff --git a/packages/server/src/fetch/orpc-handler.ts b/packages/server/src/fetch/orpc-handler.ts index 281c507e..46c49a58 100644 --- a/packages/server/src/fetch/orpc-handler.ts +++ b/packages/server/src/fetch/orpc-handler.ts @@ -1,117 +1,92 @@ -import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE } from '../procedure' -import type { FetchHandler } from './types' -import { executeWithHooks, ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_VALUE, trim, value } from '@orpc/shared' +import type { Hooks } from '@orpc/shared' +import type { Router } from '../router' +import type { Context, WithSignal } from '../types' +import type { ConditionalFetchHandler, FetchOptions } from './types' +import { executeWithHooks, ORPC_HANDLER_HEADER, ORPC_HANDLER_VALUE, trim } from '@orpc/shared' import { ORPCError } from '@orpc/shared/error' -import { ORPCDeserializer, ORPCSerializer } from '@orpc/transformer' -import { unlazy } from '../lazy' -import { isProcedure } from '../procedure' import { createProcedureClient } from '../procedure-client' -import { type ANY_ROUTER, getRouterChild } from '../router' +import { ORPCPayloadCodec, type PublicORPCPayloadCodec } from './orpc-payload-codec' +import { ORPCProcedureMatcher, type PublicORPCProcedureMatcher } from './orpc-procedure-matcher' + +export type ORPCHandlerOptions = + & Hooks + & { + procedureMatcher?: PublicORPCProcedureMatcher + payloadCodec?: PublicORPCPayloadCodec + } -const serializer = new ORPCSerializer() -const deserializer = new ORPCDeserializer() +export class ORPCHandler implements ConditionalFetchHandler { + private readonly procedureMatcher: PublicORPCProcedureMatcher + private readonly payloadCodec: PublicORPCPayloadCodec -export function createORPCHandler(): FetchHandler { - return async (options) => { - if (!options.request.headers.get(ORPC_PROTOCOL_HEADER)?.includes(ORPC_PROTOCOL_VALUE)) { - return undefined - } + constructor( + readonly router: Router, + readonly options?: NoInfer>, + ) { + this.procedureMatcher = options?.procedureMatcher ?? new ORPCProcedureMatcher(router) + this.payloadCodec = options?.payloadCodec ?? new ORPCPayloadCodec() + } - const context = await value(options.context) + condition(request: Request): boolean { + return Boolean(request.headers.get(ORPC_HANDLER_HEADER)?.includes(ORPC_HANDLER_VALUE)) + } - const handler = async () => { - const url = new URL(options.request.url) - const pathname = `/${trim(url.pathname.replace(options.prefix ?? '', ''), '/')}` + async fetch( + request: Request, + ...[options]: [options: FetchOptions] | (undefined extends T ? [] : never) + ): Promise { + const context = options?.context as T - const match = await resolveRouterMatch(options.router, pathname) + const execute = async () => { + const url = new URL(request.url) + const pathname = `/${trim(url.pathname.replace(options?.prefix ?? '', ''), '/')}` + + const match = await this.procedureMatcher.match(pathname) if (!match) { throw new ORPCError({ code: 'NOT_FOUND', message: 'Not found' }) } - const input = await parseRequestInput(options.request) + const input = await this.payloadCodec.decode(request) - const caller = createProcedureClient({ + const client = createProcedureClient({ context, procedure: match.procedure, path: match.path, }) - const output = await caller(input, { signal: options.signal }) + const output = await client(input, { signal: options?.signal }) - const { body, headers } = serializer.serialize(output) + const { body, headers } = this.payloadCodec.encode(output) - return new Response(body, { - status: 200, - headers, - }) + return new Response(body, { headers }) } try { return await executeWithHooks({ - hooks: options, - context: context as any, - execute: handler, - input: options.request, + context, + execute, + input: request, + hooks: this.options, meta: { - signal: options.signal, + signal: options?.signal, }, }) } - catch (error) { - return handleErrorResponse(error) + catch (e) { + const error = e instanceof ORPCError + ? e + : new ORPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal server error', + cause: e, + }) + + const { body, headers } = this.payloadCodec.encode(error.toJSON()) + return new Response(body, { + headers, + status: error.status, + }) } } } - -async function resolveRouterMatch( - router: ANY_ROUTER, - pathname: string, -): Promise<{ - path: string[] - procedure: ANY_PROCEDURE | ANY_LAZY_PROCEDURE -} | undefined> { - const pathSegments = trim(pathname, '/').split('/').map(decodeURIComponent) - - const match = getRouterChild(router, ...pathSegments) - const { default: maybeProcedure } = await unlazy(match) - - if (!isProcedure(maybeProcedure)) { - return undefined - } - - return { - procedure: maybeProcedure, - path: pathSegments, - } -} - -async function parseRequestInput(request: Request): Promise { - try { - return await deserializer.deserialize(request) - } - catch (error) { - throw new ORPCError({ - code: 'BAD_REQUEST', - message: 'Cannot parse request. Please check the request body and Content-Type header.', - cause: error, - }) - } -} - -function handleErrorResponse(error: unknown): Response { - const orpcError = error instanceof ORPCError - ? error - : new ORPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Internal server error', - cause: error, - }) - - const { body, headers } = serializer.serialize(orpcError.toJSON()) - - return new Response(body, { - status: orpcError.status, - headers, - }) -} diff --git a/packages/server/src/fetch/orpc-payload-codec.test.ts b/packages/server/src/fetch/orpc-payload-codec.test.ts new file mode 100644 index 00000000..88f3fd1f --- /dev/null +++ b/packages/server/src/fetch/orpc-payload-codec.test.ts @@ -0,0 +1,118 @@ +import { ORPCPayloadCodec } from './orpc-payload-codec' + +enum Test { + A = 1, + B = 2, + C = 'C', + D = 'D', +} + +const types = [ + ['enum'], + [Test.B], + [Test.D], + ['string'], + ['string'], + [1234], + [1234], + [Number.NaN], + [true], + [true], + [false], + [false], + [null], + [null], + [undefined], + [undefined], + [new Date('2023-01-01')], + [new Date('Invalid')], + [new Date('Invalid')], + [BigInt(1234)], + [BigInt(1234)], + [/uic/gi], + [/npa|npb/gi], + [/npa|npb/], + [new URL('https://unnoq.com')], + [ + { a: 1, b: 2, c: 3 }, + ], + [[1, 2, 3]], + [ + new Map([ + [1, 2], + [3, 4], + ]), + ], + [ + { a: [1, 2, 3], b: new Set([1, 2, 3]) }, + ], + [ + new Map([ + [1, 2], + [3, 4], + ]), + ], + [ + new File(['"name"'], 'file.json', { + type: 'application/json', + }), + ], + [ + new File(['content of file'], 'file.txt', { + type: 'application/octet-stream', + }), + ], + [ + new File(['content of file 2'], 'file.pdf', { type: 'application/pdf' }), + ], +] as const + +describe('orpc-payload-codec', () => { + const codec = new ORPCPayloadCodec() + + const encode = (data: unknown) => { + const { body, headers } = codec.encode(data) + return new Response(body, { headers }) + } + + it.each(types)('should work on flat: %s', async (origin) => { + expect( + await codec.decode(encode(origin)), + ) + .toEqual(origin) + }) + + it.each(types)( + 'should work on nested object: %s', + async (origin) => { + const object = { + data: origin, + } + + expect( + await codec.decode(encode(object)), + ) + .toEqual(object) + }, + ) + + it.each(types)( + 'should work on complex object: %s', + async (origin) => { + const object = { + '[]data\\]': origin, + 'list': [origin], + 'map': new Map([[origin, origin]]), + 'set': new Set([origin]), + } + expect( + await codec.decode(encode(object)), + ) + .toEqual(object) + }, + ) + + it('throw error when decode invalid payload', async () => { + expect(codec.decode(new Response('invalid payload'))).rejects.toThrowError('Cannot parse request/response. Please check the request/response body and Content-Type header.') + }) +}) diff --git a/packages/server/src/fetch/orpc-payload-codec.ts b/packages/server/src/fetch/orpc-payload-codec.ts new file mode 100644 index 00000000..d8e181d1 --- /dev/null +++ b/packages/server/src/fetch/orpc-payload-codec.ts @@ -0,0 +1,74 @@ +import { findDeepMatches, set } from '@orpc/shared' +import { ORPCError } from '@orpc/shared/error' +import * as SuperJSON from './super-json' + +export class ORPCPayloadCodec { + encode(payload: unknown): { body: FormData | string, headers?: Headers } { + const { data, meta } = SuperJSON.serialize(payload) + const { maps, values } = findDeepMatches(v => v instanceof Blob, data) + + if (values.length > 0) { + const form = new FormData() + + if (data !== undefined) { + form.append('data', JSON.stringify(data)) + } + + form.append('meta', JSON.stringify(meta)) + form.append('maps', JSON.stringify(maps)) + + for (const i in values) { + const value = values[i]! as Blob + form.append(i, value) + } + + return { body: form } + } + + return { + body: JSON.stringify({ data, meta }), + headers: new Headers({ + 'content-type': 'application/json', + }), + } + } + + async decode(re: Request | Response): Promise { + try { + if (re.headers.get('content-type')?.startsWith('multipart/form-data')) { + const form = await re.formData() + + // Since form-data only used when has file, so the data cannot be null + const rawData = form.get('data') as string + const rawMeta = form.get('meta') as string + const rawMaps = form.get('maps') as string + + let data = JSON.parse(rawData) + const meta = JSON.parse(rawMeta) as SuperJSON.JSONMeta + const maps = JSON.parse(rawMaps) as (string | number)[][] + + for (const i in maps) { + data = set(data, maps[i]!, form.get(i)) + } + + return SuperJSON.deserialize({ + data, + meta, + }) + } + + const json = await re.json() + + return SuperJSON.deserialize(json) + } + catch (e) { + throw new ORPCError({ + code: 'BAD_REQUEST', + message: 'Cannot parse request/response. Please check the request/response body and Content-Type header.', + cause: e, + }) + } + } +} + +export type PublicORPCPayloadCodec = Pick diff --git a/packages/server/src/fetch/orpc-procedure-matcher.test.ts b/packages/server/src/fetch/orpc-procedure-matcher.test.ts new file mode 100644 index 00000000..350775f1 --- /dev/null +++ b/packages/server/src/fetch/orpc-procedure-matcher.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { os } from '..' +import { isProcedure } from '../procedure' +import { ORPCProcedureMatcher } from './orpc-procedure-matcher' + +describe('oRPCProcedureMatcher', () => { + const schema = z.object({ val: z.string().transform(v => Number(v)) }) + const ping = os.input(schema).func(() => 'pong') + const pong = os.output(schema).func(() => ({ val: '1' })) + + const router = { + ping: os.lazy(() => Promise.resolve({ default: ping })), + pong, + nested: os.lazy(() => Promise.resolve({ + default: { + ping, + pong: os.lazy(() => Promise.resolve({ default: pong })), + }, + })), + } + + const matcher = new ORPCProcedureMatcher(router) + + it('should return undefined if no match is found in the router', async () => { + const result = await matcher.match('/nonexistent/path') + expect(result).toBeUndefined() + }) + + it('should return undefined if the match is not a procedure', async () => { + const result = await matcher.match('/nested') + expect(result).toBeUndefined() + }) + + it('should return the procedure and path if a valid procedure is matched', async () => { + const result = await matcher.match('/pong') + expect(result?.path).toEqual(['pong']) + expect(result?.procedure).toSatisfy(isProcedure) + expect(result?.procedure).toBe(pong) + }) + + it('should return the procedure and path if a valid procedure is matched on lazy', async () => { + const result = await matcher.match('/nested/pong') + expect(result?.path).toEqual(['nested', 'pong']) + expect(result?.procedure).toSatisfy(isProcedure) + expect(result?.procedure['~orpc'].func).toBe(pong['~orpc'].func) + }) + + it('should handle deeply nested lazy-loaded procedures', async () => { + const result = await matcher.match('/nested/ping') + expect(result?.path).toEqual(['nested', 'ping']) + expect(result?.procedure).toSatisfy(isProcedure) + expect(result?.procedure['~orpc'].func).toBe(ping['~orpc'].func) + }) + + it('should decode URI components in the path', async () => { + const result = await matcher.match('/nested/%70ing') // '%70' decodes to 'p' + expect(result?.path).toEqual(['nested', 'ping']) + expect(result?.procedure).toSatisfy(isProcedure) + expect(result?.procedure['~orpc'].func).toBe(ping['~orpc'].func) + }) + + it('should handle empty path correctly', async () => { + const result = await matcher.match('/') + expect(result).toBeUndefined() + }) + + it('should trim leading and trailing slashes in the path', async () => { + const result = await matcher.match('/nested/pong/') + expect(result?.path).toEqual(['nested', 'pong']) + expect(result?.procedure).toSatisfy(isProcedure) + expect(result?.procedure['~orpc'].func).toBe(pong['~orpc'].func) + }) + + it('should return undefined for a path that matches a non-lazy non-procedure', async () => { + const result = await matcher.match('/nested/pong/invalid') + expect(result).toBeUndefined() + }) + + it('should support matching root-level lazy procedures', async () => { + const result = await matcher.match('/ping') + expect(result?.path).toEqual(['ping']) + expect(result?.procedure).toSatisfy(isProcedure) + expect(result?.procedure['~orpc'].func).toBe(ping['~orpc'].func) + }) +}) diff --git a/packages/server/src/fetch/orpc-procedure-matcher.ts b/packages/server/src/fetch/orpc-procedure-matcher.ts new file mode 100644 index 00000000..184d52c0 --- /dev/null +++ b/packages/server/src/fetch/orpc-procedure-matcher.ts @@ -0,0 +1,29 @@ +import type { ANY_PROCEDURE } from '../procedure' +import { trim } from '@orpc/shared' +import { unlazy } from '../lazy' +import { isProcedure } from '../procedure' +import { type ANY_ROUTER, getRouterChild } from '../router' + +export class ORPCProcedureMatcher { + constructor( + private readonly router: ANY_ROUTER, + ) { } + + async match(pathname: string): Promise<{ path: string[], procedure: ANY_PROCEDURE } | undefined> { + const path = trim(pathname, '/').split('/').map(decodeURIComponent) + + const match = getRouterChild(this.router, ...path) + const { default: maybeProcedure } = await unlazy(match) + + if (!isProcedure(maybeProcedure)) { + return undefined + } + + return { + procedure: maybeProcedure, + path, + } + } +} + +export type PublicORPCProcedureMatcher = Pick diff --git a/packages/server/src/fetch/super-json.test.ts b/packages/server/src/fetch/super-json.test.ts new file mode 100644 index 00000000..fe116d7c --- /dev/null +++ b/packages/server/src/fetch/super-json.test.ts @@ -0,0 +1,107 @@ +import * as SuperJSON from './super-json' + +enum Test { + A = 1, + B = 2, + C = 'C', + D = 'D', +} + +const types = [ + ['enum'], + [Test.B], + [Test.D], + ['string'], + ['string'], + [1234], + [1234], + [Number.NaN], + [true], + [true], + [false], + [false], + [null], + [null], + [undefined], + [undefined], + [new Date('2023-01-01')], + [new Date('Invalid')], + [new Date('Invalid')], + [BigInt(1234)], + [BigInt(1234)], + [/uic/gi], + [/npa|npb/gi], + [/npa|npb/], + [new URL('https://unnoq.com')], + [ + { a: 1, b: 2, c: 3 }, + ], + [[1, 2, 3]], + [ + new Map([ + [1, 2], + [3, 4], + ]), + ], + [ + { a: [1, 2, 3], b: new Set([1, 2, 3]) }, + ], + [ + new Map([ + [1, 2], + [3, 4], + ]), + ], + [ + new File(['"name"'], 'file.json', { + type: 'application/json', + }), + ], + [ + new File(['content of file'], 'file.txt', { + type: 'application/octet-stream', + }), + ], + [ + new File(['content of file 2'], 'file.pdf', { type: 'application/pdf' }), + ], +] as const + +describe('super json', () => { + it.each(types)('should work on flat: %s', async (origin) => { + expect( + SuperJSON.deserialize(SuperJSON.serialize(origin)), + ) + .toEqual(origin) + }) + + it.each(types)( + 'should work on nested object: %s', + async (origin) => { + const object = { + data: origin, + } + + expect( + SuperJSON.deserialize(SuperJSON.serialize(object)), + ) + .toEqual(object) + }, + ) + + it.each(types)( + 'should work on complex object: %s', + async (origin) => { + const object = { + '[]data\\]': origin, + 'list': [origin], + 'map': new Map([[origin, origin]]), + 'set': new Set([origin]), + } + expect( + SuperJSON.deserialize(SuperJSON.serialize(object)), + ) + .toEqual(object) + }, + ) +}) diff --git a/packages/transformer/src/orpc/super-json.ts b/packages/server/src/fetch/super-json.ts similarity index 91% rename from packages/transformer/src/orpc/super-json.ts rename to packages/server/src/fetch/super-json.ts index f060a973..53a104ea 100644 --- a/packages/transformer/src/orpc/super-json.ts +++ b/packages/server/src/fetch/super-json.ts @@ -123,15 +123,9 @@ export function deserialize({ break case 'regexp': { - const match = currentRef[preSegment].match(/^\/(.*)\/([a-z]*)$/) + const [, pattern, flags] = currentRef[preSegment].match(/^\/(.*)\/([a-z]*)$/) - if (match) { - const [, pattern, flags] = match - currentRef[preSegment] = new RegExp(pattern!, flags) - } - else { - currentRef[preSegment] = new RegExp(currentRef[preSegment]) - } + currentRef[preSegment] = new RegExp(pattern!, flags) break } @@ -152,6 +146,7 @@ export function deserialize({ currentRef[preSegment] = new Set(currentRef[preSegment]) break + /* v8 ignore next 3 */ default: { const _expected: never = type } diff --git a/packages/server/src/fetch/types.test-d.ts b/packages/server/src/fetch/types.test-d.ts new file mode 100644 index 00000000..9cfeb4ed --- /dev/null +++ b/packages/server/src/fetch/types.test-d.ts @@ -0,0 +1,16 @@ +import type { FetchHandler } from './types' + +describe('FetchHandler', () => { + it('optional second argument when context is not required', () => { + const handler = {} as FetchHandler<{ auth: boolean } | undefined> + + handler.fetch(new Request('https://example.com')) + handler.fetch(new Request('https://example.com'), { context: { auth: true } }) + + const handler2 = {} as FetchHandler<{ auth: boolean }> + + // @ts-expect-error -- context is required + handler2.fetch(new Request('https://example.com')) + handler2.fetch(new Request('https://example.com'), { context: { auth: true } }) + }) +}) diff --git a/packages/server/src/fetch/types.ts b/packages/server/src/fetch/types.ts index 4b58f785..14e18fce 100644 --- a/packages/server/src/fetch/types.ts +++ b/packages/server/src/fetch/types.ts @@ -1,33 +1,18 @@ import type { HTTPPath } from '@orpc/contract' -import type { Hooks, Value } from '@orpc/shared' -import type { Router } from '../router' import type { Context, WithSignal } from '../types' -export type FetchHandlerOptions = - { - /** - * The `router` used for handling the request and routing, - * - */ - router: Router - - /** - * The request need to be handled. - */ - request: Request - - /** - * Remove the prefix from the request path. - * - * @example /orpc - * @example /api - */ - prefix?: HTTPPath - } - & NoInfer<(undefined extends T ? { context?: Value } : { context: Value })> +export type FetchOptions = & WithSignal - & Hooks + & { prefix?: HTTPPath } + & (undefined extends T ? { context?: T } : { context: T }) + +export interface FetchHandler { + fetch: ( + request: Request, + ...opt: [options: FetchOptions] | (undefined extends T ? [] : never) + ) => Promise +} -export type FetchHandler = ( - options: FetchHandlerOptions -) => Promise +export interface ConditionalFetchHandler extends FetchHandler { + condition: (request: Request) => boolean +} diff --git a/packages/server/src/router-client.ts b/packages/server/src/router-client.ts index 0df11cb7..4de9ba3f 100644 --- a/packages/server/src/router-client.ts +++ b/packages/server/src/router-client.ts @@ -57,11 +57,11 @@ export function createRouterClient< const procedureCaller = isLazy(options.router) ? createProcedureClient({ - ...options, - procedure: createLazyProcedureFormAnyLazy(options.router), - context: options.context, - path: options.path, - }) + ...options, + procedure: createLazyProcedureFormAnyLazy(options.router), + context: options.context, + path: options.path, + }) : {} const recursive = new Proxy(procedureCaller, { diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index e6322353..25157c07 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -6,9 +6,7 @@ }, "references": [ { "path": "../contract" }, - { "path": "../shared" }, - { "path": "../transformer" }, - { "path": "../zod" } + { "path": "../shared" } ], "include": ["src"], "exclude": [ diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index d4682e73..beeddf15 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,2 +1,2 @@ -export const ORPC_PROTOCOL_HEADER = 'x-orpc-protocol' -export const ORPC_PROTOCOL_VALUE = 'orpc' +export const ORPC_HANDLER_HEADER = 'x-orpc-handler' +export const ORPC_HANDLER_VALUE = 'orpc' diff --git a/packages/transformer/.gitignore b/packages/transformer/.gitignore deleted file mode 100644 index f3620b55..00000000 --- a/packages/transformer/.gitignore +++ /dev/null @@ -1,26 +0,0 @@ -# Hidden folders and files -.* -!.gitignore -!.*.example - -# Common generated folders -logs/ -node_modules/ -out/ -dist/ -dist-ssr/ -build/ -coverage/ -temp/ - -# Common generated files -*.log -*.log.* -*.tsbuildinfo -*.vitest-temp.json -vite.config.ts.timestamp-* -vitest.config.ts.timestamp-* - -# Common manual ignore files -*.local -*.pem \ No newline at end of file diff --git a/packages/transformer/README.md b/packages/transformer/README.md deleted file mode 100644 index 75f8f272..00000000 --- a/packages/transformer/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# `@orpc/transformer` - -A library for transforming oRPC payloads in different client-server scenarios. - -## Overview - -`@orpc/transformer` provides two distinct sets of serializers and deserializers for different use cases: - -1. `ORPC` serializer/deserializer designed for internal oRPC clients and servers to communicate with each other. -2. `OpenAPI` serializer/deserializer designed for external clients consuming the API through OpenAPI specifications. - -```ts -import { ORPCDeserializer, ORPCSerializer } from '@orpc/transformer' - -const serializer = new ORPCSerializer() // or OpenAPISerializer -const deserializer = new ORPCDeserializer() // or OpenAPIDeserializer - -export async function fetch(request: Request) { - const input = await deserializer.deserialize(request) - - const output = { some: 'data', file: new File([], 'file.txt'), date: new Date() } - - const { body, contentType } = serializer.serialize(output) - - return new Response(body, { - headers: { - 'Content-Type': contentType, - }, - }) -} -``` - -## Types Supported - -- `string` -- `number` (include `NaN`) -- `boolean` -- `null` -- `undefined` -- `Date` (include `Invalid Date`) -- `BigInt` -- `RegExp` -- `URL` -- `Set` -- `Map` -- `Blob` -- `File` - -## Implementations - -### oRPC Serializer/Deserializer - -The primary implementation designed specifically for communication between oRPC clients and servers. -This implementation is most powerful, but the payload is not human-readable. - -### OpenAPI Serializer/Deserializer - -An implementation designed for external clients consuming the API through OpenAPI specifications. This is used when developers manually create HTTP requests based on the OpenAPI documentation. - -The payload is human-readable, but the implementation is slower and requires provide `schema` to archive the same functionality as oRPC implementation. - -With the wide of `content-type` support by OpenAPI, required user learns [Bracket Notation](#bracket-notation) to represent nested structures in some limited cases. - -## Bracket Notation - -Bracket notation is a syntax used to represent nested structures in some limited cases. - -```ts -const payload = { - user: { - name: 'John Doe', - age: 30, - }, - avatar: new File([], 'avatar.png'), - friends: ['Alice', 'Bob'], -} - -// with bracket notation - -const equivalent = { - 'user[name]': 'John Doe', - 'user[age]': 30, - 'avatar': new File([], 'avatar.png'), - 'friends[]': 'Alice', - 'friends[]': 'Bob', -} -``` diff --git a/packages/transformer/package.json b/packages/transformer/package.json deleted file mode 100644 index 49f5ebee..00000000 --- a/packages/transformer/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@orpc/transformer", - "type": "module", - "version": "0.17.0", - "license": "MIT", - "homepage": "https://orpc.unnoq.com", - "repository": { - "type": "git", - "url": "git+https://github.com/unnoq/orpc.git", - "directory": "packages/transformer" - }, - "keywords": [ - "unnoq", - "orpc" - ], - "publishConfig": { - "exports": { - ".": { - "types": "./dist/src/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - }, - "./🔒/*": { - "types": "./dist/src/*.d.ts" - } - } - }, - "exports": { - ".": "./src/index.ts", - "./🔒/*": { - "types": "./src/*.ts" - } - }, - "files": [ - "!**/*.map", - "!**/*.tsbuildinfo", - "dist" - ], - "scripts": { - "build": "tsup --clean --sourcemap --entry.index=src/index.ts --format=esm --onSuccess='tsc -b --noCheck'", - "build:watch": "pnpm run build --watch", - "type:check": "tsc -b" - }, - "dependencies": { - "@orpc/shared": "workspace:*", - "@orpc/zod": "workspace:*", - "@types/content-disposition": "^0.5.8", - "content-disposition": "^0.5.4", - "fast-content-type-parse": "^2.0.0", - "wildcard-match": "^5.1.3", - "zod": "^3.24.1" - }, - "devDependencies": { - "@anatine/zod-mock": "^3.13.4", - "superjson": "^2.2.1" - } -} diff --git a/packages/transformer/src/index.ts b/packages/transformer/src/index.ts deleted file mode 100644 index 1c9628d7..00000000 --- a/packages/transformer/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './openapi/deserializer' -export * from './openapi/serializer' -export { zodCoerce } from './openapi/zod-coerce' -export * from './orpc/deserializer' -export * from './orpc/serializer' - -export * from './types' diff --git a/packages/transformer/src/openapi/deserializer.ts b/packages/transformer/src/openapi/deserializer.ts deleted file mode 100644 index 6850c00d..00000000 --- a/packages/transformer/src/openapi/deserializer.ts +++ /dev/null @@ -1,83 +0,0 @@ -/// -/// - -import type { ZodType } from 'zod' - -import type { Deserializer } from '../types' -import { parseJSONSafely } from '@orpc/shared' -import cd from 'content-disposition' -import * as BracketNotation from '../bracket-notation' -import { zodCoerce } from './zod-coerce' - -export class OpenAPIDeserializer implements Deserializer { - constructor( - public options: { - schema?: ZodType - } = {}, - ) {} - - async deserialize(re: Request | Response): Promise { - const contentType = re.headers.get('Content-Type') - const contentDisposition = re.headers.get('Content-Disposition') - const fileName = contentDisposition - ? cd.parse(contentDisposition).parameters.filename - : undefined - - if (fileName) { - const blob = await re.blob() - const file = new File([blob], fileName, { - type: blob.type, - }) - return this.options.schema ? zodCoerce(this.options.schema, file) : file - } - - if ( - ('method' in re && re.method === 'GET') - || contentType?.startsWith('application/x-www-form-urlencoded') - ) { - const params - = 'method' in re && re.method === 'GET' - ? new URLSearchParams(re.url.split('?')[1]) - : new URLSearchParams(await re.text()) - - const data = BracketNotation.deserialize([...params.entries()]) - - return this.options.schema - ? zodCoerce(this.options.schema, data, { bracketNotation: true }) - : data - } - - if (!contentType || contentType.startsWith('application/json')) { - const text = await re.text() - const data = parseJSONSafely(text) - - return this.options.schema ? zodCoerce(this.options.schema, data) : data - } - - if (contentType.startsWith('text/')) { - const data = await re.text() - return this.options.schema - ? zodCoerce(this.options.schema, data, { bracketNotation: true }) - : data - } - - if (contentType.startsWith('multipart/form-data')) { - const form = await re.formData() - - return this.deserializeAsFormData(form) - } - - const blob = await re.blob() - const data = new File([blob], 'blob', { - type: blob.type, - }) - return this.options.schema ? zodCoerce(this.options.schema, data) : data - } - - deserializeAsFormData(form: FormData): unknown { - const data = BracketNotation.deserialize([...form.entries()]) - return this.options.schema - ? zodCoerce(this.options.schema, data, { bracketNotation: true }) - : data - } -} diff --git a/packages/transformer/src/openapi/serializer.ts b/packages/transformer/src/openapi/serializer.ts deleted file mode 100644 index 5bdb3029..00000000 --- a/packages/transformer/src/openapi/serializer.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { Serialized, Serializer } from '../types' -import { findDeepMatches } from '@orpc/shared' -import { ORPCError } from '@orpc/shared/error' -import cd from 'content-disposition' -import { safeParse } from 'fast-content-type-parse' -import { isPlainObject } from 'is-what' -import wcmatch from 'wildcard-match' -import * as BracketNotation from '../bracket-notation' - -export class OpenAPISerializer implements Serializer { - constructor( - public options: { - accept?: string - } = {}, - ) {} - - serialize(payload: unknown): Serialized { - const typeMatchers = ( - this.options.accept?.split(',').map(safeParse) ?? [{ type: '*/*' }] - ).map(({ type }) => wcmatch(type)) - - if (payload instanceof Blob) { - const contentType = this.getBlobContentType(payload) - - if (typeMatchers.some(isMatch => isMatch(contentType))) { - return this.serializeAsBlob(payload) - } - } - - const payload_ = preSerialize(payload) - const hasBlobs - = findDeepMatches(v => v instanceof Blob, payload_).values.length > 0 - - const isExpectedMultipartFormData = typeMatchers.some(isMatch => - isMatch('multipart/form-data'), - ) - - if (hasBlobs && isExpectedMultipartFormData) { - return this.serializeAsMultipartFormData(payload_) - } - - if (typeMatchers.some(isMatch => isMatch('application/json'))) { - return this.serializeAsJSON(payload_) - } - - if ( - typeMatchers.some(isMatch => - isMatch('application/x-www-form-urlencoded'), - ) - ) { - return this.serializeAsURLEncoded(payload_) - } - - if (isExpectedMultipartFormData) { - return this.serializeAsMultipartFormData(payload_) - } - - throw new ORPCError({ - code: 'NOT_ACCEPTABLE', - message: `Unsupported content-type: ${this.options.accept}`, - }) - } - - private serializeAsJSON(payload: unknown): Serialized { - const body = JSON.stringify(payload) - - return { - body, - headers: new Headers({ 'Content-Type': 'application/json' }), - } - } - - private serializeAsMultipartFormData(payload: unknown): Serialized { - const form = new FormData() - - for (const [path, value] of BracketNotation.serialize(payload)) { - if ( - typeof value === 'string' - || typeof value === 'number' - || typeof value === 'boolean' - ) { - form.append(path, value.toString()) - } - else if (value === null) { - form.append(path, 'null') - } - else if (value instanceof Date && !Number.isNaN(value.valueOf())) { - form.append( - path, - Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString(), - ) - } - else if (value instanceof Blob) { - form.append(path, value) - } - } - - return { - body: form, - headers: new Headers(), - } - } - - private serializeAsURLEncoded(payload: unknown): Serialized { - const params = new URLSearchParams() - - for (const [path, value] of BracketNotation.serialize(payload)) { - if ( - typeof value === 'string' - || typeof value === 'number' - || typeof value === 'boolean' - ) { - params.append(path, value.toString()) - } - else if (value === null) { - params.append(path, 'null') - } - else if (value instanceof Date && !Number.isNaN(value.valueOf())) { - params.append( - path, - Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString(), - ) - } - } - - return { - body: params.toString(), - headers: new Headers({ - 'Content-Type': 'application/x-www-form-urlencoded', - }), - } - } - - private serializeAsBlob(payload: Blob): Serialized { - const contentType = this.getBlobContentType(payload) - const fileName = payload instanceof File ? payload.name : 'blob' - - return { - body: payload, - headers: new Headers({ - 'Content-Type': contentType, - 'Content-Disposition': cd(fileName), - }), - } - } - - private getBlobContentType(blob: Blob): string { - return blob.type || 'application/octet-stream' - } -} - -export function preSerialize(payload: unknown): unknown { - if (payload instanceof Set) - return preSerialize([...payload]) - if (payload instanceof Map) - return preSerialize([...payload.entries()]) - if (Array.isArray(payload)) { - return payload.map(v => (v === undefined ? 'undefined' : preSerialize(v))) - } - if (Number.isNaN(payload)) - return 'NaN' - if (typeof payload === 'bigint') - return payload.toString() - if (payload instanceof Date && Number.isNaN(payload.getTime())) { - return 'Invalid Date' - } - if (payload instanceof RegExp) - return payload.toString() - if (payload instanceof URL) - return payload.toString() - if (!isPlainObject(payload)) - return payload - return Object.keys(payload).reduce( - (carry, key) => { - const val = payload[key] - carry[key] = preSerialize(val) - return carry - }, - {} as Record, - ) -} diff --git a/packages/transformer/src/orpc/deserializer.ts b/packages/transformer/src/orpc/deserializer.ts deleted file mode 100644 index 7e1720a1..00000000 --- a/packages/transformer/src/orpc/deserializer.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Deserializer } from '../types' -import { set } from '@orpc/shared' -import * as SuperJSON from './super-json' - -export class ORPCDeserializer implements Deserializer { - async deserialize(re: Request | Response): Promise { - if (re.headers.get('Content-Type')?.startsWith('multipart/form-data')) { - const form = await re.formData() - - const rawData = form.get('data') as null | string - const rawMeta = form.get('meta') as string - const rawMaps = form.get('maps') as string - - let data = rawData === null ? undefined : JSON.parse(rawData) - const meta = JSON.parse(rawMeta) as SuperJSON.JSONMeta - const maps = JSON.parse(rawMaps) as (string | number)[][] - - for (const i in maps) { - data = set(data, maps[i]!, form.get(i)) - } - - return SuperJSON.deserialize({ - data, - meta, - }) - } - - const json = await re.json() - - return SuperJSON.deserialize(json) - } -} diff --git a/packages/transformer/src/orpc/serializer.ts b/packages/transformer/src/orpc/serializer.ts deleted file mode 100644 index 3acdd3bd..00000000 --- a/packages/transformer/src/orpc/serializer.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Body, Serializer } from '../types' -import { findDeepMatches } from '@orpc/shared' -import * as SuperJSON from './super-json' - -export class ORPCSerializer implements Serializer { - serialize(payload: unknown): { body: Body, headers: Headers } { - const { data, meta } = SuperJSON.serialize(payload) - const { maps, values } = findDeepMatches(v => v instanceof Blob, data) - - if (values.length > 0) { - const form = new FormData() - - if (data !== undefined) { - form.append('data', JSON.stringify(data)) - } - - form.append('meta', JSON.stringify(meta)) - form.append('maps', JSON.stringify(maps)) - - for (const i in values) { - const value = values[i]! as Blob - form.append(i, value) - } - - return { body: form, headers: new Headers() } - } - - return { - body: JSON.stringify({ data, meta }), - headers: new Headers({ - 'Content-Type': 'application/json', - }), - } - } -} diff --git a/packages/transformer/src/types.ts b/packages/transformer/src/types.ts deleted file mode 100644 index 1d2995fe..00000000 --- a/packages/transformer/src/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -/// - -export type Body = string | FormData | Blob - -export interface Serialized { - body: Body - headers: Headers -} - -export interface Serializer { - serialize: (payload: unknown) => Serialized -} - -export type Deserialized = Promise - -export interface Deserializer { - deserialize: (re: Request | Response) => Deserialized -} diff --git a/packages/transformer/tests/openapi-human-readable.test.ts b/packages/transformer/tests/openapi-human-readable.test.ts deleted file mode 100644 index 38a6c665..00000000 --- a/packages/transformer/tests/openapi-human-readable.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { z } from 'zod' -import { OpenAPIDeserializer, OpenAPISerializer } from '../src' - -describe('openAPI transformer human readable', () => { - it('should deserialize object', async () => { - const form = new FormData() - form.append('number', '123') - form.append('user[name]', 'unnoq') - form.append('user[age]', '18') - form.append('user[gender]', 'male') - - const schema = z.object({ - number: z.number(), - user: z.object({ - name: z.string(), - age: z.number(), - gender: z.enum(['male', 'female']), - }), - }) - - const deserializer = new OpenAPIDeserializer({ schema }) - - expect( - await deserializer.deserialize( - new Request('http://localhost', { - method: 'POST', - body: form, - }), - ), - ).toEqual({ - number: 123, - user: { - name: 'unnoq', - age: 18, - gender: 'male', - }, - }) - }) - - it('should deserialize array', async () => { - const form = new FormData() - form.append('a1[0]', '10') - form.append('a1[1]', '20') - form.append('a1[2]', '30') - form.append('a2[]', '1998-01-01') - form.append('a2[]', '1999-01-01') - form.append('a2[]', '2000-01-01') - - const schema = z.object({ - a1: z.array(z.number()), - a2: z.array(z.date()), - }) - - const deserializer = new OpenAPIDeserializer({ schema }) - - expect( - await deserializer.deserialize( - new Request('http://localhost', { - method: 'POST', - body: form, - }), - ), - ).toEqual({ - a1: [10, 20, 30], - a2: [ - new Date('1998-01-01'), - new Date('1999-01-01'), - new Date('2000-01-01'), - ], - }) - }) - - it('should serialize content with file', async () => { - const file = new File(['test content'], 'test.txt', { - type: 'text/plain', - }) - - const { body } = new OpenAPISerializer().serialize({ - name: 'test', - object: { a: 1, b: 2 }, - file, - nested: { - 'file': file, - '[]file\\]': file, - }, - }) - - if (!(body instanceof FormData)) - throw new Error('body must be FormData') - - expect(body.get('file')).toBeInstanceOf(Blob) - expect(body.get('nested[file]')).toBeInstanceOf(Blob) - expect(body.get('object[a]')).toBe('1') - expect(body.get('object[b]')).toBe('2') - expect(body.get('name')).toBe('test') - expect(body.get('nested[\\[\\]file\\\\\\]]')).toBeInstanceOf(Blob) - }) -}) diff --git a/packages/transformer/tests/superjson.bench.ts b/packages/transformer/tests/superjson.bench.ts deleted file mode 100644 index e99cc53c..00000000 --- a/packages/transformer/tests/superjson.bench.ts +++ /dev/null @@ -1,73 +0,0 @@ -import SuperJSON from 'superjson' -import { bench, describe } from 'vitest' -import * as SuperJSON2 from '../src/orpc/super-json' - -describe('with simple data', () => { - const data = { - a: 1234, - b: '1234', - c: true, - d: null, - e: undefined, - f: new Date('2023-01-01'), - g: BigInt(1234), - h: [1, 2, 3], - i: new Set([1, 2, 3]), - j: new Map([ - [1, 2], - [3, 4], - ]), - k: { - a: [1, 2, 3], - b: new Set([1, 2, 3]), - }, - url: new URL('https://unnoq.com'), - regexp: /uic/gi, - } - - bench('superJSON', () => { - SuperJSON.deserialize(SuperJSON.serialize(data)) - }) - - bench('superJSON2', () => { - SuperJSON2.deserialize(SuperJSON2.serialize(data)) - }) -}) - -describe('with deep data', () => { - const data = { - object: { - o: { - o: { - a: 1234, - b: '1234', - c: true, - d: null, - e: undefined, - f: new Date('2023-01-01'), - g: BigInt(1234), - h: [1, 2, 3], - i: new Set([1, 2, 3]), - j: new Map([ - [1, 2], - [3, 4], - ]), - k: { - a: [1, 2, 3], - b: new Set([1, 2, 3]), - }, - url: new URL('https://unnoq.com'), - regexp: /uic/gi, - }, - }, - }, - } - - bench('superJSON', () => { - SuperJSON.deserialize(SuperJSON.serialize(data)) - }) - - bench('superJSON2', () => { - SuperJSON2.deserialize(SuperJSON2.serialize(data)) - }) -}) diff --git a/packages/transformer/tests/transformer.bench.ts b/packages/transformer/tests/transformer.bench.ts deleted file mode 100644 index 6c5b8872..00000000 --- a/packages/transformer/tests/transformer.bench.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { bench } from 'vitest' -import { z } from 'zod' -import { - OpenAPIDeserializer, - OpenAPISerializer, - ORPCDeserializer, - ORPCSerializer, -} from '../src' - -describe('simple data', () => { - const data = { - string: 'string', - number: 1234, - boolean: true, - null: null, - undefined, - bigint: BigInt(1234), - date: new Date('2023-01-01'), - map: new Map([ - [1, 2], - [3, 4], - ]), - set: new Set([1, 2, 3]), - array: [1, 2, 3], - object: { - a: 1, - b: 2, - c: 3, - }, - } - - const schema = z.object({ - string: z.string(), - number: z.number(), - boolean: z.boolean(), - null: z.null(), - undefined: z.undefined(), - bigint: z.bigint(), - date: z.date(), - map: z.map(z.number(), z.number()), - set: z.set(z.number()), - array: z.array(z.number()), - object: z.object({ - a: z.number(), - b: z.number(), - c: z.number(), - }), - }) - - const orpcSerializer = new ORPCSerializer() - const openapiSerializer = new OpenAPISerializer() - const orpcDeserializer = new ORPCDeserializer() - const openapiDeserializer = new OpenAPIDeserializer({ schema }) - - bench('oRPCTransformer', () => { - orpcDeserializer.deserialize( - new Request('http://localhost', { - method: 'POST', - ...orpcSerializer.serialize(data), - }), - ) - }) - - bench('openAPITransformer', () => { - openapiDeserializer.deserialize( - new Request('http://localhost', { - method: 'POST', - ...openapiSerializer.serialize(data), - }), - ) - }) -}) - -describe('with file', () => { - const data = { - string: 'string', - number: 1234, - boolean: true, - null: null, - undefined, - bigint: BigInt(1234), - date: new Date('2023-01-01'), - map: new Map([ - [1, 2], - [3, 4], - ]), - set: new Set([1, 2, 3]), - array: [1, 2, 3], - object: { - a: 1, - b: 2, - c: 3, - }, - file: new File(['"name"'], 'file.json', { - type: 'application/json', - }), - } - - const schema = z.object({ - string: z.string(), - number: z.number(), - boolean: z.boolean(), - null: z.null(), - undefined: z.undefined(), - bigint: z.bigint(), - date: z.date(), - map: z.map(z.number(), z.number()), - set: z.set(z.number()), - array: z.array(z.number()), - object: z.object({ - a: z.number(), - b: z.number(), - c: z.number(), - }), - file: z.instanceof(File), - }) - - const orpcSerializer = new ORPCSerializer() - const openapiSerializer = new OpenAPISerializer() - const orpcDeserializer = new ORPCDeserializer() - const openapiDeserializer = new OpenAPIDeserializer({ schema }) - - bench('oRPCTransformer', () => { - orpcDeserializer.deserialize( - new Request('http://localhost', { - method: 'POST', - ...orpcSerializer.serialize(data), - }), - ) - }) - - bench('openAPITransformer', () => { - openapiDeserializer.deserialize( - new Request('http://localhost', { - method: 'POST', - ...openapiSerializer.serialize(data), - }), - ) - }) -}) - -describe('with unions', () => { - const data = { - object: { - a: BigInt(1234), - b: new Date('2023-01-01'), - c: new Map([ - [1, 2], - [3, 4], - ]), - file: new File(['"name"'], 'file.json', { - type: 'application/json', - }), - }, - } - - const schema = z.object({ - object: z.union([ - z.object({ - a: z.bigint(), - b: z.date(), - c: z.map(z.number(), z.number()), - file: z.instanceof(File), - }), - z.object({ - a: z.number(), - b: z.number(), - c: z.number(), - }), - ]), - }) - - const orpcSerializer = new ORPCSerializer() - const openapiSerializer = new OpenAPISerializer() - const orpcDeserializer = new ORPCDeserializer() - const openapiDeserializer = new OpenAPIDeserializer({ schema }) - - bench('oRPCTransformer', () => { - orpcDeserializer.deserialize( - new Request('http://localhost', { - method: 'POST', - ...orpcSerializer.serialize(data), - }), - ) - }) - - bench('openAPITransformer', () => { - openapiDeserializer.deserialize( - new Request('http://localhost', { - method: 'POST', - ...openapiSerializer.serialize(data), - }), - ) - }) -}) diff --git a/packages/transformer/tests/types-support.test.ts b/packages/transformer/tests/types-support.test.ts deleted file mode 100644 index 8ec555df..00000000 --- a/packages/transformer/tests/types-support.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { oz } from '@orpc/zod' -import { object, z, type ZodType } from 'zod' -import { - type Deserializer, - OpenAPIDeserializer, - OpenAPISerializer, - ORPCDeserializer, - ORPCSerializer, - type Serializer, -} from '../src' - -const transformers: { - name: string - createSerializer: () => Serializer - createDeserializer: (schema: ZodType) => Deserializer - isFileSupport: boolean -}[] = [ - { - name: 'ORPCTransformer', - createSerializer: () => new ORPCSerializer(), - createDeserializer: () => new ORPCDeserializer(), - isFileSupport: true, - }, - { - name: 'OpenAPITransformer auto', - createSerializer: () => new OpenAPISerializer(), - createDeserializer: schema => new OpenAPIDeserializer({ schema }), - isFileSupport: true, - }, - { - name: 'OpenAPITransformer multipart/form-data', - createSerializer: () => - new OpenAPISerializer({ accept: 'multipart/form-data' }), - createDeserializer: schema => new OpenAPIDeserializer({ schema }), - isFileSupport: true, - }, - { - name: 'OpenAPITransformer application/json', - createSerializer: () => - new OpenAPISerializer({ accept: 'application/json' }), - createDeserializer: schema => new OpenAPIDeserializer({ schema }), - isFileSupport: false, - }, - { - name: 'OpenAPITransformer application/x-www-form-urlencoded', - createSerializer: () => - new OpenAPISerializer({ accept: 'application/x-www-form-urlencoded' }), - createDeserializer: schema => new OpenAPIDeserializer({ schema }), - isFileSupport: false, - }, -] - -enum Test { - A = 1, - B = 2, - C = 'C', - D = 'D', -} - -const types = [ - ['enum', z.enum(['enum', 'enum2'])], - [Test.B, z.nativeEnum(Test)], - [Test.D, z.nativeEnum(Test)], - ['string', z.string()], - ['string', z.literal('string').or(object({}))], - [1234, z.number().or(object({}))], - [1234, z.literal(1234)], - [Number.NaN, z.nan()], - [true, z.boolean()], - [true, z.literal(true)], - [false, z.boolean()], - [false, z.literal(false)], - [null, z.null()], - [null, z.literal(null)], - [undefined, z.undefined()], - [undefined, z.literal(undefined)], - [new Date('2023-01-01'), z.date()], - [new Date('Invalid'), z.date().catch(new Date('1970-01-01'))], // zod not support invalid date so we use a catch - [new Date('Invalid'), oz.invalidDate()], // zod not support invalid date so we use a catch - [BigInt(1234), z.bigint()], - [BigInt(1234), z.literal(BigInt(1234))], - [/uic/gi, oz.regexp()], - [/npa|npb/gi, oz.regexp()], - [new URL('https://unnoq.com'), oz.url()], - [ - { a: 1, b: 2, c: 3 }, - z.object({ a: z.number(), b: z.number(), c: z.number() }), - ], - [[1, 2, 3], z.array(z.number())], - [new Set([1, 2, 3]), z.set(z.number())], - [ - new Map([ - [1, 2], - [3, 4], - ]), - z.map(z.number(), z.number()), - ], - [ - { a: [1, 2, 3], b: new Set([1, 2, 3]) }, - z - .object({ a: z.array(z.number()) }) - .and(z.object({ b: z.set(z.number()) })), - ], - [ - new Map([ - [1, 2], - [3, 4], - ]), - z.map(z.number(), z.number()), - ], - [ - new File(['"name"'], 'file.json', { - type: 'application/json', - }), - z.instanceof(File), - ], - [ - new File(['content of file'], 'file.txt', { - type: 'application/octet-stream', - }), - z.instanceof(Blob), - ], - [ - new File(['content of file 2'], 'file.pdf', { type: 'application/pdf' }), - z.instanceof(Blob), - ], -] as const - -describe.each(transformers)( - '$name', - ({ createSerializer, createDeserializer, isFileSupport }) => { - it.each(types)('should work on flat: %s', async (origin, schema) => { - if (!isFileSupport && origin instanceof Blob) { - return - } - - const serializer = createSerializer() - const deserializer = createDeserializer(schema) - - const { body, headers } = serializer.serialize(origin) - - const request = new Request('http://localhost', { - method: 'POST', - headers, - body, - }) - - const data = await deserializer.deserialize(request) - - expect(data).toEqual(origin) - }) - - it.each(types)( - 'should work on nested object: %s', - async (origin, schema) => { - if (!isFileSupport && origin instanceof Blob) { - return - } - - const object = { - data: origin, - } - - const serializer = createSerializer() - const deserializer = createDeserializer(z.object({ data: schema })) - - const { body, headers } = serializer.serialize(object) - - const request = new Request('http://localhost', { - method: 'POST', - headers, - body, - }) - - const data = await deserializer.deserialize(request) - - expect(data).toEqual(object) - }, - ) - - it.each(types)( - 'should work on complex object: %s', - async (origin, schema) => { - if (!isFileSupport && origin instanceof Blob) { - return - } - - const object = { - '[]data\\]': origin, - 'list': [origin], - 'map': new Map([[origin, origin]]), - 'set': new Set([origin]), - } - - const objectSchema = z.object({ - '[]data\\]': schema, - 'list': z.array(schema), - 'map': z.map(schema, schema), - 'set': z.set(schema), - }) - - const serializer = createSerializer() - const deserializer = createDeserializer(objectSchema) - - const { body, headers } = serializer.serialize(object) - - const request = new Request('http://localhost', { - method: 'POST', - headers, - body, - }) - - const data = await deserializer.deserialize(request) - - expect(data).toEqual(object) - }, - ) - }, -) diff --git a/packages/transformer/tests/zod-coerce.bench.ts b/packages/transformer/tests/zod-coerce.bench.ts deleted file mode 100644 index c47b67f3..00000000 --- a/packages/transformer/tests/zod-coerce.bench.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { bench } from 'vitest' -import { z } from 'zod' -import { zodCoerce } from '../src/openapi/zod-coerce' - -describe('with valid data', () => { - const schema = z.number().or(z.string()) - - const data = 1234 - - bench('without zodCoerce', () => { - schema.parse(data) - }) - - bench('with zodCoerce', () => { - schema.parse(zodCoerce(schema, data)) - }) -}) - -describe('simple', () => { - const schema = z.number().optional() - - const data = '1234' - - bench('without zodCoerce', () => { - schema.safeParse(data) - }) - - bench('with zodCoerce', () => { - schema.parse(zodCoerce(schema, data, { bracketNotation: true })) - }) -}) - -describe('with unions', () => { - const schema = z.object({ - a: z.union([z.number(), z.date()]), - b: z.union([z.number(), z.date()]), - c: z - .object({ - a: z.set(z.number()), - b: z.map(z.string(), z.number()), - }) - .or( - z.object({ - b: z.number(), - }), - ), - }) - - const data = { - a: '2023-01-01', - b: '123', - c: { - a: [1, 2, 3], - b: [ - ['a', 1], - ['b', 2], - ], - }, - } - - bench('without zodCoerce', () => { - schema.safeParse(data) - }) - - bench('with zodCoerce', () => { - schema.parse(zodCoerce(schema, data, { bracketNotation: true })) - }) -}) - -describe('with deep unions', () => { - const schema = z.object({ - a: z.union([z.number(), z.date()]), - b: z.union([z.number(), z.date()]), - c: z - .object({ - a: z.union([z.object({ a: z.number() }), z.object({ a: z.date() })]), - b: z.map(z.string(), z.number()), - }) - .or(z.object({ b: z.number() })), - }) - - const data = { - a: '2023-01-01', - b: '123', - c: { - a: { a: '2023-01-01' }, - b: [ - ['a', 1], - ['b', 2], - ], - }, - } - - bench('without zodCoerce', () => { - schema.safeParse(data) - }) - - bench('with zodCoerce', () => { - schema.parse(zodCoerce(schema, data, { bracketNotation: true })) - }) -}) diff --git a/packages/transformer/tsconfig.json b/packages/transformer/tsconfig.json deleted file mode 100644 index aa78a630..00000000 --- a/packages/transformer/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.lib.json", - "compilerOptions": { - "types": [] - }, - "references": [{ "path": "../shared" }, { "path": "../zod" }], - "include": ["src"], - "exclude": [ - "**/*.test.*", - "**/*.bench.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] -} diff --git a/packages/vue-query/tests/helpers.ts b/packages/vue-query/tests/helpers.ts index 8ad44cc1..7374e4e2 100644 --- a/packages/vue-query/tests/helpers.ts +++ b/packages/vue-query/tests/helpers.ts @@ -1,6 +1,6 @@ import { createORPCFetchClient } from '@orpc/client' import { os } from '@orpc/server' -import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' +import { ORPCHandler } from '@orpc/server/fetch' import { QueryClient } from '@tanstack/vue-query' import { z } from 'zod' import { createORPCVueQueryUtils } from '../src' @@ -85,18 +85,15 @@ export const appRouter = orpcServer.router({ }, }) +const orpcHandler = new ORPCHandler(appRouter) + export const orpcClient = createORPCFetchClient({ baseURL: 'http://localhost:3000', async fetch(...args) { await new Promise(resolve => setTimeout(resolve, 100)) const request = new Request(...args) - - return handleFetchRequest({ - router: appRouter, - request, - handlers: [createORPCHandler()], - }) + return orpcHandler.fetch(request) }, }) diff --git a/packages/zod/package.json b/packages/zod/package.json index 0fad2b49..fe8f287b 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -41,6 +41,9 @@ "build:watch": "pnpm run build --watch", "type:check": "tsc -b" }, + "peerDependencies": { + "@orpc/openapi": "workspace:*" + }, "dependencies": { "json-schema-typed": "^8.0.1", "wildcard-match": "^5.1.3", diff --git a/packages/transformer/src/openapi/zod-coerce.ts b/packages/zod/src/coercer.ts similarity index 82% rename from packages/transformer/src/openapi/zod-coerce.ts rename to packages/zod/src/coercer.ts index 38b8613b..1fb8c518 100644 --- a/packages/transformer/src/openapi/zod-coerce.ts +++ b/packages/zod/src/coercer.ts @@ -1,3 +1,5 @@ +import type { Schema } from '@orpc/contract' +import type { SchemaCoercer } from '@orpc/openapi/fetch' import { guard } from '@orpc/shared' import { getCustomZodType } from '@orpc/zod' import { isPlainObject } from 'is-what' @@ -26,33 +28,23 @@ import { type ZodUnion, } from 'zod' -export interface ZodCoerceOptions { - /** - * Whether to enable fix bracket-notation limitations. - * - * @default false - */ - bracketNotation?: boolean - - /** - * Some fix rules only apply in the root - * - * @internal - * @default true - */ - isRoot?: boolean +export class ZodCoercer implements SchemaCoercer { + coerce(schema: Schema, value: unknown): unknown { + if (!schema || schema['~standard'].vendor !== 'zod') { + return value + } + + const zodSchema = schema as ZodTypeAny + const coerced = zodCoerceInternal(zodSchema, value, { bracketNotation: true }) + return coerced + } } -export function zodCoerce( +function zodCoerceInternal( schema: ZodTypeAny, value: unknown, - options?: ZodCoerceOptions, + options?: { isRoot?: boolean, bracketNotation?: boolean }, ): unknown { - if (schema['~standard'].vendor !== 'zod') { - // not support other schema yet - return value - } - const isRoot = options?.isRoot ?? true const options_ = { ...options, isRoot: false } @@ -62,11 +54,11 @@ export function zodCoerce( && Array.isArray(value) && value.length === 1 ) { - const newValue = zodCoerce(schema, value[0], options_) + const newValue = zodCoerceInternal(schema, value[0], options_) if (schema.safeParse(newValue).success) { return newValue } - return zodCoerce(schema, value, options_) + return zodCoerceInternal(schema, value, options_) } const customType = getCustomZodType(schema._def) @@ -186,7 +178,7 @@ export function zodCoerce( const schema_ = schema as ZodArray if (Array.isArray(value)) { - return value.map(v => zodCoerce(schema_._def.type, v, options_)) + return value.map(v => zodCoerceInternal(schema_._def.type, v, options_)) } if (options_?.bracketNotation) { @@ -205,7 +197,7 @@ export function zodCoerce( const arr = Array.from({ length: (indexes.at(-1) ?? -1) + 1 }) for (const i of indexes) { - arr[i] = zodCoerce(schema_._def.type, value[i], options_) + arr[i] = zodCoerceInternal(schema_._def.type, value[i], options_) } return arr @@ -230,7 +222,7 @@ export function zodCoerce( continue const v = value[k] - newObj[k] = zodCoerce( + newObj[k] = zodCoerceInternal( schema_.shape[k] ?? schema_._def.catchall, v, options_, @@ -247,7 +239,7 @@ export function zodCoerce( if (Array.isArray(value) && value.length === 1) { const emptySchema = schema_.shape[''] ?? schema_._def.catchall - return { '': zodCoerce(emptySchema, value[0], options_) } + return { '': zodCoerceInternal(emptySchema, value[0], options_) } } } } @@ -258,7 +250,7 @@ export function zodCoerce( if (Array.isArray(value)) { return new Set( - value.map(v => zodCoerce(schema_._def.valueType, v, options_)), + value.map(v => zodCoerceInternal(schema_._def.valueType, v, options_)), ) } @@ -278,7 +270,7 @@ export function zodCoerce( const arr = Array.from({ length: (indexes.at(-1) ?? -1) + 1 }) for (const i of indexes) { - arr[i] = zodCoerce(schema_._def.valueType, value[i], options_) + arr[i] = zodCoerceInternal(schema_._def.valueType, value[i], options_) } return new Set(arr) @@ -296,8 +288,8 @@ export function zodCoerce( ) { return new Map( value.map(([k, v]) => [ - zodCoerce(schema_._def.keyType, k, options_), - zodCoerce(schema_._def.valueType, v, options_), + zodCoerceInternal(schema_._def.keyType, k, options_), + zodCoerceInternal(schema_._def.valueType, v, options_), ]), ) } @@ -322,8 +314,8 @@ export function zodCoerce( if (arr.every(v => !!v)) { return new Map( arr.map(([k, v]) => [ - zodCoerce(schema_._def.keyType, k, options_), - zodCoerce(schema_._def.valueType, v, options_), + zodCoerceInternal(schema_._def.keyType, k, options_), + zodCoerceInternal(schema_._def.valueType, v, options_), ]), ) } @@ -339,8 +331,8 @@ export function zodCoerce( const newObj: any = {} for (const [k, v] of Object.entries(value)) { - const key = zodCoerce(schema_._def.keyType, k, options_) as any - const val = zodCoerce(schema_._def.valueType, v, options_) + const key = zodCoerceInternal(schema_._def.keyType, k, options_) as any + const val = zodCoerceInternal(schema_._def.valueType, v, options_) newObj[key] = val } @@ -363,7 +355,7 @@ export function zodCoerce( const results: [unknown, number][] = [] for (const s of schema_._def.options) { - const newValue = zodCoerce(s, value, { ...options_, isRoot }) + const newValue = zodCoerceInternal(s, value, { ...options_, isRoot }) if (newValue === value) continue @@ -388,9 +380,9 @@ export function zodCoerce( else if (typeName === ZodFirstPartyTypeKind.ZodIntersection) { const schema_ = schema as ZodIntersection - return zodCoerce( + return zodCoerceInternal( schema_._def.right, - zodCoerce(schema_._def.left, value, { ...options_, isRoot }), + zodCoerceInternal(schema_._def.left, value, { ...options_, isRoot }), { ...options_, isRoot }, ) } @@ -399,49 +391,49 @@ export function zodCoerce( else if (typeName === ZodFirstPartyTypeKind.ZodReadonly) { const schema_ = schema as ZodReadonly - return zodCoerce(schema_._def.innerType, value, { ...options_, isRoot }) + return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) } // else if (typeName === ZodFirstPartyTypeKind.ZodPipeline) { const schema_ = schema as ZodPipeline - return zodCoerce(schema_._def.in, value, { ...options_, isRoot }) + return zodCoerceInternal(schema_._def.in, value, { ...options_, isRoot }) } // else if (typeName === ZodFirstPartyTypeKind.ZodLazy) { const schema_ = schema as ZodLazy - return zodCoerce(schema_._def.getter(), value, { ...options_, isRoot }) + return zodCoerceInternal(schema_._def.getter(), value, { ...options_, isRoot }) } // else if (typeName === ZodFirstPartyTypeKind.ZodEffects) { const schema_ = schema as ZodEffects - return zodCoerce(schema_._def.schema, value, { ...options_, isRoot }) + return zodCoerceInternal(schema_._def.schema, value, { ...options_, isRoot }) } // else if (typeName === ZodFirstPartyTypeKind.ZodBranded) { const schema_ = schema as ZodBranded - return zodCoerce(schema_._def.type, value, { ...options_, isRoot }) + return zodCoerceInternal(schema_._def.type, value, { ...options_, isRoot }) } // else if (typeName === ZodFirstPartyTypeKind.ZodCatch) { const schema_ = schema as ZodCatch - return zodCoerce(schema_._def.innerType, value, { ...options_, isRoot }) + return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) } // else if (typeName === ZodFirstPartyTypeKind.ZodDefault) { const schema_ = schema as ZodDefault - return zodCoerce(schema_._def.innerType, value, { ...options_, isRoot }) + return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) } // @@ -456,7 +448,7 @@ export function zodCoerce( return schema_.safeParse(value).success ? value : null } - return zodCoerce(schema_._def.innerType, value, { ...options_, isRoot }) + return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) } // @@ -471,7 +463,7 @@ export function zodCoerce( return schema_.safeParse(value).success ? value : undefined } - return zodCoerce(schema_._def.innerType, value, { ...options_, isRoot }) + return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) } // diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index 8cfbe053..b0f75c87 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -1,243 +1,2 @@ -/// - -import type { JSONSchema } from 'json-schema-typed/draft-2020-12' - -import wcmatch from 'wildcard-match' -import { - custom, - type CustomErrorParams, - type input, - type output, - type ZodEffects, - type ZodType, - type ZodTypeAny, - type ZodTypeDef, -} from 'zod' - -export type CustomZodType = 'File' | 'Blob' | 'Invalid Date' | 'RegExp' | 'URL' - -const customZodTypeSymbol = Symbol('customZodTypeSymbol') - -const customZodFileMimeTypeSymbol = Symbol('customZodFileMimeTypeSymbol') - -const CUSTOM_JSON_SCHEMA_SYMBOL = Symbol('CUSTOM_JSON_SCHEMA') -const CUSTOM_JSON_SCHEMA_INPUT_SYMBOL = Symbol('CUSTOM_JSON_SCHEMA_INPUT') -const CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL = Symbol('CUSTOM_JSON_SCHEMA_OUTPUT') - -type CustomParams = CustomErrorParams & { - fatal?: boolean -} - -export function getCustomZodType(def: ZodTypeDef): CustomZodType | undefined { - return customZodTypeSymbol in def - ? (def[customZodTypeSymbol] as CustomZodType) - : undefined -} - -export function getCustomZodFileMimeType(def: ZodTypeDef): string | undefined { - return customZodFileMimeTypeSymbol in def - ? (def[customZodFileMimeTypeSymbol] as string) - : undefined -} - -export function getCustomJSONSchema( - def: ZodTypeDef, - options?: { mode?: 'input' | 'output' }, -): Exclude | undefined { - if (options?.mode === 'input' && CUSTOM_JSON_SCHEMA_INPUT_SYMBOL in def) { - return def[CUSTOM_JSON_SCHEMA_INPUT_SYMBOL] as Exclude - } - - if (options?.mode === 'output' && CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL in def) { - return def[CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL] as Exclude - } - - if (CUSTOM_JSON_SCHEMA_SYMBOL in def) { - return def[CUSTOM_JSON_SCHEMA_SYMBOL] as Exclude - } - - return undefined -} - -function composeParams(options: { - params?: string | CustomParams | ((input: T) => CustomParams) - defaultMessage?: string | ((input: T) => string) -}): (input: T) => CustomParams { - return (val) => { - const defaultMessage - = typeof options.defaultMessage === 'function' - ? options.defaultMessage(val) - : options.defaultMessage - - if (!options.params) { - return { - message: defaultMessage, - } - } - - if (typeof options.params === 'function') { - return { - message: defaultMessage, - ...options.params(val), - } - } - - if (typeof options.params === 'object') { - return { - message: defaultMessage, - ...options.params, - } - } - - return { - message: options.params, - } - } -} - -export function file( - params?: string | CustomParams | ((input: unknown) => CustomParams), -): ZodType, ZodTypeDef, InstanceType> & { - type: ( - mimeType: string, - params?: string | CustomParams | ((input: unknown) => CustomParams), - ) => ZodEffects< - ZodType, ZodTypeDef, InstanceType>, - InstanceType, - InstanceType - > -} { - const schema = custom>( - val => val instanceof File, - composeParams({ params, defaultMessage: 'Input is not a file' }), - ) - - Object.assign(schema._def, { - [customZodTypeSymbol]: 'File' satisfies CustomZodType, - }) - - return Object.assign(schema, { - type: ( - mimeType: string, - params?: string | CustomParams | ((input: unknown) => CustomParams), - ) => { - const isMatch = wcmatch(mimeType) - - const refinedSchema = schema.refine( - val => isMatch(val.type.split(';')[0]!), - composeParams>({ - params, - defaultMessage: val => - `Expected a file of type ${mimeType} but got a file of type ${val.type || 'unknown'}`, - }), - ) - - Object.assign(refinedSchema._def, { - [customZodTypeSymbol]: 'File' satisfies CustomZodType, - [customZodFileMimeTypeSymbol]: mimeType, - }) - - return refinedSchema - }, - }) -} - -export function blob( - params?: string | CustomParams | ((input: unknown) => CustomParams), -): ZodType, ZodTypeDef, InstanceType> { - const schema = custom>( - val => val instanceof Blob, - composeParams({ params, defaultMessage: 'Input is not a blob' }), - ) - - Object.assign(schema._def, { - [customZodTypeSymbol]: 'Blob' satisfies CustomZodType, - }) - - return schema -} - -export function invalidDate( - params?: string | CustomParams | ((input: unknown) => CustomParams), -): ZodType { - const schema = custom( - val => val instanceof Date && Number.isNaN(val.getTime()), - composeParams({ params, defaultMessage: 'Input is not an invalid date' }), - ) - - Object.assign(schema._def, { - [customZodTypeSymbol]: 'Invalid Date' satisfies CustomZodType, - }) - - return schema -} - -export function regexp( - options?: CustomParams, -): ZodType { - const schema = custom( - val => val instanceof RegExp, - composeParams({ params: options, defaultMessage: 'Input is not a regexp' }), - ) - - Object.assign(schema._def, { - [customZodTypeSymbol]: 'RegExp' satisfies CustomZodType, - }) - - return schema -} - -export function url(options?: CustomParams): ZodType { - const schema = custom( - val => val instanceof URL, - composeParams({ params: options, defaultMessage: 'Input is not a URL' }), - ) - - Object.assign(schema._def, { - [customZodTypeSymbol]: 'URL' satisfies CustomZodType, - }) - - return schema -} - -export function openapi< - T extends ZodTypeAny, - TMode extends 'input' | 'output' | 'both' = 'both', ->( - schema: T, - custom: Exclude< - JSONSchema< - TMode extends 'input' - ? input - : TMode extends 'output' - ? output - : input & output - >, - boolean - >, - options?: { mode: TMode }, -): ReturnType { - const newSchema = schema.refine(() => true) as ReturnType - - const SYMBOL - = options?.mode === 'input' - ? CUSTOM_JSON_SCHEMA_INPUT_SYMBOL - : options?.mode === 'output' - ? CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL - : CUSTOM_JSON_SCHEMA_SYMBOL - - Object.assign(newSchema._def, { - [SYMBOL]: custom, - }) - - return newSchema -} - -export const oz = { - openapi, - file, - blob, - invalidDate, - regexp, - url, -} +export * from './coercer' +export * from './schemas' diff --git a/packages/zod/src/schemas.ts b/packages/zod/src/schemas.ts new file mode 100644 index 00000000..d59f49d7 --- /dev/null +++ b/packages/zod/src/schemas.ts @@ -0,0 +1,243 @@ +/// + +import type { JSONSchema } from 'json-schema-typed/draft-2020-12' + +import wcmatch from 'wildcard-match' +import { + custom, + type CustomErrorParams, + type input, + type output, + type ZodEffects, + type ZodType, + type ZodTypeAny, + type ZodTypeDef, +} from 'zod' + +export type CustomZodType = 'File' | 'Blob' | 'Invalid Date' | 'RegExp' | 'URL' + +const customZodTypeSymbol = Symbol('customZodTypeSymbol') + +const customZodFileMimeTypeSymbol = Symbol('customZodFileMimeTypeSymbol') + +const CUSTOM_JSON_SCHEMA_SYMBOL = Symbol('CUSTOM_JSON_SCHEMA') +const CUSTOM_JSON_SCHEMA_INPUT_SYMBOL = Symbol('CUSTOM_JSON_SCHEMA_INPUT') +const CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL = Symbol('CUSTOM_JSON_SCHEMA_OUTPUT') + +type CustomParams = CustomErrorParams & { + fatal?: boolean +} + +export function getCustomZodType(def: ZodTypeDef): CustomZodType | undefined { + return customZodTypeSymbol in def + ? (def[customZodTypeSymbol] as CustomZodType) + : undefined +} + +export function getCustomZodFileMimeType(def: ZodTypeDef): string | undefined { + return customZodFileMimeTypeSymbol in def + ? (def[customZodFileMimeTypeSymbol] as string) + : undefined +} + +export function getCustomJSONSchema( + def: ZodTypeDef, + options?: { mode?: 'input' | 'output' }, +): Exclude | undefined { + if (options?.mode === 'input' && CUSTOM_JSON_SCHEMA_INPUT_SYMBOL in def) { + return def[CUSTOM_JSON_SCHEMA_INPUT_SYMBOL] as Exclude + } + + if (options?.mode === 'output' && CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL in def) { + return def[CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL] as Exclude + } + + if (CUSTOM_JSON_SCHEMA_SYMBOL in def) { + return def[CUSTOM_JSON_SCHEMA_SYMBOL] as Exclude + } + + return undefined +} + +function composeParams(options: { + params?: string | CustomParams | ((input: T) => CustomParams) + defaultMessage?: string | ((input: T) => string) +}): (input: T) => CustomParams { + return (val) => { + const defaultMessage + = typeof options.defaultMessage === 'function' + ? options.defaultMessage(val) + : options.defaultMessage + + if (!options.params) { + return { + message: defaultMessage, + } + } + + if (typeof options.params === 'function') { + return { + message: defaultMessage, + ...options.params(val), + } + } + + if (typeof options.params === 'object') { + return { + message: defaultMessage, + ...options.params, + } + } + + return { + message: options.params, + } + } +} + +export function file( + params?: string | CustomParams | ((input: unknown) => CustomParams), +): ZodType, ZodTypeDef, InstanceType> & { + type: ( + mimeType: string, + params?: string | CustomParams | ((input: unknown) => CustomParams), + ) => ZodEffects< + ZodType, ZodTypeDef, InstanceType>, + InstanceType, + InstanceType + > +} { + const schema = custom>( + val => val instanceof File, + composeParams({ params, defaultMessage: 'Input is not a file' }), + ) + + Object.assign(schema._def, { + [customZodTypeSymbol]: 'File' satisfies CustomZodType, + }) + + return Object.assign(schema, { + type: ( + mimeType: string, + params?: string | CustomParams | ((input: unknown) => CustomParams), + ) => { + const isMatch = wcmatch(mimeType) + + const refinedSchema = schema.refine( + val => isMatch(val.type.split(';')[0]!), + composeParams>({ + params, + defaultMessage: val => + `Expected a file of type ${mimeType} but got a file of type ${val.type || 'unknown'}`, + }), + ) + + Object.assign(refinedSchema._def, { + [customZodTypeSymbol]: 'File' satisfies CustomZodType, + [customZodFileMimeTypeSymbol]: mimeType, + }) + + return refinedSchema + }, + }) +} + +export function blob( + params?: string | CustomParams | ((input: unknown) => CustomParams), +): ZodType, ZodTypeDef, InstanceType> { + const schema = custom>( + val => val instanceof Blob, + composeParams({ params, defaultMessage: 'Input is not a blob' }), + ) + + Object.assign(schema._def, { + [customZodTypeSymbol]: 'Blob' satisfies CustomZodType, + }) + + return schema +} + +export function invalidDate( + params?: string | CustomParams | ((input: unknown) => CustomParams), +): ZodType { + const schema = custom( + val => val instanceof Date && Number.isNaN(val.getTime()), + composeParams({ params, defaultMessage: 'Input is not an invalid date' }), + ) + + Object.assign(schema._def, { + [customZodTypeSymbol]: 'Invalid Date' satisfies CustomZodType, + }) + + return schema +} + +export function regexp( + options?: CustomParams, +): ZodType { + const schema = custom( + val => val instanceof RegExp, + composeParams({ params: options, defaultMessage: 'Input is not a regexp' }), + ) + + Object.assign(schema._def, { + [customZodTypeSymbol]: 'RegExp' satisfies CustomZodType, + }) + + return schema +} + +export function url(options?: CustomParams): ZodType { + const schema = custom( + val => val instanceof URL, + composeParams({ params: options, defaultMessage: 'Input is not a URL' }), + ) + + Object.assign(schema._def, { + [customZodTypeSymbol]: 'URL' satisfies CustomZodType, + }) + + return schema +} + +export function openapi< + T extends ZodTypeAny, + TMode extends 'input' | 'output' | 'both' = 'both', +>( + schema: T, + custom: Exclude< + JSONSchema< + TMode extends 'input' + ? input + : TMode extends 'output' + ? output + : input & output + >, + boolean + >, + options?: { mode: TMode }, +): ReturnType { + const newSchema = schema.refine(() => true) as ReturnType + + const SYMBOL + = options?.mode === 'input' + ? CUSTOM_JSON_SCHEMA_INPUT_SYMBOL + : options?.mode === 'output' + ? CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL + : CUSTOM_JSON_SCHEMA_SYMBOL + + Object.assign(newSchema._def, { + [SYMBOL]: custom, + }) + + return newSchema +} + +export const oz = { + openapi, + file, + blob, + invalidDate, + regexp, + url, +} diff --git a/packages/zod/tests/coercer.test.ts b/packages/zod/tests/coercer.test.ts new file mode 100644 index 00000000..95049af2 --- /dev/null +++ b/packages/zod/tests/coercer.test.ts @@ -0,0 +1,38 @@ +import { OpenAPIServerlessHandler } from '@orpc/openapi/fetch' +import { os } from '@orpc/server' +import { z } from 'zod' +import { ZodCoercer } from '../src' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('zodCoercer', () => { + it('should coerce input', async () => { + const fn = vi.fn().mockReturnValue('__mocked__') + + const router = os.router({ + ping: os + .input(z.object({ val: z.bigint() })) + .func(fn), + }) + + const handler = new OpenAPIServerlessHandler(router, { + schemaCoercers: [ + new ZodCoercer(), + ], + }) + const res = await handler.fetch(new Request('https://example.com/ping', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + val: '123', + }), + })) + + expect(res.status).toBe(200) + expect(fn).toHaveBeenCalledWith({ val: 123n }, undefined, expect.any(Object)) + }) +}) diff --git a/packages/zod/tsconfig.json b/packages/zod/tsconfig.json index b64920aa..a1c619db 100644 --- a/packages/zod/tsconfig.json +++ b/packages/zod/tsconfig.json @@ -3,7 +3,9 @@ "compilerOptions": { "types": [] }, - "references": [], + "references": [ + { "path": "../openapi" } + ], "include": ["src"], "exclude": [ "**/*.test.*", diff --git a/playgrounds/contract-openapi/package.json b/playgrounds/contract-openapi/package.json index 0a36b17e..e47892e7 100644 --- a/playgrounds/contract-openapi/package.json +++ b/playgrounds/contract-openapi/package.json @@ -5,7 +5,8 @@ "private": true, "scripts": { "dev": "tsx --watch src/main.ts", - "start": "tsx src/main.ts" + "start": "tsx src/main.ts", + "type:check": "tsc --noEmit" }, "dependencies": { "@orpc/client": "latest", diff --git a/playgrounds/contract-openapi/src/main.ts b/playgrounds/contract-openapi/src/main.ts index 81df1830..f0f3c84d 100644 --- a/playgrounds/contract-openapi/src/main.ts +++ b/playgrounds/contract-openapi/src/main.ts @@ -1,12 +1,28 @@ import { createServer } from 'node:http' import { generateOpenAPI } from '@orpc/openapi' -import { createOpenAPIServerHandler } from '@orpc/openapi/fetch' -import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' +import { OpenAPIServerHandler } from '@orpc/openapi/fetch' +import { CompositeHandler, ORPCHandler } from '@orpc/server/fetch' +import { ZodCoercer } from '@orpc/zod' import { createServerAdapter } from '@whatwg-node/server' import { contract } from './contract' import { router } from './router' import './polyfill' +const openAPIHandler = new OpenAPIServerHandler(router, { + schemaCoercers: [ + new ZodCoercer(), + ], + onError: ({ error }) => { + console.error(error) + }, +}) +const orpcHandler = new ORPCHandler(router, { + onError: ({ error }) => { + console.error(error) + }, +}) +const compositeHandler = new CompositeHandler([openAPIHandler, orpcHandler]) + const server = createServer( createServerAdapter(async (request: Request) => { const url = new URL(request.url) @@ -16,15 +32,9 @@ const server = createServer( : {} if (url.pathname.startsWith('/api')) { - return handleFetchRequest({ - router, - request, + return compositeHandler.fetch(request, { prefix: '/api', context, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - onError: ({ error }) => { - console.error(error) - }, }) } diff --git a/playgrounds/expressjs/package.json b/playgrounds/expressjs/package.json index 3429ec6e..657db21f 100644 --- a/playgrounds/expressjs/package.json +++ b/playgrounds/expressjs/package.json @@ -5,7 +5,8 @@ "private": true, "scripts": { "dev": "tsx --watch src/main.ts", - "start": "tsx src/main.ts" + "start": "tsx src/main.ts", + "type:check": "tsc --noEmit" }, "dependencies": { "@orpc/client": "latest", diff --git a/playgrounds/expressjs/src/main.ts b/playgrounds/expressjs/src/main.ts index 39eb25d4..53dd322e 100644 --- a/playgrounds/expressjs/src/main.ts +++ b/playgrounds/expressjs/src/main.ts @@ -1,6 +1,7 @@ import { generateOpenAPI } from '@orpc/openapi' -import { createOpenAPIServerHandler } from '@orpc/openapi/fetch' -import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' +import { OpenAPIServerHandler } from '@orpc/openapi/fetch' +import { CompositeHandler, ORPCHandler } from '@orpc/server/fetch' +import { ZodCoercer } from '@orpc/zod' import { createServerAdapter } from '@whatwg-node/server' import express from 'express' import { router } from './router' @@ -8,6 +9,21 @@ import './polyfill' const app = express() +const openAPIHandler = new OpenAPIServerHandler(router, { + schemaCoercers: [ + new ZodCoercer(), + ], + onError: ({ error }) => { + console.error(error) + }, +}) +const orpcHandler = new ORPCHandler(router, { + onError: ({ error }) => { + console.error(error) + }, +}) +const compositeHandler = new CompositeHandler([openAPIHandler, orpcHandler]) + app.all( '/api/*', createServerAdapter((request: Request) => { @@ -15,15 +31,9 @@ app.all( ? { user: { id: 'test', name: 'John Doe', email: 'john@doe.com' } } : {} - return handleFetchRequest({ - request, + return compositeHandler.fetch(request, { prefix: '/api', context, - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - onError: ({ error }) => { - console.error(error) - }, }) }), ) diff --git a/playgrounds/nextjs/package.json b/playgrounds/nextjs/package.json index 6a465134..e727d770 100644 --- a/playgrounds/nextjs/package.json +++ b/playgrounds/nextjs/package.json @@ -6,7 +6,8 @@ "dev": "next dev --port 3000", "build": "next build", "start": "next start --port 3000", - "lint": "next lint" + "lint": "next lint", + "type:check": "tsc --noEmit" }, "dependencies": { "@orpc/client": "latest", diff --git a/playgrounds/nextjs/src/actions/planet.ts b/playgrounds/nextjs/src/actions/planet.ts index 668f0425..6205d338 100644 --- a/playgrounds/nextjs/src/actions/planet.ts +++ b/playgrounds/nextjs/src/actions/planet.ts @@ -2,7 +2,7 @@ import { createFormAction } from '@orpc/next' import { ORPCError } from '@orpc/server' -import { oz } from '@orpc/zod' +import { oz, ZodCoercer } from '@orpc/zod' import { z } from 'zod' import { planets } from '../data/planet' import { authed, pub } from '../orpc' @@ -45,6 +45,7 @@ export const createPlanet = authed export const createPlanetFA = createFormAction({ procedure: createPlanet, + schemaCoercers: [new ZodCoercer()], onSuccess(output) { // redirect('/planets') }, diff --git a/playgrounds/nextjs/src/app/api/[...rest]/route.ts b/playgrounds/nextjs/src/app/api/[...rest]/route.ts index bac44f53..c648e307 100644 --- a/playgrounds/nextjs/src/app/api/[...rest]/route.ts +++ b/playgrounds/nextjs/src/app/api/[...rest]/route.ts @@ -1,13 +1,27 @@ -import { createOpenAPIServerHandler } from '@orpc/openapi/fetch' -import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' +import { OpenAPIServerlessHandler } from '@orpc/openapi/fetch' +import { CompositeHandler, ORPCHandler } from '@orpc/server/fetch' +import { ZodCoercer } from '@orpc/zod' import { router } from './router' +import '../../../polyfill' + +const openAPIHandler = new OpenAPIServerlessHandler(router, { + schemaCoercers: [ + new ZodCoercer(), + ], + onError: ({ error }) => { + console.error(error) + }, +}) +const orpcHandler = new ORPCHandler(router, { + onError: ({ error }) => { + console.error(error) + }, +}) +const compositeHandler = new CompositeHandler([openAPIHandler, orpcHandler]) export function GET(request: Request) { - return handleFetchRequest({ - router, - request, + return compositeHandler.fetch(request, { prefix: '/api', - handlers: [createORPCHandler(), createOpenAPIServerHandler()], }) } diff --git a/playgrounds/nextjs/src/orpc.ts b/playgrounds/nextjs/src/orpc.ts index 8a855608..bf6bdc73 100644 --- a/playgrounds/nextjs/src/orpc.ts +++ b/playgrounds/nextjs/src/orpc.ts @@ -1,7 +1,6 @@ import type { z } from 'zod' import type { UserSchema } from './schemas/user' import { ORPCError, os } from '@orpc/server' -import './polyfill' const base = os .use(async (input, context, meta) => { diff --git a/playgrounds/nuxt/app.vue b/playgrounds/nuxt/app.vue index 08ad3632..ebf5b2fd 100644 --- a/playgrounds/nuxt/app.vue +++ b/playgrounds/nuxt/app.vue @@ -1,5 +1,14 @@