diff --git a/apps/content/content/docs/client/react-query.mdx b/apps/content/content/docs/client/react-query.mdx index 7aad76bfb..354b4c48e 100644 --- a/apps/content/content/docs/client/react-query.mdx +++ b/apps/content/content/docs/client/react-query.mdx @@ -15,11 +15,11 @@ description: Simplify React Query usage with minimal integration using ORPC and ```ts twoslash import { createORPCReactQueryUtils } from '@orpc/react-query'; -import { createORPCClient } from '@orpc/client'; +import { createORPCFetchClient } from '@orpc/client'; import type { router } from 'examples/server'; // Create an ORPC client -export const client = createORPCClient({ +export const client = createORPCFetchClient({ baseURL: 'http://localhost:3000/api', }); @@ -35,12 +35,13 @@ orpc.getting. ```tsx twoslash import { createORPCReactQueryUtils, RouterUtils } from '@orpc/react-query'; -import { createORPCClient } from '@orpc/client'; +import { createORPCFetchClient } from '@orpc/client'; +import { RouterClient } from '@orpc/server'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { router } from 'examples/server'; import * as React from 'react'; -const ORPCContext = React.createContext | undefined>(undefined); +const ORPCContext = React.createContext> | undefined>(undefined); export function useORPC() { const orpc = React.useContext(ORPCContext); @@ -54,7 +55,7 @@ export function useORPC() { export function ORPCProvider({ children }: { children: React.ReactNode }) { const [client] = React.useState(() => - createORPCClient({ + createORPCFetchClient({ baseURL: 'http://localhost:3000/api', }) ); diff --git a/apps/content/content/docs/client/react.mdx b/apps/content/content/docs/client/react.mdx index 8f1d039e4..ba0eea679 100644 --- a/apps/content/content/docs/client/react.mdx +++ b/apps/content/content/docs/client/react.mdx @@ -13,16 +13,17 @@ npm i @orpc/client @orpc/react @tanstack/react-query ```tsx twoslash import { createORPCReact } from '@orpc/react' -import { createORPCClient } from '@orpc/client' +import { createORPCFetchClient } from '@orpc/client' +import { RouterClient } from '@orpc/server' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useState } from 'react' import type { router } from 'examples/server' import * as React from 'react' -export const { orpc, ORPCContext } = createORPCReact() +export const { orpc, ORPCContext } = createORPCReact>() export function ORPCProvider({ children }: { children: React.ReactNode }) { - const [client] = useState(() => createORPCClient({ + const [client] = useState(() => createORPCFetchClient({ baseURL: 'http://localhost:3000/api', })) const [queryClient] = useState(() => new QueryClient()) diff --git a/apps/content/content/docs/client/vanilla.mdx b/apps/content/content/docs/client/vanilla.mdx index 24d829d10..68feac97a 100644 --- a/apps/content/content/docs/client/vanilla.mdx +++ b/apps/content/content/docs/client/vanilla.mdx @@ -14,10 +14,10 @@ npm i @orpc/client To create a fully typed client, you need either the type of the [router](/docs/server/router) you intend to use or the [contract](/docs/contract/builder). ```ts twoslash -import { createORPCClient, ORPCError } from '@orpc/client' +import { createORPCFetchClient, ORPCError } from '@orpc/client' import type { router } from 'examples/server' -const client = createORPCClient({ +const client = createORPCFetchClient({ baseURL: 'http://localhost:3000/api', // fetch: optional override for the default fetch function // headers: provide additional headers diff --git a/apps/content/content/docs/client/vue-query.mdx b/apps/content/content/docs/client/vue-query.mdx index 1fc476b8c..88f555352 100644 --- a/apps/content/content/docs/client/vue-query.mdx +++ b/apps/content/content/docs/client/vue-query.mdx @@ -13,11 +13,11 @@ description: Simplify Vue Query usage with minimal integration using ORPC and Ta ```ts twoslash import { createORPCVueQueryUtils } from '@orpc/vue-query'; -import { createORPCClient } from '@orpc/client'; +import { createORPCFetchClient } from '@orpc/client'; import type { router } from 'examples/server'; // Create an ORPC client -export const client = createORPCClient({ +export const client = createORPCFetchClient({ baseURL: 'http://localhost:3000/api', }); diff --git a/apps/content/content/docs/contract-first.mdx b/apps/content/content/docs/contract-first.mdx index 9e31d8372..adbd5b411 100644 --- a/apps/content/content/docs/contract-first.mdx +++ b/apps/content/content/docs/contract-first.mdx @@ -129,10 +129,10 @@ That's it! The contract definition and implementation are now completely separat Create a fully typed client using just the contract definition: ```ts twoslash -import { createORPCClient, ORPCError } from '@orpc/client' +import { createORPCFetchClient, ORPCError } from '@orpc/client' import type { contract } from 'examples/contract' -const client = createORPCClient({ +const client = createORPCFetchClient({ baseURL: 'http://localhost:3000/prefix', // fetch: optional override for the default fetch function // headers: provide additional headers diff --git a/apps/content/content/docs/index.mdx b/apps/content/content/docs/index.mdx index 6668df92c..30a136e27 100644 --- a/apps/content/content/docs/index.mdx +++ b/apps/content/content/docs/index.mdx @@ -184,10 +184,10 @@ Start the server and visit http://localhost:3000/api/getting?name=yourname to se Use the fully typed client in any environment: ```ts twoslash -import { createORPCClient, ORPCError } from '@orpc/client' +import { createORPCFetchClient, ORPCError } from '@orpc/client' import type { router } from 'examples/server' -const client = createORPCClient({ +const client = createORPCFetchClient({ baseURL: 'http://localhost:3000/api', // fetch: optional override for the default fetch function // headers: provide additional headers diff --git a/apps/content/content/docs/server/caller.mdx b/apps/content/content/docs/server/client.mdx similarity index 75% rename from apps/content/content/docs/server/caller.mdx rename to apps/content/content/docs/server/client.mdx index 3331b9808..38b4e6c9c 100644 --- a/apps/content/content/docs/server/caller.mdx +++ b/apps/content/content/docs/server/client.mdx @@ -1,5 +1,5 @@ --- -title: Caller +title: Caller/Client description: Make your procedures callable in oRPC. --- @@ -9,7 +9,7 @@ You can directly call a procedure if its [Global Context](/docs/server/global-co For security reasons, context cannot be passed when invoking such procedures directly. ```ts twoslash -import { os, createProcedureCaller } from '@orpc/server' +import { os, createProcedureClient } from '@orpc/server' import { z } from 'zod' // ❌ Cannot call this procedure directly because undefined is not assignable to 'Context' @@ -36,17 +36,17 @@ const output_ = await router.getting({ name: 'World' }) // output is 'Hello, Wor ## Calling Procedures with Context -For context-sensitive calls, use a Procedure Caller. -A Procedure Caller securely provides the required context during invocation. +For context-sensitive calls, use a Procedure Client. +A Procedure Client securely provides the required context during invocation. ```ts twoslash -import { os, createProcedureCaller } from '@orpc/server' +import { os, createProcedureClient } from '@orpc/server' type Context = { user?: { id: string } } const getting = os.context().func(() => 'pong') -const gettingCaller = createProcedureCaller({ +const gettingClient = createProcedureClient({ procedure: getting, context: async () => { // you can access headers, cookies, etc. here to create context @@ -54,35 +54,35 @@ const gettingCaller = createProcedureCaller({ }, }) -const output = await gettingCaller() // output is 'pong' +const output = await gettingClient() // output is 'pong' ``` Now, you can provide context when invoking a procedure. -Additionally, you can use `gettingCaller` as a [Server Action](/docs/server/server-action). +Additionally, you can use `gettingClient` as a [Server Action](/docs/server/server-action). ## Calling Routers with Shared Context -To call multiple procedures with shared context, use a `Router Caller`. +To call multiple procedures with shared context, use a `Router Client`. ```ts twoslash -import { os, createRouterCaller } from '@orpc/server' +import { os, createRouterClient } from '@orpc/server' const router = os.router({ ping: os.func(() => 'pong') }) -const caller = createRouterCaller({ +const client = createRouterClient({ router: router, context: {}, }) -const result = await caller.ping() // result is 'pong' +const result = await client.ping() // result is 'pong' ``` ## Summary - **Direct Calls:** Use when no context is required, or the context accepts `undefined`. -- **Procedure Caller:** Use for securely calling a single procedure with a specific context. -- **Router Caller:** Use for securely calling multiple procedures with shared context. +- **Procedure Client:** Use for securely calling a single procedure with a specific context. +- **Router Client:** Use for securely calling multiple procedures with shared context. oRPC provides flexible and secure ways to invoke procedures tailored to your application needs. \ No newline at end of file diff --git a/apps/content/content/docs/server/context.mdx b/apps/content/content/docs/server/context.mdx index bb468f613..13c9c4f60 100644 --- a/apps/content/content/docs/server/context.mdx +++ b/apps/content/content/docs/server/context.mdx @@ -42,7 +42,7 @@ export const router = pub.router({ Middleware context is the context that is created or modified by middleware. If your procedure only depends on `Middleware Context`, you can -[call it](/docs/server/caller) or use it as a [Server Action](/docs/server/server-action) directly. +[call it](/docs/server/client) or use it as a [Server Action](/docs/server/server-action) directly. ```ts twoslash import { os, ORPCError } from '@orpc/server' @@ -105,7 +105,7 @@ This pattern is useful for server-side applications where dependencies can be in rather than relying on global mechanisms like `headers` or `cookies` in Next.js. ```ts twoslash -import { os, ORPCError, createProcedureCaller } from '@orpc/server' +import { os, ORPCError, createProcedureClient } from '@orpc/server' import { handleFetchRequest, createORPCHandler } from '@orpc/server/fetch' import { createOpenAPIServerlessHandler, createOpenAPIServerHandler } from '@orpc/openapi/fetch' @@ -147,8 +147,8 @@ export function fetch(request: Request) { } // If you want to call this procedure or use as server action -// you must create another caller with context by using `createProcedureCaller` or `createRouterCaller` -const caller = createProcedureCaller({ +// you must create another client with context by using `createProcedureClient` or `createRouterClient` +const client = createProcedureClient({ procedure: router.getting, context: async () => { // some logic to create context @@ -159,7 +159,7 @@ const caller = createProcedureCaller({ }, }) -const output = await caller() +const output = await client() ``` ## Summary diff --git a/apps/content/content/docs/server/file-upload.mdx b/apps/content/content/docs/server/file-upload.mdx index d447aaca6..31b62b6e2 100644 --- a/apps/content/content/docs/server/file-upload.mdx +++ b/apps/content/content/docs/server/file-upload.mdx @@ -63,9 +63,9 @@ To upload files with oRPC from the client, set up an oRPC client and pass a `File` object directly to the upload endpoint. ```typescript -import { createORPCClient } from '@orpc/client' +import { createORPCFetchClient } from '@orpc/client' -const client = createORPCClient({ +const client = createORPCFetchClient({ baseURL: 'http://localhost:3000', }) diff --git a/apps/content/content/docs/server/lazy.mdx b/apps/content/content/docs/server/lazy.mdx index bc4fba770..f3b49d986 100644 --- a/apps/content/content/docs/server/lazy.mdx +++ b/apps/content/content/docs/server/lazy.mdx @@ -16,7 +16,7 @@ Here's how you can set up and use them: ```typescript twoslash import { os } from '@orpc/server' -const pub = os.context<{ user?: { id: string } }>() +const pub = os.context<{ user?: { id: string } } | undefined>() // Define a router with lazy loading const router = pub.router({ diff --git a/apps/content/content/docs/server/meta.json b/apps/content/content/docs/server/meta.json index 91511bec5..287bdb8b1 100644 --- a/apps/content/content/docs/server/meta.json +++ b/apps/content/content/docs/server/meta.json @@ -8,7 +8,7 @@ "file-upload", "lazy", "server-action", - "caller", + "client", "error-handling", "data-types", "integrations", diff --git a/apps/content/content/docs/server/server-action.mdx b/apps/content/content/docs/server/server-action.mdx index 0ba3fe214..2cf987fd0 100644 --- a/apps/content/content/docs/server/server-action.mdx +++ b/apps/content/content/docs/server/server-action.mdx @@ -9,7 +9,7 @@ oRPC makes it simple to implement server actions, offering a robust and type-saf Server actions are supported out of the box and are powered by several key features: - [Middleware](/docs/server/middleware), -- [Procedure Caller](/docs/server/caller) +- [Procedure Client](/docs/server/client) - [Smart Conversion](/docs/openapi/smart-conversion) - [Bracket Notation](/docs/openapi/bracket-notation), @@ -17,8 +17,8 @@ Server actions are supported out of the box and are powered by several key featu To use a procedure as a server action, the procedure must either: -1. Be [directly callable](/docs/server/caller#direct-procedure-calls), or -2. Use [Calling Procedures with Context](/docs/server/caller#calling-procedures-with-context) to create a callable procedure with context. +1. Be [directly callable](/docs/server/client#direct-procedure-calls), or +2. Use [Calling Procedures with Context](/docs/server/client#calling-procedures-with-context) to create a callable procedure with context. ## Usage @@ -172,13 +172,13 @@ automatically convert `1992` into a `bigint` and seamlessly parse objects like ` Some procedures cannot be used as server actions directly. This is typically because they require additional context, such as user information or other runtime data. -In such cases, you can use [createProcedureCaller](/docs/server/caller#calling-procedures-with-context) -or `createSafeAction` and `createFormAction` (built on top of `createProcedureCaller`) +In such cases, you can use [createProcedureClient](/docs/server/client#calling-procedures-with-context) +or `createSafeAction` and `createFormAction` (built on top of `createProcedureClient`) to provide the required context dynamically, making the procedure callable and usable as a server action. ```ts twoslash import { createSafeAction, createFormAction } from '@orpc/next' -import { createProcedureCaller, os } from '@orpc/server' +import { createProcedureClient, os } from '@orpc/server' import { z } from 'zod' type Context = { user?: { id: string } } @@ -191,7 +191,7 @@ const getting = os // @errors: 2349 getting({ name: 'Unnoq' }) // ❌ cannot call this procedure directly, and cannot be used as a server action -export const caller = createProcedureCaller({ // or createSafeAction or createFormAction +export const client = createProcedureClient({ // or createSafeAction or createFormAction procedure: getting, context: async () => { // you can access headers, cookies, etc. here to create context @@ -199,7 +199,7 @@ export const caller = createProcedureCaller({ // or createSafeAction or createFo }, }) -caller({ name: 'Unnoq' }) // ✅ can call this procedure directly, and can be used as a server action +client({ name: 'Unnoq' }) // ✅ can call this procedure directly, and can be used as a server action ``` This flexibility ensures you can adapt server actions to scenarios requiring runtime information, enhancing usability across diverse use cases. \ No newline at end of file diff --git a/apps/content/content/home/client.mdx b/apps/content/content/home/client.mdx index e7f7ed61f..462714709 100644 --- a/apps/content/content/home/client.mdx +++ b/apps/content/content/home/client.mdx @@ -1,10 +1,10 @@ ```ts twoslash -import { createORPCClient, ORPCError } from '@orpc/client' +import { createORPCFetchClient, ORPCError } from '@orpc/client' import type { router } from 'examples/server' -const client = createORPCClient({ +const client = createORPCFetchClient({ baseURL: 'http://localhost:3000/api', // fetch: optional override for the default fetch function // headers: provide additional headers @@ -44,13 +44,14 @@ try { ```tsx twoslash import { createORPCReact } from '@orpc/react' -import { createORPCClient } from '@orpc/client' +import { createORPCFetchClient } from '@orpc/client' +import { RouterClient } from '@orpc/server' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useState } from 'react' import type { router } from 'examples/server' import * as React from 'react' -export const { orpc, ORPCContext } = createORPCReact() +export const { orpc, ORPCContext } = createORPCReact>() // ------------------ Example ------------------ @@ -117,7 +118,7 @@ const queries = orpc.useQueries(o => [ // ------------------ Provider ------------------ export function ORPCProvider({ children }: { children: React.ReactNode }) { - const [client] = useState(() => createORPCClient({ + const [client] = useState(() => createORPCFetchClient({ baseURL: 'http://localhost:3000/api', })) const [queryClient] = useState(() => new QueryClient()) diff --git a/apps/content/content/home/landing.mdx b/apps/content/content/home/landing.mdx index 638fc89cd..098d4bfa1 100644 --- a/apps/content/content/home/landing.mdx +++ b/apps/content/content/home/landing.mdx @@ -4,7 +4,6 @@ export const getting = os .use(authMiddleware) // require auth .use(cache('5m')) // cache the output - .use(canMiddleware, (i) => i.id) // permission check by id .route({ path: '/getting/{id}' // dynamic params support method: 'POST' // custom OpenAPI method @@ -16,6 +15,7 @@ export const getting = os avatar: oz.file().type('image/*') }) })) + .use(canMiddleware, (i) => i.id) // permission check by id .output(z.string()) // validate output .func(async (input) => 'Name and Avatar has been updated') ``` @@ -38,7 +38,7 @@ const text = await getting({ }) ``` -The [Procedure Caller](/docs/server/caller) feature lets your procedures behave like regular TypeScript functions. +The [Procedure Client](/docs/server/client) feature lets your procedures behave like regular TypeScript functions. ## Expose It Online with a Fully Typed Client diff --git a/apps/content/examples/react-query.ts b/apps/content/examples/react-query.ts index d3b20b7ca..74ac0a88c 100644 --- a/apps/content/examples/react-query.ts +++ b/apps/content/examples/react-query.ts @@ -1,4 +1,5 @@ +import type { RouterClient } from '@orpc/server' import type { router } from 'examples/server' import { createORPCReactQueryUtils } from '@orpc/react-query' -export const orpc = createORPCReactQueryUtils('fake-client' as any) +export const orpc = createORPCReactQueryUtils({} as RouterClient) diff --git a/apps/content/examples/react.ts b/apps/content/examples/react.ts index bde0496b1..516262c77 100644 --- a/apps/content/examples/react.ts +++ b/apps/content/examples/react.ts @@ -1,6 +1,5 @@ +import type { RouterClient } from '@orpc/server' import type { router } from 'examples/server' import { createORPCReact } from '@orpc/react' -// biome-ignore lint/correctness/noUnusedImports: -export const { orpc, ORPCContext } - = createORPCReact() +export const { orpc, ORPCContext } = createORPCReact>() diff --git a/apps/content/examples/server.ts b/apps/content/examples/server.ts index 720ce74bd..33094fe8c 100644 --- a/apps/content/examples/server.ts +++ b/apps/content/examples/server.ts @@ -3,7 +3,7 @@ import { ORPCError, os } from '@orpc/server' import { oz } from '@orpc/zod' import { z } from 'zod' -export type Context = { user?: { id: string } } +export type Context = { user?: { id: string } } | undefined // global pub, authed completely optional export const pub /** public access */ = os.context() @@ -41,7 +41,7 @@ export const router = pub.router({ }), ) .use(async (input, context, meta) => { - if (!context.user) { + if (!context?.user) { throw new ORPCError({ code: 'UNAUTHORIZED', }) diff --git a/apps/content/examples/vue-query.ts b/apps/content/examples/vue-query.ts index 71dee1231..29a0fcb4c 100644 --- a/apps/content/examples/vue-query.ts +++ b/apps/content/examples/vue-query.ts @@ -1,4 +1,5 @@ +import type { RouterClient } from '@orpc/server' import type { router } from 'examples/server' import { createORPCVueQueryUtils } from '@orpc/vue-query' -export const orpc = createORPCVueQueryUtils('fake-client' as any) +export const orpc = createORPCVueQueryUtils({} as RouterClient) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 01fcd4e44..90be18871 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,9 +1,9 @@ /** unnoq */ -import { createRouterClient } from './router' +import { createRouterFetchClient } from './router-fetch-client' -export * from './procedure' -export * from './router' +export * from './procedure-fetch-client' +export * from './router-fetch-client' export * from '@orpc/shared/error' -export const createORPCClient = createRouterClient +export const createORPCFetchClient = createRouterFetchClient diff --git a/packages/client/src/procedure-fetch-client.test-d.ts b/packages/client/src/procedure-fetch-client.test-d.ts new file mode 100644 index 000000000..54598e131 --- /dev/null +++ b/packages/client/src/procedure-fetch-client.test-d.ts @@ -0,0 +1,13 @@ +import type { ProcedureClient } from '@orpc/server' +import { createProcedureFetchClient } from './procedure-fetch-client' + +describe('procedure fetch client', () => { + it('just a client', () => { + const client = createProcedureFetchClient({ + baseURL: 'http://localhost:3000/orpc', + path: ['ping'], + }) + + expectTypeOf(client).toEqualTypeOf>() + }) +}) diff --git a/packages/client/src/procedure.test.ts b/packages/client/src/procedure-fetch-client.test.ts similarity index 89% rename from packages/client/src/procedure.test.ts rename to packages/client/src/procedure-fetch-client.test.ts index 3b4647705..46e32fe1e 100644 --- a/packages/client/src/procedure.test.ts +++ b/packages/client/src/procedure-fetch-client.test.ts @@ -1,5 +1,5 @@ import { ORPCDeserializer, ORPCSerializer } from '@orpc/transformer' -import { createProcedureClient } from './procedure' +import { createProcedureFetchClient } from './procedure-fetch-client' vi.mock('@orpc/transformer', () => ({ ORPCSerializer: vi.fn().mockReturnValue({ serialize: vi.fn() }), @@ -10,7 +10,7 @@ beforeEach(() => { vi.clearAllMocks() }) -describe('procedure client', () => { +describe('procedure fetch client', () => { const serialize = (ORPCSerializer as any)().serialize const deserialize = (ORPCDeserializer as any)().deserialize const response = new Response('output') @@ -22,7 +22,7 @@ describe('procedure client', () => { deserialize.mockReturnValue('transformed_output') it('works', async () => { - const client = createProcedureClient({ + const client = createProcedureFetchClient({ baseURL: 'http://localhost:3000/orpc', path: ['ping'], fetch: fakeFetch, @@ -50,7 +50,7 @@ describe('procedure client', () => { async () => new Headers({ 'x-test': 'hello' }), async () => ({ 'x-test': 'hello' }), ])('works with headers', async (headers) => { - const client = createProcedureClient({ + const client = createProcedureFetchClient({ path: ['ping'], baseURL: 'http://localhost:3000/orpc', fetch: fakeFetch, @@ -72,7 +72,7 @@ describe('procedure client', () => { const controller = new AbortController() const signal = controller.signal - const client = createProcedureClient({ + const client = createProcedureFetchClient({ path: ['ping'], baseURL: 'http://localhost:3000/orpc', fetch: fakeFetch, diff --git a/packages/client/src/procedure.ts b/packages/client/src/procedure-fetch-client.ts similarity index 83% rename from packages/client/src/procedure.ts rename to packages/client/src/procedure-fetch-client.ts index 010aebb60..073b386b7 100644 --- a/packages/client/src/procedure.ts +++ b/packages/client/src/procedure-fetch-client.ts @@ -1,7 +1,4 @@ -/// -/// - -import type { Caller } from '@orpc/server' +import type { ProcedureClient } from '@orpc/server' import type { Promisable } from '@orpc/shared' import { ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_VALUE, trim } from '@orpc/shared' import { ORPCError } from '@orpc/shared/error' @@ -34,13 +31,11 @@ export interface CreateProcedureClientOptions { const serializer = new ORPCSerializer() const deserializer = new ORPCDeserializer() -export function createProcedureClient( +export function createProcedureFetchClient( options: CreateProcedureClientOptions, -): Caller { - const client: Caller = async (...args) => { - const [input, callerOptions] = args - - const fetch_ = options.fetch ?? fetch +): ProcedureClient { + const client: ProcedureClient = async (...[input, callerOptions]) => { + const fetchClient = options.fetch ?? fetch const url = `${trim(options.baseURL, '/')}/${options.path.map(encodeURIComponent).join('/')}` const headers = new Headers({ @@ -59,7 +54,7 @@ export function createProcedureClient( headers.append(key, value) } - const response = await fetch_(url, { + const response = await fetchClient(url, { method: 'POST', headers, body: serialized.body, @@ -90,8 +85,8 @@ export function createProcedureClient( ) } - return json + return json as any } - return client as any + return client } diff --git a/packages/client/src/procedure.test-d.ts b/packages/client/src/procedure.test-d.ts deleted file mode 100644 index f520fb340..000000000 --- a/packages/client/src/procedure.test-d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Caller } from '@orpc/server' -import { createProcedureClient } from './procedure' - -describe('procedure client', () => { - it('just a caller', () => { - const client = createProcedureClient({ - baseURL: 'http://localhost:3000/orpc', - path: ['ping'], - }) - - expectTypeOf(client).toEqualTypeOf>() - }) -}) diff --git a/packages/client/src/router.test-d.ts b/packages/client/src/router-fetch-client.test-d.ts similarity index 59% rename from packages/client/src/router.test-d.ts rename to packages/client/src/router-fetch-client.test-d.ts index 37cac7c20..1ffa7c682 100644 --- a/packages/client/src/router.test-d.ts +++ b/packages/client/src/router-fetch-client.test-d.ts @@ -1,10 +1,10 @@ -import type { Caller } from '@orpc/server' +import type { ProcedureClient } from '@orpc/server' import { oc } from '@orpc/contract' import { os } from '@orpc/server' import { z } from 'zod' -import { createRouterClient } from './router' +import { createRouterFetchClient } from './router-fetch-client' -describe('router client', () => { +describe('router fetch client', () => { const pingContract = oc .input(z.object({ in: z.string() }).transform(i => i.in)) .output(z.string().transform(out => ({ out }))) @@ -28,20 +28,20 @@ describe('router client', () => { }) it('build correct types with contract router', () => { - const client = createRouterClient({ + const client = createRouterFetchClient({ baseURL: 'http://localhost:3000/orpc', }) - expectTypeOf(client.ping).toMatchTypeOf>() - expectTypeOf(client.nested.pong).toMatchTypeOf>() + expectTypeOf(client.ping).toMatchTypeOf>() + expectTypeOf(client.nested.pong).toMatchTypeOf>() }) it('build correct types with router', () => { - const client = createRouterClient({ + const client = createRouterFetchClient({ baseURL: 'http://localhost:3000/orpc', }) - expectTypeOf(client.ping).toMatchTypeOf>() - expectTypeOf(client.nested.pong).toMatchTypeOf>() + expectTypeOf(client.ping).toMatchTypeOf>() + expectTypeOf(client.nested.pong).toMatchTypeOf>() }) }) diff --git a/packages/client/src/router.test.ts b/packages/client/src/router-fetch-client.test.ts similarity index 50% rename from packages/client/src/router.test.ts rename to packages/client/src/router-fetch-client.test.ts index d81d431a0..780fe416d 100644 --- a/packages/client/src/router.test.ts +++ b/packages/client/src/router-fetch-client.test.ts @@ -1,20 +1,20 @@ -import { createProcedureClient } from './procedure' -import { createRouterClient } from './router' +import { createProcedureFetchClient } from './procedure-fetch-client' +import { createRouterFetchClient } from './router-fetch-client' -vi.mock('./procedure', () => ({ - createProcedureClient: vi.fn(), +vi.mock('./procedure-fetch-client', () => ({ + createProcedureFetchClient: vi.fn(), })) beforeEach(() => { vi.clearAllMocks() }) -describe('router client', () => { +describe('router fetch client', () => { const procedureClient = vi.fn().mockReturnValue('__mocked__') - vi.mocked(createProcedureClient).mockReturnValue(procedureClient) + vi.mocked(createProcedureFetchClient).mockReturnValue(procedureClient) it('works', async () => { - const client = createRouterClient({ + const client = createRouterFetchClient({ baseURL: 'http://localhost:3000/orpc', }) as any @@ -22,8 +22,8 @@ describe('router client', () => { const o1 = await client.ping({ value: 'hello' }) expect(o1).toEqual('__mocked__') - expect(createProcedureClient).toBeCalledTimes(1) - expect(createProcedureClient).toBeCalledWith({ + expect(createProcedureFetchClient).toBeCalledTimes(1) + expect(createProcedureFetchClient).toBeCalledWith({ baseURL: 'http://localhost:3000/orpc', path: ['ping'], }) @@ -32,8 +32,8 @@ describe('router client', () => { const o2 = await client.nested.pong({ value: 'hello' }) expect(o2).toEqual('__mocked__') - expect(createProcedureClient).toBeCalledTimes(2) - expect(createProcedureClient).toBeCalledWith({ + expect(createProcedureFetchClient).toBeCalledTimes(2) + expect(createProcedureFetchClient).toBeCalledWith({ baseURL: 'http://localhost:3000/orpc', path: ['nested', 'pong'], }) @@ -42,7 +42,7 @@ describe('router client', () => { it('works with options', async () => { const headers = vi.fn() const fetch = vi.fn() - const client = createRouterClient({ + const client = createRouterFetchClient({ baseURL: 'http://localhost:3000/orpc', path: ['base'], headers, @@ -52,12 +52,20 @@ describe('router client', () => { vi.clearAllMocks() await client.ping({ value: 'hello' }) - expect(createProcedureClient).toBeCalledTimes(1) - expect(createProcedureClient).toBeCalledWith({ + expect(createProcedureFetchClient).toBeCalledTimes(1) + expect(createProcedureFetchClient).toBeCalledWith({ baseURL: 'http://localhost:3000/orpc', path: ['base', 'ping'], headers, fetch, }) }) + + it('not recursive on symbol', async () => { + const client = createRouterFetchClient({ + baseURL: 'http://localhost:3000/orpc', + }) as any + + expect(client[Symbol('test')]).toBeUndefined() + }) }) diff --git a/packages/client/src/router-fetch-client.ts b/packages/client/src/router-fetch-client.ts new file mode 100644 index 000000000..9cb92be8e --- /dev/null +++ b/packages/client/src/router-fetch-client.ts @@ -0,0 +1,32 @@ +import type { ContractRouter } from '@orpc/contract' +import type { ANY_ROUTER, RouterClient } from '@orpc/server' +import type { SetOptional } from '@orpc/shared' +import type { CreateProcedureClientOptions } from './procedure-fetch-client' +import { createProcedureFetchClient } from './procedure-fetch-client' + +export function createRouterFetchClient( + options: SetOptional, +): RouterClient { + const path = options?.path ?? [] + + const client = new Proxy( + createProcedureFetchClient({ + ...options, + path, + }), + { + get(target, key) { + if (typeof key !== 'string') { + return Reflect.get(target, key) + } + + return createRouterFetchClient({ + ...options, + path: [...path, key], + }) + }, + }, + ) + + return client as any +} diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts deleted file mode 100644 index 859e34883..000000000 --- a/packages/client/src/router.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { - ContractProcedure, - ContractRouter, - SchemaInput, - SchemaOutput, -} from '@orpc/contract' -import type { Caller, Lazy, Procedure, Router } from '@orpc/server' -import type { SetOptional } from '@orpc/shared' -import type { CreateProcedureClientOptions } from './procedure' -import { createProcedureClient } from './procedure' - -export type RouterClient | ContractRouter> = { - [K in keyof T]: T[K] extends - | ContractProcedure - | Procedure - | Lazy> - ? Caller, SchemaOutput> - : T[K] extends Router | ContractRouter - ? RouterClient - : never -} - -export function createRouterClient | ContractRouter>( - options: SetOptional, -): RouterClient { - const path = options?.path ?? [] - - const client = new Proxy( - createProcedureClient({ - baseURL: options.baseURL, - fetch: options.fetch, - headers: options.headers, - path, - }), - { - get(target, key) { - if (typeof key !== 'string') { - return Reflect.get(target, key) - } - - return createRouterClient({ - ...options, - path: [...path, key], - }) - }, - }, - ) - - return client as any -} diff --git a/packages/client/tests/e2e.test.ts b/packages/client/tests/e2e.test.ts index 241a42e14..1e3f2b7df 100644 --- a/packages/client/tests/e2e.test.ts +++ b/packages/client/tests/e2e.test.ts @@ -8,7 +8,7 @@ describe('e2e', () => { it('works on error', () => { // @ts-expect-error - invalid input expect(client.user.find()).rejects.toThrowError( - 'Validation input failed', + 'Input validation failed', ) }) diff --git a/packages/client/tests/helpers.ts b/packages/client/tests/helpers.ts index df8e8bfb6..0759ca4ed 100644 --- a/packages/client/tests/helpers.ts +++ b/packages/client/tests/helpers.ts @@ -1,7 +1,7 @@ import { os } from '@orpc/server' import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' import { z } from 'zod' -import { createORPCClient } from '../src' +import { createORPCFetchClient } from '../src' export const orpcServer = os @@ -96,7 +96,7 @@ export const appRouter = orpcServer.router({ }, }) -export const client = createORPCClient({ +export const client = createORPCFetchClient({ baseURL: 'http://localhost:3000', async fetch(...args) { diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 8abbc732f..ed55cc038 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.lib.json", "compilerOptions": { + "lib": ["ES2020", "DOM", "DOM.Iterable"], "types": [] }, "references": [ diff --git a/packages/contract/src/builder.ts b/packages/contract/src/builder.ts index 52d317bbb..fa40a5dea 100644 --- a/packages/contract/src/builder.ts +++ b/packages/contract/src/builder.ts @@ -25,7 +25,7 @@ export class ContractBuilder { }) } - input(schema: U, example?: SchemaInput): DecoratedContractProcedure { + input(schema: U, example?: SchemaInput): DecoratedContractProcedure { return new DecoratedContractProcedure({ InputSchema: schema, inputExample: example, @@ -33,7 +33,7 @@ export class ContractBuilder { }) } - output(schema: U, example?: SchemaOutput): DecoratedContractProcedure { + output(schema: U, example?: SchemaOutput): DecoratedContractProcedure { return new DecoratedContractProcedure({ OutputSchema: schema, outputExample: example, diff --git a/packages/contract/src/procedure-decorated.test-d.ts b/packages/contract/src/procedure-decorated.test-d.ts index 253e72c3d..8ba2982a2 100644 --- a/packages/contract/src/procedure-decorated.test-d.ts +++ b/packages/contract/src/procedure-decorated.test-d.ts @@ -56,17 +56,17 @@ describe('prefix', () => { describe('pushTag', () => { it('return ContractProcedure', () => { - const tagged = decorated.pushTag('tag', 'tag2') + const tagged = decorated.unshiftTag('tag', 'tag2') expectTypeOf(tagged).toEqualTypeOf>() }) it('throw error on invalid tag', () => { - decorated.pushTag('tag') - decorated.pushTag('tag', 'tag2') + decorated.unshiftTag('tag') + decorated.unshiftTag('tag', 'tag2') // @ts-expect-error - invalid tag - decorated.pushTag(1) + decorated.unshiftTag(1) // @ts-expect-error - invalid tag - decorated.pushTag({}) + decorated.unshiftTag({}) }) }) diff --git a/packages/contract/src/procedure-decorated.test.ts b/packages/contract/src/procedure-decorated.test.ts index fa48554ab..51213f4fb 100644 --- a/packages/contract/src/procedure-decorated.test.ts +++ b/packages/contract/src/procedure-decorated.test.ts @@ -52,24 +52,30 @@ describe('prefix', () => { }) }) -describe('pushTag', () => { +describe('unshiftTag', () => { const decorated = new DecoratedContractProcedure({ InputSchema: undefined, OutputSchema: undefined }) it('works', () => { - const tagged = decorated.pushTag('tag1', 'tag2') + const tagged = decorated.unshiftTag('tag1', 'tag2') expect(tagged).toBeInstanceOf(DecoratedContractProcedure) expect(tagged['~orpc']).toEqual({ route: { tags: ['tag1', 'tag2'] } }) - const tagged2 = tagged.pushTag('tag3') + const tagged2 = tagged.unshiftTag('tag3') expect(tagged2).toBeInstanceOf(DecoratedContractProcedure) - expect(tagged2['~orpc']).toEqual({ route: { tags: ['tag1', 'tag2', 'tag3'] } }) + expect(tagged2['~orpc']).toEqual({ route: { tags: ['tag3', 'tag1', 'tag2'] } }) }) it('not reference', () => { - const tagged = decorated.pushTag('tag1', 'tag2') + const tagged = decorated.unshiftTag('tag1', 'tag2') expect(tagged['~orpc']).not.toBe(decorated['~orpc']) expect(tagged).not.toBe(decorated) }) + + it('prevent duplicate', () => { + const tagged = decorated.unshiftTag('tag1', 'tag2') + const tagged2 = tagged.unshiftTag('tag1', 'tag3') + expect(tagged2['~orpc'].route?.tags).toEqual(['tag1', 'tag3', 'tag2']) + }) }) describe('input', () => { diff --git a/packages/contract/src/procedure-decorated.ts b/packages/contract/src/procedure-decorated.ts index 853d2372e..ed9f4ba30 100644 --- a/packages/contract/src/procedure-decorated.ts +++ b/packages/contract/src/procedure-decorated.ts @@ -40,12 +40,15 @@ export class DecoratedContractProcedure< }) } - pushTag(...tags: string[]): DecoratedContractProcedure { + unshiftTag(...tags: string[]): DecoratedContractProcedure { return new DecoratedContractProcedure({ ...this['~orpc'], route: { ...this['~orpc'].route, - tags: [...(this['~orpc'].route?.tags ?? []), ...tags], + tags: [ + ...tags, + ...this['~orpc'].route?.tags?.filter(tag => !tags.includes(tag)) ?? [], + ], }, }) } diff --git a/packages/contract/src/procedure.test.ts b/packages/contract/src/procedure.test.ts index 18f820f7e..ff639b859 100644 --- a/packages/contract/src/procedure.test.ts +++ b/packages/contract/src/procedure.test.ts @@ -11,4 +11,10 @@ describe('isContractProcedure', () => { expect(1).not.toSatisfy(isContractProcedure) expect({ '~orpc': {} }).not.toSatisfy(isContractProcedure) }) + + it('works with raw object', () => { + expect(Object.assign({}, new ContractProcedure({ InputSchema: undefined, OutputSchema: undefined }))).toSatisfy(isContractProcedure) + expect(Object.assign({}, new ContractProcedure({ InputSchema: z.object({}), OutputSchema: undefined }))).toSatisfy(isContractProcedure) + expect(Object.assign({}, new ContractProcedure({ InputSchema: z.object({}), OutputSchema: undefined, route: {} }))).toSatisfy(isContractProcedure) + }) }) diff --git a/packages/contract/src/procedure.ts b/packages/contract/src/procedure.ts index ff358a787..6e29a435f 100644 --- a/packages/contract/src/procedure.ts +++ b/packages/contract/src/procedure.ts @@ -11,7 +11,7 @@ export interface RouteOptions { summary?: string description?: string deprecated?: boolean - tags?: string[] + tags?: readonly string[] } export interface ContractProcedureDef { @@ -35,5 +35,19 @@ export type ANY_CONTRACT_PROCEDURE = ContractProcedure export type WELL_CONTRACT_PROCEDURE = ContractProcedure export function isContractProcedure(item: unknown): item is ANY_CONTRACT_PROCEDURE { - return item instanceof ContractProcedure + if (item instanceof ContractProcedure) { + return true + } + + return ( + (typeof item === 'object' || typeof item === 'function') + && item !== null + && '~type' in item + && item['~type'] === 'ContractProcedure' + && '~orpc' in item + && typeof item['~orpc'] === 'object' + && item['~orpc'] !== null + && 'InputSchema' in item['~orpc'] + && 'OutputSchema' in item['~orpc'] + ) } diff --git a/packages/contract/src/router-builder.test-d.ts b/packages/contract/src/router-builder.test-d.ts index 5cf1b6819..5733db98d 100644 --- a/packages/contract/src/router-builder.test-d.ts +++ b/packages/contract/src/router-builder.test-d.ts @@ -61,6 +61,8 @@ describe('router', () => { const routed = builder.router(router) expectTypeOf(routed).toEqualTypeOf>() + + expectTypeOf(builder.router(ping)).toEqualTypeOf>() }) it('throw error on invalid router', () => { diff --git a/packages/contract/src/router-builder.ts b/packages/contract/src/router-builder.ts index db18f64bc..88e156e30 100644 --- a/packages/contract/src/router-builder.ts +++ b/packages/contract/src/router-builder.ts @@ -40,27 +40,24 @@ export class ContractRouterBuilder { } router(router: T): AdaptedContractRouter { - const adapted: ContractRouter = {} + if (isContractProcedure(router)) { + let decorated = DecoratedContractProcedure.decorate(router) - for (const key in router) { - const item = router[key] + if (this['~orpc'].tags) { + decorated = decorated.unshiftTag(...this['~orpc'].tags) + } - if (isContractProcedure(item)) { - let decorated = DecoratedContractProcedure.decorate(item) + if (this['~orpc'].prefix) { + decorated = decorated.prefix(this['~orpc'].prefix) + } - if (this['~orpc'].tags) { - decorated = decorated.pushTag(...this['~orpc'].tags) - } + return decorated as any + } - if (this['~orpc'].prefix) { - decorated = decorated.prefix(this['~orpc'].prefix) - } + const adapted: ContractRouter = {} - adapted[key] = decorated - } - else { - adapted[key] = this.router(item as ContractRouter) - } + for (const key in router) { + adapted[key] = this.router(router[key]!) } return adapted as any diff --git a/packages/contract/src/router.test-d.ts b/packages/contract/src/router.test-d.ts index 74c20a0cc..88cc62f9c 100644 --- a/packages/contract/src/router.test-d.ts +++ b/packages/contract/src/router.test-d.ts @@ -30,6 +30,10 @@ const router = { } describe('ContractRouter', () => { + it('procedure also is a contract router', () => { + const _: ContractRouter = ping + }) + it('just an object and accepts both procedures and decorated procedures', () => { const _: ContractRouter = router }) diff --git a/packages/contract/src/router.ts b/packages/contract/src/router.ts index 8dbb26fbe..f37b73ffe 100644 --- a/packages/contract/src/router.ts +++ b/packages/contract/src/router.ts @@ -1,22 +1,20 @@ import type { ANY_CONTRACT_PROCEDURE, ContractProcedure } from './procedure' import type { SchemaInput, SchemaOutput } from './types' -export interface ContractRouter { - [k: string]: ANY_CONTRACT_PROCEDURE | ContractRouter +export type ContractRouter = ANY_CONTRACT_PROCEDURE | { + [k: string]: ContractRouter } -export type InferContractRouterInputs = { - [K in keyof T]: T[K] extends ContractProcedure +export type InferContractRouterInputs = + T extends ContractProcedure ? SchemaInput - : T[K] extends ContractRouter - ? InferContractRouterInputs - : never -} + : { + [K in keyof T]: T[K] extends ContractRouter ? InferContractRouterInputs : never + } -export type InferContractRouterOutputs = { - [K in keyof T]: T[K] extends ContractProcedure +export type InferContractRouterOutputs = + T extends ContractProcedure ? SchemaOutput - : T[K] extends ContractRouter - ? InferContractRouterOutputs - : never -} + : { + [K in keyof T]: T[K] extends ContractRouter ? InferContractRouterOutputs : never + } diff --git a/packages/next/src/action-form.test.ts b/packages/next/src/action-form.test.ts index 5ce89346a..2c3ad467a 100644 --- a/packages/next/src/action-form.test.ts +++ b/packages/next/src/action-form.test.ts @@ -27,7 +27,7 @@ describe('createFormAction', () => { procedure, }) - expect(formAction(new FormData())).rejects.toThrowError('Validation input failed') + expect(formAction(new FormData())).rejects.toThrowError('Input validation failed') }) it('hooks', async () => { diff --git a/packages/next/src/action-form.ts b/packages/next/src/action-form.ts index 8f9988521..401892ecd 100644 --- a/packages/next/src/action-form.ts +++ b/packages/next/src/action-form.ts @@ -1,24 +1,32 @@ -import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE, CreateProcedureCallerOptions } from '@orpc/server' -import { createProcedureCaller, loadProcedure, ORPCError } from '@orpc/server' +import type { Schema, SchemaInput } from '@orpc/contract' +import type { Context, CreateProcedureClientOptions } from '@orpc/server' +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 function createFormAction(opt: CreateProcedureCallerOptions): FormAction { - const caller = createProcedureCaller(opt) +export function createFormAction< + TContext extends Context, + TInputSchema extends Schema, + TOutputSchema extends Schema, + TFuncOutput extends SchemaInput, +>(opt: CreateProcedureClientOptions): FormAction { + const caller = createProcedureClient(opt) const formAction = async (input: FormData): Promise => { try { - const procedure = await loadProcedure(opt.procedure) + const { default: procedure } = await unlazy(opt.procedure) + + const inputSchema = procedure['~orpc'].contract['~orpc'].InputSchema const deserializer = new OpenAPIDeserializer({ - schema: procedure.zz$p.contract['~orpc'].InputSchema, + schema: inputSchema?.['~standard'].vendor === 'zod' ? inputSchema as any : undefined, }) const deserializedInput = deserializer.deserializeAsFormData(input) - await caller(deserializedInput) + await caller(deserializedInput as any) } catch (e) { if (e instanceof ORPCError) { diff --git a/packages/next/src/action-safe.test.ts b/packages/next/src/action-safe.test.ts index 37bb138df..c85817067 100644 --- a/packages/next/src/action-safe.test.ts +++ b/packages/next/src/action-safe.test.ts @@ -14,7 +14,7 @@ describe('createSafeAction', () => { // @ts-expect-error - invalid input expect(safe({ name: 123 })).resolves.toEqual([undefined, { code: 'BAD_REQUEST', - message: 'Validation input failed', + message: 'Input validation failed', issues: [ { code: 'invalid_type', diff --git a/packages/next/src/action-safe.ts b/packages/next/src/action-safe.ts index 1210f0185..52c988090 100644 --- a/packages/next/src/action-safe.ts +++ b/packages/next/src/action-safe.ts @@ -1,26 +1,27 @@ -import type { SchemaInput, SchemaOutput } from '@orpc/contract' -import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE, CreateProcedureCallerOptions, Lazy, Procedure, WELL_ORPC_ERROR_JSON } from '@orpc/server' -import { createProcedureCaller, ORPCError } from '@orpc/server' +import type { Schema, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { Context, CreateProcedureClientOptions, ProcedureClient, WELL_ORPC_ERROR_JSON } from '@orpc/server' +import { createProcedureClient, ORPCError } from '@orpc/server' -export type SafeAction = T extends - | Procedure - | Lazy> - ? ( - ...options: - | [input: SchemaInput] - | (undefined extends SchemaInput ? [] : never) - ) => Promise< - | [SchemaOutput, undefined, 'success'] - | [undefined, WELL_ORPC_ERROR_JSON, 'error'] - > - : never +export type SafeAction = ProcedureClient< + TInput, + | [TOutput, undefined, 'success'] + | [undefined, WELL_ORPC_ERROR_JSON, 'error'] +> -export function createSafeAction(opt: CreateProcedureCallerOptions): SafeAction { - const caller = createProcedureCaller(opt) +export function createSafeAction< + TContext extends Context, + TInputSchema extends Schema, + TOutputSchema extends Schema, + TFuncOutput extends SchemaInput, +>( + opt: CreateProcedureClientOptions, +): SafeAction, SchemaOutput> { + const caller = createProcedureClient(opt) - const safeAction = async (...input: [any] | []) => { + const safeAction: SafeAction, SchemaOutput> = async (...[input, option]) => { try { - const output = await caller(...input) + const output = await caller(input as any, option) return [output as any, undefined, 'success'] } catch (e) { diff --git a/packages/next/src/client/action-hooks.test.tsx b/packages/next/src/client/action-hooks.test.tsx index 3d10c2e4d..a572f7c7e 100644 --- a/packages/next/src/client/action-hooks.test.tsx +++ b/packages/next/src/client/action-hooks.test.tsx @@ -63,7 +63,7 @@ describe('useAction', () => { expect(result.current.isError).toBe(true) expect(result.current.input).toEqual({ value: 12334 }) expect(result.current.output).toEqual(undefined) - expect(result.current.error?.message).toEqual('Validation input failed') + expect(result.current.error?.message).toEqual('Input validation failed') }) it('return result on execute', async () => { @@ -86,7 +86,7 @@ describe('useAction', () => { const [output2, error2, status2] = await result.current.execute({ value: 123 }) expect(output2).toBe(undefined) - expect(error2?.message).toBe('Validation input failed') + expect(error2?.message).toBe('Input validation failed') expect(status2).toBe('error') await vi.waitFor(() => expect(result.current.status).toBe('error')) @@ -94,7 +94,7 @@ describe('useAction', () => { expect(result.current.isError).toBe(true) expect(result.current.input).toEqual({ value: 123 }) expect(result.current.output).toEqual(undefined) - expect(result.current.error?.message).toBe('Validation input failed') + expect(result.current.error?.message).toBe('Input validation failed') }) it('hooks', async () => { diff --git a/packages/next/src/client/action-hooks.ts b/packages/next/src/client/action-hooks.ts index ec452cee1..b20f9c351 100644 --- a/packages/next/src/client/action-hooks.ts +++ b/packages/next/src/client/action-hooks.ts @@ -1,3 +1,4 @@ +import type { ProcedureClient } from '@orpc/server' import type { Hooks } from '@orpc/shared' import { convertToStandardError } from '@orpc/server' import { convertToArray, executeWithHooks } from '@orpc/shared' @@ -22,7 +23,7 @@ export type UseActionState = { const idleState = { status: 'idle', isPending: false, isError: false, input: undefined, output: undefined, error: undefined } as const export function useAction( - action: (input: TInput) => Promise, + action: ProcedureClient, hooks?: Hooks, ): UseActionState { const [state, setState] = useState, 'execute' | 'reset'>>(idleState) diff --git a/packages/next/src/client/action-safe-hooks.test.tsx b/packages/next/src/client/action-safe-hooks.test.tsx index 41310bc5c..e73e5ce5b 100644 --- a/packages/next/src/client/action-safe-hooks.test.tsx +++ b/packages/next/src/client/action-safe-hooks.test.tsx @@ -65,7 +65,7 @@ describe('useSafeAction', () => { expect(result.current.isPending).toBe(false) expect(result.current.isError).toBe(true) expect(result.current.output).toBe(undefined) - expect(result.current.error?.message).toEqual('Validation input failed') + expect(result.current.error?.message).toEqual('Input validation failed') expect(result.current.input).toEqual({ value: 12334 }) }) diff --git a/packages/next/src/client/action-safe-hooks.ts b/packages/next/src/client/action-safe-hooks.ts index 2613342da..45aa2b8da 100644 --- a/packages/next/src/client/action-safe-hooks.ts +++ b/packages/next/src/client/action-safe-hooks.ts @@ -1,14 +1,16 @@ +import type { ProcedureClient } from '@orpc/server' import type { Hooks } from '@orpc/shared' -import { type ANY_ORPC_ERROR_JSON, ORPCError } from '@orpc/server' +import type { SafeAction } from '../action-safe' +import { ORPCError } from '@orpc/server' import { useCallback } from 'react' import { useAction, type UseActionState } from './action-hooks' export function useSafeAction( - action: (input: TInput) => Promise<[TOutput, undefined, 'success'] | [undefined, ANY_ORPC_ERROR_JSON, 'error']>, + action: SafeAction, hooks?: Hooks, ): UseActionState { - const normal = useCallback(async (input: TInput) => { - const [output, errorJson, status] = await action(input) + const normal: ProcedureClient = useCallback(async (...args) => { + const [output, errorJson, status] = await action(...args) if (status === 'error') { throw ORPCError.fromJSON(errorJson) diff --git a/packages/openapi/src/fetch/base-handler.ts b/packages/openapi/src/fetch/base-handler.ts index c13495099..1792d0484 100644 --- a/packages/openapi/src/fetch/base-handler.ts +++ b/packages/openapi/src/fetch/base-handler.ts @@ -1,18 +1,18 @@ /// import type { HTTPPath } from '@orpc/contract' -import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE, Router } from '@orpc/server' +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 { createProcedureCaller, isLazy, isProcedure, LAZY_LOADER_SYMBOL, LAZY_ROUTER_PREFIX_SYMBOL, ORPCError } from '@orpc/server' +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: Router, method: string, pathname: string) => Promise<{ +export type ResolveRouter = (router: ANY_ROUTER, method: string, pathname: string) => Promise<{ path: string[] - procedure: ANY_PROCEDURE | ANY_LAZY_PROCEDURE + procedure: ANY_PROCEDURE params: Record } | undefined> @@ -44,19 +44,12 @@ export function createOpenAPIHandler(createHonoRouter: () => Routing): FetchHand if (!match) { throw new ORPCError({ code: 'NOT_FOUND', message: 'Not found' }) } - const procedure = isLazy(match.procedure) ? (await match.procedure[LAZY_LOADER_SYMBOL]()).default : match.procedure - const path = match.path - if (!isProcedure(procedure)) { - throw new ORPCError({ - code: 'NOT_FOUND', - message: 'Not found', - }) - } + const { path, procedure } = match - const params = procedure.zz$p.contract['~orpc'].InputSchema + const params = procedure['~orpc'].contract['~orpc'].InputSchema ? zodCoerce( - procedure.zz$p.contract['~orpc'].InputSchema, + procedure['~orpc'].contract['~orpc'].InputSchema, match.params, { bracketNotation: true }, ) as Record @@ -65,7 +58,7 @@ export function createOpenAPIHandler(createHonoRouter: () => Routing): FetchHand const input = await deserializeInput(options.request, procedure) const mergedInput = mergeParamsAndInput(params, input) - const caller = createProcedureCaller({ + const caller = createProcedureClient({ context, procedure, path, @@ -120,8 +113,8 @@ export function createOpenAPIHandler(createHonoRouter: () => Routing): FetchHand } } -const routingCache = new Map, Routing>() -const pendingCache = new Map, { ref: EachContractLeafResultItem[] }> () +const routingCache = new Map() +const pendingCache = new Map () export function createResolveRouter(createHonoRouter: () => Routing): ResolveRouter { const addRoutes = (routing: Routing, pending: { ref: EachContractLeafResultItem[] }, options: EachLeafOptions) => { @@ -137,7 +130,7 @@ export function createResolveRouter(createHonoRouter: () => Routing): ResolveRou pending.ref.push(...lazies) } - return async (router: Router, method: string, pathname: string) => { + return async (router: ANY_ROUTER, method: string, pathname: string) => { const pending = (() => { let pending = pendingCache.get(router) if (!pending) { @@ -163,10 +156,11 @@ export function createResolveRouter(createHonoRouter: () => Routing): ResolveRou const newPending = [] for (const item of pending.ref) { + const lazyPrefix = getLazyRouterPrefix(item.lazy) + if ( - (LAZY_ROUTER_PREFIX_SYMBOL in item.lazy) - && item.lazy[LAZY_ROUTER_PREFIX_SYMBOL] - && !pathname.startsWith(item.lazy[LAZY_ROUTER_PREFIX_SYMBOL] as HTTPPath) + lazyPrefix + && !pathname.startsWith(lazyPrefix) && !pathname.startsWith(`/${item.path.map(encodeURIComponent).join('/')}`) ) { newPending.push(item) @@ -198,23 +192,17 @@ export function createResolveRouter(createHonoRouter: () => Routing): ResolveRou ) : match[1] as Record - let current: Router | ANY_PROCEDURE | ANY_LAZY_PROCEDURE | undefined = router - for (const segment of path) { - if ((typeof current !== 'object' && typeof current !== 'function') || !current) { - current = undefined - break - } + const { default: maybeProcedure } = await unlazy(getRouterChild(router, ...path)) - current = (current as any)[segment] + if (!isProcedure(maybeProcedure)) { + return undefined } - return isProcedure(current) || isLazy(current) - ? { - path, - procedure: current, - params: { ...params }, // params from hono not a normal object, so we need spread here - } - : undefined + return { + path, + procedure: maybeProcedure, + params: { ...params }, // params from hono not a normal object, so we need spread here + } } } @@ -235,7 +223,7 @@ function mergeParamsAndInput(coercedParams: Record, input: unkn async function deserializeInput(request: Request, procedure: ANY_PROCEDURE): Promise { const deserializer = new OpenAPIDeserializer({ - schema: procedure.zz$p.contract['~orpc'].InputSchema, + schema: procedure['~orpc'].contract['~orpc'].InputSchema, }) try { diff --git a/packages/openapi/src/fetch/server-handler.test.ts b/packages/openapi/src/fetch/server-handler.test.ts index af0f31439..4a0e945b2 100644 --- a/packages/openapi/src/fetch/server-handler.test.ts +++ b/packages/openapi/src/fetch/server-handler.test.ts @@ -118,7 +118,7 @@ describe.each(handlers)('openAPIServerHandler', (handler) => { expect(await response?.json()).toEqual({ code: 'BAD_REQUEST', status: 400, - message: 'Validation input failed', + message: 'Input validation failed', issues: [ { code: 'invalid_type', @@ -149,7 +149,7 @@ describe.each(handlers)('openAPIServerHandler', (handler) => { expect(await response?.json()).toEqual({ code: 'BAD_REQUEST', status: 400, - message: 'Validation input failed', + message: 'Input validation failed', issues: [ { code: 'invalid_type', @@ -180,7 +180,7 @@ describe.each(handlers)('openAPIServerHandler', (handler) => { expect(await response?.json()).toEqual({ code: 'BAD_REQUEST', status: 400, - message: 'Validation input failed', + message: 'Input validation failed', issues: [ { code: 'invalid_type', diff --git a/packages/openapi/src/generator.ts b/packages/openapi/src/generator.ts index dafacaca6..b7e80694d 100644 --- a/packages/openapi/src/generator.ts +++ b/packages/openapi/src/generator.ts @@ -1,7 +1,7 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12' import type { EachLeafOptions } from './utils' import { type ContractRouter, isContractProcedure } from '@orpc/contract' -import { LAZY_LOADER_SYMBOL, type Router } from '@orpc/server' +import { type ANY_ROUTER, unlazy } from '@orpc/server' import { findDeepMatches, isPlainObject, omit } from '@orpc/shared' import { preSerialize } from '@orpc/transformer' import { @@ -43,7 +43,7 @@ export interface GenerateOpenAPIOptions { export async function generateOpenAPI( opts: { - router: ContractRouter | Router + router: ContractRouter | ANY_ROUTER } & Omit, options?: GenerateOpenAPIOptions, ): Promise { @@ -329,7 +329,7 @@ export async function generateOpenAPI( summary: internal.route?.summary, description: internal.route?.description, deprecated: internal.route?.deprecated, - tags: internal.route?.tags, + tags: internal.route?.tags ? [...internal.route.tags] : undefined, operationId: path.join('.'), parameters: parameters.length ? parameters : undefined, requestBody, @@ -344,7 +344,7 @@ export async function generateOpenAPI( }) for (const lazy of lazies) { - const router = (await lazy.lazy[LAZY_LOADER_SYMBOL]()).default + const { default: router } = await unlazy(lazy.lazy) pending.push({ path: lazy.path, diff --git a/packages/openapi/src/utils.ts b/packages/openapi/src/utils.ts index a8d852e80..ec9167292 100644 --- a/packages/openapi/src/utils.ts +++ b/packages/openapi/src/utils.ts @@ -1,10 +1,10 @@ -import type { ANY_CONTRACT_PROCEDURE, ContractRouter, HTTPPath, WELL_CONTRACT_PROCEDURE } from '@orpc/contract' -import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE, Lazy, Router } from '@orpc/server' +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 { isLazy, isProcedure, ROUTER_CONTRACT_SYMBOL } from '@orpc/server' +import { flatLazy, getRouterContract, isLazy, isProcedure } from '@orpc/server' export interface EachLeafOptions { - router: ANY_PROCEDURE | Router | ContractRouter | ANY_CONTRACT_PROCEDURE + router: ContractRouter | ANY_ROUTER path: string[] } @@ -14,7 +14,7 @@ export interface EachLeafCallbackOptions { } export interface EachContractLeafResultItem { - lazy: ANY_LAZY_PROCEDURE | Lazy> + lazy: Lazy | Lazy> path: string[] } @@ -24,11 +24,13 @@ export function eachContractProcedureLeaf( result: EachContractLeafResultItem[] = [], isCurrentRouterContract = false, ): EachContractLeafResultItem[] { - if (!isCurrentRouterContract && ROUTER_CONTRACT_SYMBOL in options.router && options.router[ROUTER_CONTRACT_SYMBOL]) { + const hiddenContract = getRouterContract(options.router) + + if (!isCurrentRouterContract && hiddenContract) { return eachContractProcedureLeaf( { path: options.path, - router: options.router[ROUTER_CONTRACT_SYMBOL] as any, + router: hiddenContract, }, callback, result, @@ -38,7 +40,7 @@ export function eachContractProcedureLeaf( if (isLazy(options.router)) { result.push({ - lazy: options.router, + lazy: flatLazy(options.router), path: options.path, }) } @@ -46,7 +48,7 @@ export function eachContractProcedureLeaf( // else if (isProcedure(options.router)) { callback({ - contract: options.router.zz$p.contract, + contract: options.router['~orpc'].contract, path: options.path, }) } diff --git a/packages/react-query/src/utils-procedure.test-d.ts b/packages/react-query/src/utils-procedure.test-d.ts index c42d3ce0a..dea2bbf05 100644 --- a/packages/react-query/src/utils-procedure.test-d.ts +++ b/packages/react-query/src/utils-procedure.test-d.ts @@ -1,10 +1,10 @@ -import type { Caller } from '@orpc/server' +import type { ProcedureClient } from '@orpc/server' import type { InfiniteData, QueryKey } from '@tanstack/react-query' import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { createProcedureUtils } from './utils-procedure' describe('queryOptions', () => { - const client = vi.fn>( + const client = vi.fn>( (...[input]) => Promise.resolve(input?.toString()), ) const utils = createProcedureUtils(client, []) @@ -151,7 +151,7 @@ describe('infiniteOptions', () => { }) it('input can be optional', () => { - const utils = createProcedureUtils({} as Caller<{ limit?: number, cursor?: number } | undefined, string>, []) + const utils = createProcedureUtils({} as ProcedureClient<{ limit?: number, cursor?: number } | undefined, string>, []) utils.infiniteOptions({ getNextPageParam, diff --git a/packages/react-query/src/utils-procedure.test.ts b/packages/react-query/src/utils-procedure.test.ts index bafdcbb51..23ab5fd94 100644 --- a/packages/react-query/src/utils-procedure.test.ts +++ b/packages/react-query/src/utils-procedure.test.ts @@ -1,4 +1,4 @@ -import type { Caller } from '@orpc/server' +import type { ProcedureClient } from '@orpc/server' import * as keyModule from './key' import { createProcedureUtils } from './utils-procedure' @@ -12,7 +12,7 @@ beforeEach(() => { }) describe('queryOptions', () => { - const client = vi.fn>((...[input]) => Promise.resolve(input?.toString())) + const client = vi.fn>((...[input]) => Promise.resolve(input?.toString())) const utils = createProcedureUtils(client, ['ping']) it('works', async () => { @@ -75,7 +75,7 @@ describe('infiniteOptions', () => { describe('mutationOptions', () => { it('works', async () => { - const client = vi.fn>( + const client = vi.fn>( (...[input]) => Promise.resolve(input?.toString()), ) const utils = createProcedureUtils(client, ['ping']) diff --git a/packages/react-query/src/utils-procedure.ts b/packages/react-query/src/utils-procedure.ts index 590207e16..6bf69a222 100644 --- a/packages/react-query/src/utils-procedure.ts +++ b/packages/react-query/src/utils-procedure.ts @@ -1,4 +1,4 @@ -import type { Caller } from '@orpc/server' +import type { ProcedureClient } from '@orpc/server' import type { IsEqual } from '@orpc/shared' import type { QueryKey } from '@tanstack/react-query' import type { InfiniteOptions, MutationOptions, QueryOptions } from './types' @@ -26,7 +26,7 @@ export interface ProcedureUtils { } export function createProcedureUtils( - client: Caller, + client: ProcedureClient, path: string[], ): ProcedureUtils { return { diff --git a/packages/react-query/src/utils-router.test-d.ts b/packages/react-query/src/utils-router.test-d.ts index 41d89c735..488ec9b75 100644 --- a/packages/react-query/src/utils-router.test-d.ts +++ b/packages/react-query/src/utils-router.test-d.ts @@ -1,3 +1,4 @@ +import type { RouterClient } from '@orpc/server' import { oc } from '@orpc/contract' import { os } from '@orpc/server' import { z } from 'zod' @@ -22,7 +23,7 @@ const router = os.contract(contractRouter).router({ describe('with contract router', () => { it('build correct types', () => { - const utils = createRouterUtils({} as any) + const utils = createRouterUtils({} as RouterClient) const generalUtils = createGeneralUtils([]) const pingUtils = createProcedureUtils(ping, []) @@ -40,7 +41,7 @@ describe('with contract router', () => { describe('with router', () => { it('build correct types', () => { - const utils = createRouterUtils({} as any) + const utils = createRouterUtils({} as RouterClient) const generalUtils = createGeneralUtils([]) const pingUtils = createProcedureUtils(ping, []) diff --git a/packages/react-query/src/utils-router.ts b/packages/react-query/src/utils-router.ts index 1a34a8151..b352410bc 100644 --- a/packages/react-query/src/utils-router.ts +++ b/packages/react-query/src/utils-router.ts @@ -1,28 +1,20 @@ -import type { RouterClient } from '@orpc/client' -import type { ContractProcedure, ContractRouter, SchemaInput, SchemaOutput } from '@orpc/contract' -import type { Lazy, Procedure, Router } from '@orpc/server' +import type { ProcedureClient, RouterClient } from '@orpc/server' import { createGeneralUtils, type GeneralUtils } from './utils-general' import { createProcedureUtils, type ProcedureUtils } from './utils-procedure' -export type RouterUtils | ContractRouter> = { - [K in keyof T]: T[K] extends - | ContractProcedure - | Procedure - | Lazy> - ? - & ProcedureUtils, SchemaOutput> - & GeneralUtils> - : T[K] extends Router | ContractRouter - ? RouterUtils - : never -} & GeneralUtils +export type RouterUtils> = +T extends ProcedureClient + ? ProcedureUtils & GeneralUtils + : { + [K in keyof T]: T[K] extends RouterClient ? RouterUtils : never + } & GeneralUtils /** * @param client - The client create form `@orpc/client` * @param path - The base path for query key */ -export function createRouterUtils | ContractRouter>( - client: RouterClient, +export function createRouterUtils>( + client: T, path: string[] = [], ): RouterUtils { const generalUtils = createGeneralUtils(path) diff --git a/packages/react-query/tests/e2e.test.tsx b/packages/react-query/tests/e2e.test.tsx index 53c0d3e5f..4d073ba4d 100644 --- a/packages/react-query/tests/e2e.test.tsx +++ b/packages/react-query/tests/e2e.test.tsx @@ -23,7 +23,7 @@ describe('useQuery', () => { // @ts-expect-error -- invalid input const { result } = renderHook(() => useQuery(orpc.user.create.queryOptions({ input: {} }), queryClient)) - await vi.waitFor(() => expect(result.current.error).toEqual(new Error('Validation input failed'))) + await vi.waitFor(() => expect(result.current.error).toEqual(new Error('Input validation failed'))) expect(queryClient.getQueryData(orpc.ping.key({ type: 'query' }))).toEqual(undefined) }) diff --git a/packages/react-query/tests/helpers.tsx b/packages/react-query/tests/helpers.tsx index e310b8c06..fa2329659 100644 --- a/packages/react-query/tests/helpers.tsx +++ b/packages/react-query/tests/helpers.tsx @@ -1,4 +1,4 @@ -import { createORPCClient } from '@orpc/client' +import { createORPCFetchClient } from '@orpc/client' import { os } from '@orpc/server' import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' import { QueryClient } from '@tanstack/react-query' @@ -91,7 +91,7 @@ export const appRouter = orpcServer.router({ }, }) -export const orpcClient = createORPCClient({ +export const orpcClient = createORPCFetchClient({ baseURL: 'http://localhost:3000', async fetch(...args) { diff --git a/packages/react/src/general-hooks.test-d.ts b/packages/react/src/general-hooks.test-d.ts index 8b6663797..4c2b850c3 100644 --- a/packages/react/src/general-hooks.test-d.ts +++ b/packages/react/src/general-hooks.test-d.ts @@ -1,4 +1,4 @@ -import type { SchemaInput, SchemaOutput } from '@orpc/contract' +import type { SchemaOutput } from '@orpc/contract' import type { DefaultError, Mutation, @@ -6,22 +6,21 @@ import type { } from '@tanstack/react-query' import { ORPCContext, - type UserCreateInputSchema, - type UserFindInputSchema, type UserSchema, } from '../tests/orpc' import { createGeneralHooks } from './general-hooks' +type User = SchemaOutput + describe('useIsFetching', () => { - const hooks = createGeneralHooks({ + const hooks = createGeneralHooks({ context: ORPCContext, path: ['user'], }) const procedureHooks = createGeneralHooks< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + { id: string }, + User >({ context: ORPCContext, path: ['user', 'find'], @@ -55,15 +54,14 @@ describe('useIsFetching', () => { }) describe('useIsMutating', () => { - const hooks = createGeneralHooks({ + const hooks = createGeneralHooks({ context: ORPCContext, path: ['user'], }) const procedureHooks = createGeneralHooks< - typeof UserCreateInputSchema, - typeof UserSchema, - SchemaOutput + { id: string }, + User >({ context: ORPCContext, path: ['user', 'create'], @@ -83,15 +81,14 @@ describe('useIsMutating', () => { }) describe('useMutationState', () => { - const hooks = createGeneralHooks({ + const hooks = createGeneralHooks({ context: ORPCContext, path: ['user'], }) const procedureHooks = createGeneralHooks< - typeof UserCreateInputSchema, - typeof UserSchema, - SchemaOutput + { id: string }, + User >({ context: ORPCContext, path: ['user', 'create'], @@ -106,9 +103,9 @@ describe('useMutationState', () => { const result2 = procedureHooks.useMutationState() expectTypeOf(result2).toEqualTypeOf< MutationState< - SchemaOutput, + { id: string, name: string }, DefaultError, - SchemaInput + { id: string } >[] >() }) @@ -137,9 +134,9 @@ describe('useMutationState', () => { select: (data) => { expectTypeOf(data).toEqualTypeOf< Mutation< - SchemaOutput, + { id: string, name: string }, DefaultError, - SchemaInput + { id: string } > >() diff --git a/packages/react/src/general-hooks.test.tsx b/packages/react/src/general-hooks.test.tsx index e0fa14083..f93255ce2 100644 --- a/packages/react/src/general-hooks.test.tsx +++ b/packages/react/src/general-hooks.test.tsx @@ -1,12 +1,8 @@ -import type { SchemaOutput } from '@orpc/contract' import { useMutation, useQuery } from '@tanstack/react-query' import { renderHook } from '@testing-library/react' import { ORPCContext, queryClient, - type UserCreateInputSchema, - type UserFindInputSchema, - type UserSchema, wrapper, } from '../tests/orpc' import { createGeneralHooks } from './general-hooks' @@ -16,25 +12,17 @@ beforeEach(() => { }) describe('useIsFetching', () => { - const user_hooks = createGeneralHooks({ + const user_hooks = createGeneralHooks({ context: ORPCContext, path: ['user'], }) - const user_find_Hooks = createGeneralHooks< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput - >({ + const user_find_Hooks = createGeneralHooks({ context: ORPCContext, path: ['user', 'find'], }) - const user_create_Hooks = createGeneralHooks< - typeof UserCreateInputSchema, - typeof UserSchema, - SchemaOutput - >({ + const user_create_Hooks = createGeneralHooks({ context: ORPCContext, path: ['user', 'create'], }) @@ -104,25 +92,17 @@ describe('useIsFetching', () => { }) describe('useIsMutating', () => { - const user_hooks = createGeneralHooks({ + const user_hooks = createGeneralHooks({ context: ORPCContext, path: ['user'], }) - const user_find_Hooks = createGeneralHooks< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput - >({ + const user_find_Hooks = createGeneralHooks({ context: ORPCContext, path: ['user', 'find'], }) - const user_create_Hooks = createGeneralHooks< - typeof UserCreateInputSchema, - typeof UserSchema, - SchemaOutput - >({ + const user_create_Hooks = createGeneralHooks({ context: ORPCContext, path: ['user', 'create'], }) @@ -165,25 +145,17 @@ describe('useIsMutating', () => { }) describe('useMutationState', () => { - const user_hooks = createGeneralHooks({ + const user_hooks = createGeneralHooks({ context: ORPCContext, path: ['user'], }) - const user_find_Hooks = createGeneralHooks< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput - >({ + const user_find_Hooks = createGeneralHooks({ context: ORPCContext, path: ['user', 'find'], }) - const user_create_Hooks = createGeneralHooks< - typeof UserCreateInputSchema, - typeof UserSchema, - SchemaOutput - >({ + const user_create_Hooks = createGeneralHooks({ context: ORPCContext, path: ['user', 'create'], }) diff --git a/packages/react/src/general-hooks.ts b/packages/react/src/general-hooks.ts index 3a10a913a..62ea6c3a1 100644 --- a/packages/react/src/general-hooks.ts +++ b/packages/react/src/general-hooks.ts @@ -1,4 +1,3 @@ -import type { Schema, SchemaInput, SchemaOutput } from '@orpc/contract' import type { PartialDeep, SetOptional } from '@orpc/shared' import type { ORPCQueryFilters } from './tanstack-query' import { @@ -13,29 +12,25 @@ import { import { type ORPCContext, useORPCContext } from './react-context' import { getMutationKeyFromPath, getQueryKeyFromPath } from './tanstack-key' -export interface GeneralHooks< - TInputSchema extends Schema, - TOutputSchema extends Schema, - TFuncOutput extends SchemaOutput, -> { +export interface GeneralHooks { useIsFetching: ( - filers?: ORPCQueryFilters>>, + filers?: ORPCQueryFilters>, ) => number useIsMutating: (filters?: SetOptional) => number useMutationState: < UResult = MutationState< - SchemaOutput, + TOutput, DefaultError, - SchemaInput + TInput >, >(options?: { filters?: SetOptional select?: ( mutation: Mutation< - SchemaOutput, + TOutput, DefaultError, - SchemaInput + TInput >, ) => UResult } @@ -53,14 +48,9 @@ export interface CreateGeneralHooksOptions { path: string[] } -export function createGeneralHooks< - TInputSchema extends Schema = undefined, - TOutputSchema extends Schema = undefined, - TFuncOutput extends - SchemaOutput = SchemaOutput, ->( +export function createGeneralHooks( options: CreateGeneralHooksOptions, -): GeneralHooks { +): GeneralHooks { return { useIsFetching(filters) { const { queryType, input, ...rest } = filters ?? {} diff --git a/packages/react/src/general-utils.test-d.ts b/packages/react/src/general-utils.test-d.ts index 03297793d..09dbc9102 100644 --- a/packages/react/src/general-utils.test-d.ts +++ b/packages/react/src/general-utils.test-d.ts @@ -1,9 +1,8 @@ -import type { SchemaOutput } from '@orpc/contract' +import type { SchemaInput, SchemaOutput } from '@orpc/contract' import type { Promisable } from '@orpc/shared' import type { InfiniteData } from '@tanstack/react-query' import { queryClient, - type UserCreateInputSchema, type UserFindInputSchema, type UserListInputSchema, type UserListOutputSchema, @@ -11,33 +10,36 @@ import { } from '../tests/orpc' import { createGeneralUtils } from './general-utils' +type UserFindInput = SchemaInput +type User = SchemaOutput + +type UserListInput = SchemaInput +type UserListOutput = SchemaOutput + const user_utils = createGeneralUtils({ queryClient, path: ['user'], }) const user_find_utils = createGeneralUtils< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User >({ queryClient, path: ['user', 'find'], }) const user_list_utils = createGeneralUtils< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({ queryClient, path: ['user', 'list'], }) const user_create_utils = createGeneralUtils< - typeof UserCreateInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User >({ queryClient, path: ['user', 'create'], diff --git a/packages/react/src/general-utils.test.tsx b/packages/react/src/general-utils.test.tsx index 3786d32ef..469e47d9a 100644 --- a/packages/react/src/general-utils.test.tsx +++ b/packages/react/src/general-utils.test.tsx @@ -1,4 +1,4 @@ -import type { SchemaOutput } from '@orpc/contract' +import type { SchemaInput, SchemaOutput } from '@orpc/contract' import type { UserCreateInputSchema, UserFindInputSchema, @@ -15,6 +15,14 @@ import { import { renderHook } from '@testing-library/react' import { createGeneralUtils } from './general-utils' +type UserFindInput = SchemaInput +type User = SchemaOutput + +type UserListInput = SchemaInput +type UserListOutput = SchemaOutput + +type UserCreateInput = SchemaInput + let qc = new QueryClient() let user_utils = createGeneralUtils({ @@ -22,28 +30,22 @@ let user_utils = createGeneralUtils({ path: ['user'], }) -let user_find_utils = createGeneralUtils< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput ->({ +let user_find_utils = createGeneralUtils({ queryClient: qc, path: ['user', 'find'], }) let user_list_utils = createGeneralUtils< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({ queryClient: qc, path: ['user', 'list'], }) let user_create_utils = createGeneralUtils< - typeof UserCreateInputSchema, - typeof UserSchema, - SchemaOutput + UserCreateInput, + User >({ queryClient: qc, path: ['user', 'create'], @@ -57,27 +59,24 @@ beforeEach(() => { }) user_find_utils = createGeneralUtils< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User >({ queryClient: qc, path: ['user', 'find'], }) user_list_utils = createGeneralUtils< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({ queryClient: qc, path: ['user', 'list'], }) user_create_utils = createGeneralUtils< - typeof UserCreateInputSchema, - typeof UserSchema, - SchemaOutput + UserCreateInput, + User >({ queryClient: qc, path: ['user', 'create'], diff --git a/packages/react/src/general-utils.ts b/packages/react/src/general-utils.ts index 54b7ff216..a64b1e033 100644 --- a/packages/react/src/general-utils.ts +++ b/packages/react/src/general-utils.ts @@ -1,4 +1,3 @@ -import type { Schema, SchemaInput, SchemaOutput } from '@orpc/contract' import type { PartialDeep, SetOptional } from '@orpc/shared' import type { CancelOptions, @@ -23,56 +22,52 @@ import type { import type { InferCursor, SchemaInputForInfiniteQuery } from './types' import { getMutationKeyFromPath, getQueryKeyFromPath } from './tanstack-key' -export interface GeneralUtils< - TInputSchema extends Schema, - TOutputSchema extends Schema, - TFuncOutput extends SchemaOutput, -> { +export interface GeneralUtils { getQueriesData: ( filters?: OmitKeyof< - ORPCQueryFilters>>, + ORPCQueryFilters>, 'queryType' >, - ) => [QueryKey, SchemaOutput | undefined][] + ) => [QueryKey, TOutput | undefined][] getInfiniteQueriesData: ( filters?: OmitKeyof< - ORPCQueryFilters>>, + ORPCQueryFilters>>, 'queryType' >, ) => [ QueryKey, | undefined | InfiniteData< - SchemaOutput, - InferCursor + TOutput, + InferCursor >, ][] setQueriesData: ( filters: OmitKeyof< - ORPCQueryFilters>>, + ORPCQueryFilters>, 'queryType' >, updater: Updater< - SchemaOutput | undefined, - SchemaOutput | undefined + TOutput | undefined, + TOutput | undefined >, options?: SetDataOptions, - ) => [QueryKey, SchemaOutput | undefined][] + ) => [QueryKey, TOutput | undefined][] setInfiniteQueriesData: ( filters: OmitKeyof< - ORPCQueryFilters>>, + ORPCQueryFilters>>, 'queryType' >, updater: Updater< | InfiniteData< - SchemaOutput, - InferCursor + TOutput, + InferCursor > | undefined, | InfiniteData< - SchemaOutput, - InferCursor + TOutput, + InferCursor > | undefined >, @@ -81,60 +76,60 @@ export interface GeneralUtils< QueryKey, | undefined | InfiniteData< - SchemaOutput, - InferCursor + TOutput, + InferCursor >, ][] invalidate: ( filters?: ORPCInvalidateQueryFilters< - PartialDeep> + PartialDeep >, options?: InvalidateOptions, ) => Promise refetch: ( - filters?: ORPCQueryFilters>>, + filters?: ORPCQueryFilters>, options?: RefetchOptions, ) => Promise cancel: ( - filters?: ORPCQueryFilters>>, + filters?: ORPCQueryFilters>, options?: CancelOptions, ) => Promise remove: ( - filters?: ORPCQueryFilters>>, + filters?: ORPCQueryFilters>, ) => void reset: ( - filters?: ORPCQueryFilters>>, + filters?: ORPCQueryFilters>, options?: ResetOptions, ) => Promise isFetching: ( - filters?: ORPCQueryFilters>>, + filters?: ORPCQueryFilters>, ) => number isMutating: (filters?: SetOptional) => number getQueryDefaults: ( filters?: Pick< - ORPCQueryFilters>>, + ORPCQueryFilters>, 'input' | 'queryKey' >, ) => OmitKeyof< - QueryObserverOptions>, + QueryObserverOptions, 'queryKey' > getInfiniteQueryDefaults: ( filters?: Pick< - ORPCQueryFilters>>, + ORPCQueryFilters>>, 'input' | 'queryKey' >, ) => OmitKeyof< QueryObserverOptions< - SchemaOutput, + TOutput, DefaultError, - SchemaOutput, - InfiniteData>, + TOutput, + InfiniteData, QueryKey, - InferCursor + InferCursor >, 'queryKey' > @@ -142,12 +137,12 @@ export interface GeneralUtils< setQueryDefaults: ( options: Partial< OmitKeyof< - QueryObserverOptions>, + QueryObserverOptions, 'queryKey' > >, filters?: Pick< - ORPCQueryFilters>>, + ORPCQueryFilters>, 'input' | 'queryKey' >, ) => void @@ -155,18 +150,18 @@ export interface GeneralUtils< options: Partial< OmitKeyof< QueryObserverOptions< - SchemaOutput, + TOutput, DefaultError, - SchemaOutput, - InfiniteData>, + TOutput, + InfiniteData, QueryKey, - InferCursor + InferCursor >, 'queryKey' > >, filters?: Pick< - ORPCQueryFilters>>, + ORPCQueryFilters>, 'input' | 'queryKey' >, ) => void @@ -174,16 +169,16 @@ export interface GeneralUtils< getMutationDefaults: ( filters?: Pick, ) => MutationObserverOptions< - SchemaOutput, + TOutput, DefaultError, - SchemaInput + TInput > setMutationDefaults: ( options: OmitKeyof< MutationObserverOptions< - SchemaOutput, + TOutput, DefaultError, - SchemaInput + TInput >, 'mutationKey' >, @@ -202,14 +197,9 @@ export interface CreateGeneralUtilsOptions { path: string[] } -export function createGeneralUtils< - TInputSchema extends Schema = undefined, - TOutputSchema extends Schema = undefined, - TFuncOutput extends - SchemaOutput = SchemaOutput, ->( +export function createGeneralUtils( options: CreateGeneralUtilsOptions, -): GeneralUtils { +): GeneralUtils { return { getQueriesData(filters) { const { input, ...rest } = filters ?? {} diff --git a/packages/react/src/orpc-path.ts b/packages/react/src/orpc-path.ts index 2f554d181..c5043f971 100644 --- a/packages/react/src/orpc-path.ts +++ b/packages/react/src/orpc-path.ts @@ -1,16 +1,12 @@ import type { ProcedureHooks } from './procedure-hooks' -import type { - ORPCHooksWithContractRouter, - ORPCHooksWithRouter, -} from './react-hooks' +import type { ORPCHooks } from './react-hooks' export const orpcPathSymbol = Symbol('orpcPathSymbol') export function getORPCPath( orpc: - | ORPCHooksWithContractRouter - | ORPCHooksWithRouter - | ProcedureHooks, + | ORPCHooks + | ProcedureHooks, ): string[] { const val = Reflect.get(orpc, orpcPathSymbol) diff --git a/packages/react/src/procedure-hooks.test-d.ts b/packages/react/src/procedure-hooks.test-d.ts index 1f86c47f2..ecbd45c8f 100644 --- a/packages/react/src/procedure-hooks.test-d.ts +++ b/packages/react/src/procedure-hooks.test-d.ts @@ -11,11 +11,16 @@ import { } from '../tests/orpc' import { createProcedureHooks } from './procedure-hooks' +type UserFindInput = SchemaInput +type User = SchemaOutput + +type UserListInput = SchemaInput +type UserListOutput = SchemaOutput + describe('useQuery', () => { const hooks = createProcedureHooks< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User >({ context: ORPCContext, path: ['user', 'find'], @@ -55,9 +60,8 @@ describe('useQuery', () => { describe('useInfiniteQuery', () => { const hooks = createProcedureHooks< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({ context: ORPCContext, path: ['user', 'list'], @@ -122,9 +126,8 @@ describe('useInfiniteQuery', () => { describe('useSuspenseQuery', () => { const hooks = createProcedureHooks< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User >({ context: ORPCContext, path: ['user', 'find'], @@ -162,9 +165,8 @@ describe('useSuspenseQuery', () => { describe('useSuspenseInfiniteQuery', () => { const hooks = createProcedureHooks< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({ context: ORPCContext, path: ['user', 'list'], @@ -225,9 +227,8 @@ describe('useSuspenseInfiniteQuery', () => { describe('usePrefetchQuery', () => { const hooks = createProcedureHooks< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User >({ context: ORPCContext, path: ['user', 'find'], @@ -246,9 +247,8 @@ describe('usePrefetchQuery', () => { describe('usePrefetchInfiniteQuery', () => { const hooks = createProcedureHooks< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({ context: ORPCContext, path: ['user', 'list'], @@ -286,11 +286,12 @@ describe('usePrefetchInfiniteQuery', () => { }) }) +type UserCreateInput = SchemaInput + describe('useMutation', () => { const hooks = createProcedureHooks< - typeof UserCreateInputSchema, - typeof UserSchema, - SchemaOutput + UserCreateInput, + User >({ context: ORPCContext, path: ['user', 'create'], diff --git a/packages/react/src/procedure-hooks.test.tsx b/packages/react/src/procedure-hooks.test.tsx index f3a8b3466..178930726 100644 --- a/packages/react/src/procedure-hooks.test.tsx +++ b/packages/react/src/procedure-hooks.test.tsx @@ -1,13 +1,10 @@ -import type { SchemaOutput } from '@orpc/contract' +import type { SchemaInput, SchemaOutput } from '@orpc/contract' import { renderHook, screen, waitFor } from '@testing-library/react' import { ORPCContext, queryClient, - type UserCreateInputSchema, - type UserFindInputSchema, type UserListInputSchema, type UserListOutputSchema, - type UserSchema, wrapper, } from '../tests/orpc' import { createProcedureHooks } from './procedure-hooks' @@ -17,11 +14,7 @@ beforeEach(() => { }) describe('useQuery', () => { - const hooks = createProcedureHooks< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput - >({ + const hooks = createProcedureHooks({ context: ORPCContext, path: ['user', 'find'], }) @@ -47,7 +40,6 @@ describe('useQuery', () => { }) it('on error', async () => { - // @ts-expect-error invalid input const { result } = renderHook(() => hooks.useQuery({ id: {} }), { wrapper, }) @@ -55,16 +47,18 @@ describe('useQuery', () => { await waitFor(() => expect(result.current.status).toBe('error')) expect((result.current.error as any).message).toEqual( - 'Validation input failed', + 'Input validation failed', ) }) }) +type UserListInput = SchemaInput +type UserListOutput = SchemaOutput + describe('useInfiniteQuery', () => { const hooks = createProcedureHooks< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({ context: ORPCContext, path: ['user', 'list'], @@ -143,17 +137,13 @@ describe('useInfiniteQuery', () => { await waitFor(() => expect(result.current.status).toBe('error')) expect((result.current.error as any).message).toEqual( - 'Validation input failed', + 'Input validation failed', ) }) }) describe('useSuspenseQuery', () => { - const hooks = createProcedureHooks< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput - >({ + const hooks = createProcedureHooks({ context: ORPCContext, path: ['user', 'find'], }) @@ -179,14 +169,13 @@ describe('useSuspenseQuery', () => { }) it('on error', async () => { - // @ts-expect-error invalid input const { result } = renderHook(() => hooks.useSuspenseQuery({ id: {} }), { wrapper, }) await waitFor(() => expect(screen.getByTestId('error-boundary')).toHaveTextContent( - 'Validation input failed', + 'Input validation failed', ), ) }) @@ -194,9 +183,8 @@ describe('useSuspenseQuery', () => { describe('useSuspenseInfiniteQuery', () => { const hooks = createProcedureHooks< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({ context: ORPCContext, path: ['user', 'list'], @@ -274,18 +262,14 @@ describe('useSuspenseInfiniteQuery', () => { await waitFor(() => expect(screen.getByTestId('error-boundary')).toHaveTextContent( - 'Validation input failed', + 'Input validation failed', ), ) }) }) describe('usePrefetchQuery', () => { - const hooks = createProcedureHooks< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput - >({ + const hooks = createProcedureHooks({ context: ORPCContext, path: ['user', 'find'], }) @@ -311,9 +295,8 @@ describe('usePrefetchQuery', () => { describe('usePrefetchInfiniteQuery', () => { const hooks = createProcedureHooks< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({ context: ORPCContext, path: ['user', 'list'], @@ -350,11 +333,7 @@ describe('usePrefetchInfiniteQuery', () => { }) describe('useMutation', () => { - const hooks = createProcedureHooks< - typeof UserCreateInputSchema, - typeof UserSchema, - SchemaOutput - >({ + const hooks = createProcedureHooks({ context: ORPCContext, path: ['user', 'create'], }) @@ -376,12 +355,11 @@ describe('useMutation', () => { wrapper, }) - // @ts-expect-error invalid input result.current.mutate({ name: {} }) await waitFor(() => expect((result.current.error as any)?.message).toEqual( - 'Validation input failed', + 'Input validation failed', ), ) }) diff --git a/packages/react/src/procedure-hooks.ts b/packages/react/src/procedure-hooks.ts index 681327a37..b97220f87 100644 --- a/packages/react/src/procedure-hooks.ts +++ b/packages/react/src/procedure-hooks.ts @@ -1,4 +1,3 @@ -import type { Schema, SchemaInput, SchemaOutput } from '@orpc/contract' import type { InferCursor, SchemaInputForInfiniteQuery } from './types' import { get, @@ -33,16 +32,12 @@ import { orpcPathSymbol } from './orpc-path' import { type ORPCContext, useORPCContext } from './react-context' import { getMutationKeyFromPath, getQueryKeyFromPath } from './tanstack-key' -export interface ProcedureHooks< - TInputSchema extends Schema, - TOutputSchema extends Schema, - TFuncOutput extends SchemaOutput, -> { - useQuery: >( - input: SchemaInput, +export interface ProcedureHooks { + useQuery: ( + input: TInput, options?: SetOptional< UseQueryOptions< - SchemaOutput, + TOutput, DefaultError, USelectData >, @@ -51,32 +46,32 @@ export interface ProcedureHooks< ) => UseQueryResult useInfiniteQuery: < USelectData = InfiniteData< - SchemaOutput, - InferCursor + TOutput, + InferCursor >, >( options: PartialOnUndefinedDeep< SetOptional< UseInfiniteQueryOptions< - SchemaOutput, + TOutput, DefaultError, USelectData, - SchemaOutput, + TOutput, QueryKey, - InferCursor + InferCursor >, 'queryFn' | 'queryKey' > & { - input: SchemaInputForInfiniteQuery + input: SchemaInputForInfiniteQuery } >, ) => UseInfiniteQueryResult - useSuspenseQuery: >( - input: SchemaInput, + useSuspenseQuery: ( + input: TInput, options?: SetOptional< UseSuspenseQueryOptions< - SchemaOutput, + TOutput, DefaultError, USelectData >, @@ -85,44 +80,44 @@ export interface ProcedureHooks< ) => UseSuspenseQueryResult useSuspenseInfiniteQuery: < USelectData = InfiniteData< - SchemaOutput, - InferCursor + TOutput, + InferCursor >, >( options: PartialOnUndefinedDeep< SetOptional< UseSuspenseInfiniteQueryOptions< - SchemaOutput, + TOutput, DefaultError, USelectData, - SchemaOutput, + TOutput, QueryKey, - InferCursor + InferCursor >, 'queryFn' | 'queryKey' > & { - input: SchemaInputForInfiniteQuery + input: SchemaInputForInfiniteQuery } >, ) => UseSuspenseInfiniteQueryResult usePrefetchQuery: ( - input: SchemaInput, - options?: FetchQueryOptions>, + input: TInput, + options?: FetchQueryOptions, ) => void usePrefetchInfiniteQuery: ( options: PartialOnUndefinedDeep< SetOptional< FetchInfiniteQueryOptions< - SchemaOutput, + TOutput, DefaultError, - SchemaOutput, + TOutput, QueryKey, - InferCursor + InferCursor >, 'queryKey' | 'queryFn' > & { - input: SchemaInputForInfiniteQuery + input: SchemaInputForInfiniteQuery } >, ) => void @@ -130,16 +125,16 @@ export interface ProcedureHooks< useMutation: ( options?: SetOptional< UseMutationOptions< - SchemaOutput, + TOutput, DefaultError, - SchemaInput + TInput >, 'mutationFn' | 'mutationKey' >, ) => UseMutationResult< - SchemaOutput, + TOutput, DefaultError, - SchemaInput + TInput > } @@ -154,14 +149,9 @@ export interface CreateProcedureHooksOptions { path: string[] } -export function createProcedureHooks< - TInputSchema extends Schema = undefined, - TOutputSchema extends Schema = undefined, - TFuncOutput extends - SchemaOutput = SchemaOutput, ->( +export function createProcedureHooks( options: CreateProcedureHooksOptions, -): ProcedureHooks { +): ProcedureHooks { return { [orpcPathSymbol as any]: options.path, diff --git a/packages/react/src/procedure-utils.test-d.ts b/packages/react/src/procedure-utils.test-d.ts index ce43233a4..f3d2c9592 100644 --- a/packages/react/src/procedure-utils.test-d.ts +++ b/packages/react/src/procedure-utils.test-d.ts @@ -8,11 +8,16 @@ import type { } from '../tests/orpc' import { createProcedureUtils } from './procedure-utils' +type UserFindInput = SchemaInput +type User = SchemaOutput + +type UserListInput = SchemaInput +type UserListOutput = SchemaOutput + describe('fetchQuery', () => { const utils = createProcedureUtils< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User >({} as any) it('simple', async () => { @@ -45,9 +50,8 @@ describe('fetchQuery', () => { describe('fetchInfiniteQuery', () => { const utils = createProcedureUtils< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({} as any) it('simple', async () => { @@ -105,9 +109,8 @@ describe('fetchInfiniteQuery', () => { describe('prefetchQuery', () => { const utils = createProcedureUtils< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User >({} as any) it('simple', async () => { @@ -140,9 +143,8 @@ describe('prefetchQuery', () => { describe('prefetchInfiniteQuery', () => { const utils = createProcedureUtils< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({} as any) it('simple', () => { @@ -195,9 +197,8 @@ describe('prefetchInfiniteQuery', () => { describe('getQueryData', () => { const utils = createProcedureUtils< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User >({} as any) it('simple', () => { @@ -215,9 +216,8 @@ describe('getQueryData', () => { describe('getInfiniteQueryData', () => { const utils = createProcedureUtils< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({} as any) it('simple', () => { @@ -242,9 +242,8 @@ describe('getInfiniteQueryData', () => { describe('ensureQueryData', () => { const utils = createProcedureUtils< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User >({} as any) it('simple', async () => { @@ -277,9 +276,8 @@ describe('ensureQueryData', () => { describe('ensureInfiniteQuery', () => { const utils = createProcedureUtils< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({} as any) it('simple', async () => { @@ -339,9 +337,8 @@ describe('ensureInfiniteQuery', () => { describe('getQueryState', () => { const utils = createProcedureUtils< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User >({} as any) it('simple', () => { @@ -359,9 +356,8 @@ describe('getQueryState', () => { describe('getInfiniteQueryState', () => { const utils = createProcedureUtils< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({} as any) it('simple', () => { @@ -388,9 +384,8 @@ describe('getInfiniteQueryState', () => { describe('setQueryData', () => { const utils = createProcedureUtils< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User >({} as any) it('simple', () => { @@ -427,9 +422,8 @@ describe('setQueryData', () => { describe('setInfiniteQueryData', () => { const utils = createProcedureUtils< - typeof UserListInputSchema, - typeof UserListOutputSchema, - SchemaOutput + UserListInput, + UserListOutput >({} as any) it('simple', () => { diff --git a/packages/react/src/procedure-utils.test.tsx b/packages/react/src/procedure-utils.test.tsx index 40c10a3e8..0f8ac7218 100644 --- a/packages/react/src/procedure-utils.test.tsx +++ b/packages/react/src/procedure-utils.test.tsx @@ -1,14 +1,20 @@ -import type { SchemaOutput } from '@orpc/contract' +import type { SchemaInput, SchemaOutput } from '@orpc/contract' import type { UserFindInputSchema, UserListInputSchema, UserListOutputSchema, UserSchema } from '../tests/orpc' import { orpcClient, queryClient } from '../tests/orpc' import { createProcedureUtils } from './procedure-utils' +type UserFindInput = SchemaInput +type User = SchemaOutput + +type UserListInput = SchemaInput +type UserListOutput = SchemaOutput + beforeEach(() => { queryClient.clear() }) describe('fetchQuery', () => { - const utils = createProcedureUtils>({ + const utils = createProcedureUtils({ client: orpcClient.user.find, path: ['user', 'find'], queryClient, @@ -33,13 +39,13 @@ describe('fetchQuery', () => { it('on error', () => { // @ts-expect-error invalid input expect(utils.fetchQuery({ id: {} })).rejects.toThrowError( - 'Validation input failed', + 'Input validation failed', ) }) }) describe('fetchInfiniteQuery', () => { - const utils = createProcedureUtils>({ + const utils = createProcedureUtils({ client: orpcClient.user.list, path: ['user', 'list'], queryClient, @@ -70,12 +76,12 @@ describe('fetchInfiniteQuery', () => { expect( // @ts-expect-error invalid input utils.fetchInfiniteQuery({ input: { keyword: {} } }), - ).rejects.toThrowError('Validation input failed') + ).rejects.toThrowError('Input validation failed') }) }) describe('prefetchQuery', () => { - const utils = createProcedureUtils>({ + const utils = createProcedureUtils({ client: orpcClient.user.find, path: ['user', 'find'], queryClient, @@ -108,7 +114,7 @@ describe('prefetchQuery', () => { }) describe('prefetchInfiniteQuery', () => { - const utils = createProcedureUtils>({ + const utils = createProcedureUtils({ client: orpcClient.user.list, path: ['user', 'list'], queryClient, @@ -143,7 +149,7 @@ describe('prefetchInfiniteQuery', () => { }) describe('ensureQueryData', () => { - const utils = createProcedureUtils>({ + const utils = createProcedureUtils({ client: orpcClient.user.find, path: ['user', 'find'], queryClient, @@ -168,13 +174,13 @@ describe('ensureQueryData', () => { it('on error', () => { // @ts-expect-error invalid input expect(utils.ensureQueryData({ id: {} })).rejects.toThrowError( - 'Validation input failed', + 'Input validation failed', ) }) }) describe('ensureInfiniteQuery', () => { - const utils = createProcedureUtils>({ + const utils = createProcedureUtils({ client: orpcClient.user.list, path: ['user', 'list'], queryClient, @@ -205,12 +211,12 @@ describe('ensureInfiniteQuery', () => { expect( // @ts-expect-error invalid input utils.ensureInfiniteQueryData({ input: { keyword: {} } }), - ).rejects.toThrowError('Validation input failed') + ).rejects.toThrowError('Input validation failed') }) }) describe('getQueryData', () => { - const utils = createProcedureUtils>({ + const utils = createProcedureUtils({ client: orpcClient.user.find, path: ['user', 'find'], queryClient, @@ -224,7 +230,7 @@ describe('getQueryData', () => { }) describe('getInfiniteQueryData', () => { - const utils = createProcedureUtils>({ + const utils = createProcedureUtils({ client: orpcClient.user.list, path: ['user', 'list'], queryClient, @@ -238,7 +244,7 @@ describe('getInfiniteQueryData', () => { }) describe('getQueryState', () => { - const utils = createProcedureUtils>({ + const utils = createProcedureUtils({ client: orpcClient.user.find, path: ['user', 'find'], queryClient, @@ -255,7 +261,7 @@ describe('getQueryState', () => { }) describe('getInfiniteQueryState', () => { - const utils = createProcedureUtils>({ + const utils = createProcedureUtils({ client: orpcClient.user.list, path: ['user', 'list'], queryClient, @@ -272,7 +278,7 @@ describe('getInfiniteQueryState', () => { }) describe('setQueryData', () => { - const utils = createProcedureUtils>({ + const utils = createProcedureUtils({ client: orpcClient.user.find, path: ['user', 'find'], queryClient, @@ -302,7 +308,7 @@ describe('setQueryData', () => { }) describe('getInfiniteQueryData 2', () => { - const utils = createProcedureUtils>({ + const utils = createProcedureUtils({ client: orpcClient.user.list, path: ['user', 'list'], queryClient, diff --git a/packages/react/src/procedure-utils.ts b/packages/react/src/procedure-utils.ts index df65341eb..e27c7114b 100644 --- a/packages/react/src/procedure-utils.ts +++ b/packages/react/src/procedure-utils.ts @@ -1,5 +1,4 @@ -import type { Schema, SchemaInput, SchemaOutput } from '@orpc/contract' -import type { Caller } from '@orpc/server' +import type { ProcedureClient } from '@orpc/server' import type { PartialOnUndefinedDeep, SetOptional } from '@orpc/shared' import type { DefaultError, @@ -17,44 +16,40 @@ import type { import type { InferCursor, SchemaInputForInfiniteQuery } from './types' import { getQueryKeyFromPath } from './tanstack-key' -export interface ProcedureUtils< - TInputSchema extends Schema, - TOutputSchema extends Schema, - TFuncOutput extends SchemaOutput, -> { +export interface ProcedureUtils { fetchQuery: ( - input: SchemaInput, + input: TInput, options?: SetOptional< - FetchQueryOptions>, + FetchQueryOptions, 'queryKey' | 'queryFn' >, - ) => Promise> + ) => Promise fetchInfiniteQuery: ( options: PartialOnUndefinedDeep< SetOptional< FetchInfiniteQueryOptions< - SchemaOutput, + TOutput, DefaultError, - SchemaOutput, + TOutput, QueryKey, - InferCursor + InferCursor >, 'queryKey' | 'queryFn' > & { - input: SchemaInputForInfiniteQuery + input: SchemaInputForInfiniteQuery } >, ) => Promise< InfiniteData< - SchemaOutput, - InferCursor + TOutput, + InferCursor > > prefetchQuery: ( - input: SchemaInput, + input: TInput, options?: SetOptional< - FetchQueryOptions>, + FetchQueryOptions, 'queryKey' | 'queryFn' >, ) => Promise @@ -62,108 +57,104 @@ export interface ProcedureUtils< options: PartialOnUndefinedDeep< SetOptional< FetchInfiniteQueryOptions< - SchemaOutput, + TOutput, DefaultError, - SchemaOutput, + TOutput, QueryKey, - InferCursor + InferCursor >, 'queryKey' | 'queryFn' > & { - input: SchemaInputForInfiniteQuery + input: SchemaInputForInfiniteQuery } >, ) => Promise getQueryData: ( - input: SchemaInput, - ) => SchemaOutput | undefined + input: TInput, + ) => TOutput | undefined getInfiniteQueryData: ( - input: SchemaInputForInfiniteQuery, + input: SchemaInputForInfiniteQuery, ) => | InfiniteData< - SchemaOutput, - InferCursor + TOutput, + InferCursor > | undefined ensureQueryData: ( - input: SchemaInput, + input: TInput, options?: SetOptional< - EnsureQueryDataOptions>, + EnsureQueryDataOptions, 'queryFn' | 'queryKey' >, - ) => Promise> + ) => Promise ensureInfiniteQueryData: ( options: PartialOnUndefinedDeep< SetOptional< EnsureInfiniteQueryDataOptions< - SchemaOutput, + TOutput, DefaultError, - SchemaOutput, + TOutput, QueryKey, - InferCursor + InferCursor >, 'queryKey' | 'queryFn' > & { - input: SchemaInputForInfiniteQuery + input: SchemaInputForInfiniteQuery } >, ) => Promise< InfiniteData< - SchemaOutput, - InferCursor + TOutput, + InferCursor > > getQueryState: ( - input: SchemaInput, - ) => QueryState> | undefined + input: TInput, + ) => QueryState | undefined getInfiniteQueryState: ( - input: SchemaInputForInfiniteQuery, + input: SchemaInputForInfiniteQuery, ) => | QueryState< InfiniteData< - SchemaOutput, - InferCursor + TOutput, + InferCursor > > | undefined setQueryData: ( - input: SchemaInput, + input: TInput, updater: Updater< - SchemaOutput | undefined, - SchemaOutput | undefined + TOutput | undefined, + TOutput | undefined >, options?: SetDataOptions, - ) => SchemaOutput | undefined + ) => TOutput | undefined setInfiniteQueryData: ( - input: SchemaInputForInfiniteQuery, + input: SchemaInputForInfiniteQuery, updater: Updater< | InfiniteData< - SchemaOutput, - InferCursor + TOutput, + InferCursor > | undefined, | InfiniteData< - SchemaOutput, - InferCursor + TOutput, + InferCursor > | undefined >, options?: SetDataOptions, ) => | InfiniteData< - SchemaOutput, - InferCursor + TOutput, + InferCursor > | undefined } -export interface CreateProcedureUtilsOptions< - TInputSchema extends Schema, - TOutputSchema extends Schema, - TFuncOutput , -> { - client: Caller, SchemaOutput> +export interface CreateProcedureUtilsOptions { + client: ProcedureClient queryClient: QueryClient /** @@ -172,17 +163,9 @@ export interface CreateProcedureUtilsOptions< path: string[] } -export function createProcedureUtils< - TInputSchema extends Schema, - TOutputSchema extends Schema, - TFuncOutput extends SchemaOutput, ->( - options: CreateProcedureUtilsOptions< - TInputSchema, - TOutputSchema, - TFuncOutput - >, -): ProcedureUtils { +export function createProcedureUtils( + options: CreateProcedureUtilsOptions, +): ProcedureUtils { return { fetchQuery(input, options_) { return options.queryClient.fetchQuery({ diff --git a/packages/react/src/react-context.ts b/packages/react/src/react-context.ts index 18939605b..b895fab6a 100644 --- a/packages/react/src/react-context.ts +++ b/packages/react/src/react-context.ts @@ -1,29 +1,23 @@ -import type { RouterClient } from '@orpc/client' -import type { ContractRouter } from '@orpc/contract' -import type { Router } from '@orpc/server' +import type { RouterClient } from '@orpc/server' import type { QueryClient } from '@tanstack/react-query' import { type Context, createContext, useContext } from 'react' -export interface ORPCContextValue< - TRouter extends ContractRouter | Router, -> { - client: RouterClient +export interface ORPCContextValue> { + client: T queryClient: QueryClient } -export type ORPCContext> = Context< - ORPCContextValue | undefined +export type ORPCContext> = Context< + ORPCContextValue | undefined > -export function createORPCContext< - TRouter extends ContractRouter | Router, ->(): ORPCContext { +export function createORPCContext>(): ORPCContext { return createContext(undefined as any) } -export function useORPCContext>( - context: ORPCContext, -): ORPCContextValue { +export function useORPCContext>( + context: ORPCContext, +): ORPCContextValue { const value = useContext(context) if (!value) { diff --git a/packages/react/src/react-hooks.ts b/packages/react/src/react-hooks.ts index 1be8c05be..630ddbd64 100644 --- a/packages/react/src/react-hooks.ts +++ b/packages/react/src/react-hooks.ts @@ -1,39 +1,18 @@ -import type { - ContractProcedure, - ContractRouter, - SchemaOutput, -} from '@orpc/contract' -import type { Lazy, Procedure, Router } from '@orpc/server' +import type { ProcedureClient, RouterClient } from '@orpc/server' import type { ORPCContext } from './react-context' import { createGeneralHooks, type GeneralHooks } from './general-hooks' import { orpcPathSymbol } from './orpc-path' import { createProcedureHooks, type ProcedureHooks } from './procedure-hooks' -export type ORPCHooksWithContractRouter = { - [K in keyof TRouter]: TRouter[K] extends ContractProcedure< - infer UInputSchema, - infer UOutputSchema - > - ? ProcedureHooks> & GeneralHooks> - : TRouter[K] extends ContractRouter - ? ORPCHooksWithContractRouter - : never -} & GeneralHooks +export type ORPCHooks> = + T extends ProcedureClient + ? ProcedureHooks & GeneralHooks + : { + [K in keyof T]: T[K] extends RouterClient ? ORPCHooks : never + } & GeneralHooks -export type ORPCHooksWithRouter> = { - [K in keyof TRouter]: TRouter[K] extends - | Procedure - | Lazy> - ? ProcedureHooks & GeneralHooks - : TRouter[K] extends Router - ? ORPCHooksWithRouter - : never -} & GeneralHooks - -export interface CreateORPCHooksOptions< - TRouter extends ContractRouter | Router, -> { - context: ORPCContext +export interface CreateORPCHooksOptions> { + context: ORPCContext /** * The path of the router. @@ -43,23 +22,16 @@ export interface CreateORPCHooksOptions< path?: string[] } -export function createORPCHooks>( - options: CreateORPCHooksOptions, -): TRouter extends Router - ? ORPCHooksWithRouter - : TRouter extends ContractRouter - ? ORPCHooksWithContractRouter - : never { +export function createORPCHooks>( + options: CreateORPCHooksOptions, +): ORPCHooks { const path = options.path ?? [] const generalHooks = createGeneralHooks({ context: options.context, path }) - // for sure root is not procedure, so do not it procedure hooks on root - const procedureHooks = path.length - ? createProcedureHooks({ - context: options.context, - path, - }) - : {} + const procedureHooks = createProcedureHooks({ + context: options.context, + path, + }) return new Proxy( { diff --git a/packages/react/src/react-utils.ts b/packages/react/src/react-utils.ts index c2ba056e9..d43720571 100644 --- a/packages/react/src/react-utils.ts +++ b/packages/react/src/react-utils.ts @@ -1,35 +1,17 @@ -import type { - ContractProcedure, - ContractRouter, - SchemaOutput, -} from '@orpc/contract' -import type { Lazy, Procedure, Router } from '@orpc/server' +import type { ProcedureClient, RouterClient } from '@orpc/server' import type { ORPCContextValue } from './react-context' import { createGeneralUtils, type GeneralUtils } from './general-utils' import { createProcedureUtils, type ProcedureUtils } from './procedure-utils' -export type ORPCUtilsWithContractRouter = { - [K in keyof TRouter]: TRouter[K] extends ContractProcedure - ? ProcedureUtils> & GeneralUtils> - : TRouter[K] extends ContractRouter - ? ORPCUtilsWithContractRouter - : never -} & GeneralUtils +export type ORPCUtils> = + T extends ProcedureClient + ? ProcedureUtils & GeneralUtils + : { + [K in keyof T]: T[K] extends RouterClient ? ORPCUtils : never + } & GeneralUtils -export type ORPCUtilsWithRouter> = { - [K in keyof TRouter]: TRouter[K] extends - | Procedure - | Lazy> - ? ProcedureUtils & GeneralUtils - : TRouter[K] extends Router - ? ORPCUtilsWithRouter - : never -} & GeneralUtils - -export interface CreateORPCUtilsOptions< - TRouter extends ContractRouter | Router, -> { - contextValue: ORPCContextValue +export interface CreateORPCUtilsOptions> { + contextValue: ORPCContextValue /** * The path of the router. @@ -39,13 +21,9 @@ export interface CreateORPCUtilsOptions< path?: string[] } -export function createORPCUtils>( - options: CreateORPCUtilsOptions, -): TRouter extends Router - ? ORPCUtilsWithRouter - : TRouter extends ContractRouter - ? ORPCUtilsWithContractRouter - : never { +export function createORPCUtils>( + options: CreateORPCUtilsOptions, +): ORPCUtils { const path = options.path ?? [] const client = options.contextValue.client as any diff --git a/packages/react/src/react.test-d.ts b/packages/react/src/react.test-d.ts index efbc95d5a..caa5ba92e 100644 --- a/packages/react/src/react.test-d.ts +++ b/packages/react/src/react.test-d.ts @@ -1,4 +1,4 @@ -import type { SchemaOutput } from '@orpc/contract' +import type { SchemaInput, SchemaOutput } from '@orpc/contract' import type { QueryClient } from '@tanstack/react-query' import type { GeneralHooks } from './general-hooks' import type { GeneralUtils } from './general-utils' @@ -13,33 +13,34 @@ import { } from '../tests/orpc' import { useQueriesFactory } from './use-queries/hook' +type UserFindInput = SchemaInput +type User = SchemaOutput + describe('useUtils', () => { const utils = orpc.useUtils() it('router level', () => { expectTypeOf(utils).toMatchTypeOf< - GeneralUtils + GeneralUtils >() expectTypeOf(utils.user).toMatchTypeOf< - GeneralUtils + GeneralUtils >() }) it('procedure level', () => { expectTypeOf(utils.user.find).toMatchTypeOf< GeneralUtils< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User > >() expectTypeOf(utils.user.find).toMatchTypeOf< ProcedureUtils< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User > >() }) @@ -61,28 +62,26 @@ it('useQueries', () => { describe('hooks', () => { it('router level', () => { expectTypeOf(orpc).toMatchTypeOf< - GeneralHooks + GeneralHooks >() expectTypeOf(orpc.user).toMatchTypeOf< - GeneralHooks + GeneralHooks >() }) it('procedure level', () => { expectTypeOf(orpc.user.find).toMatchTypeOf< GeneralHooks< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User > >() expectTypeOf(orpc.user.find).toMatchTypeOf< ProcedureHooks< - typeof UserFindInputSchema, - typeof UserSchema, - SchemaOutput + UserFindInput, + User > >() }) diff --git a/packages/react/src/react.tsx b/packages/react/src/react.tsx index b865de4fd..8efdc08d6 100644 --- a/packages/react/src/react.tsx +++ b/packages/react/src/react.tsx @@ -1,52 +1,25 @@ -import type { ContractRouter } from '@orpc/contract' -import type { Router } from '@orpc/server' -import { - createORPCContext, - type ORPCContext, - type ORPCContextValue, - useORPCContext, -} from './react-context' -import { - createORPCHooks, - type ORPCHooksWithContractRouter, - type ORPCHooksWithRouter, -} from './react-hooks' -import { - createORPCUtils, - type ORPCUtilsWithContractRouter, - type ORPCUtilsWithRouter, -} from './react-utils' -import { - useQueriesFactory, - type UseQueriesWithContractRouter, - type UseQueriesWithRouter, -} from './use-queries/hook' +import type { RouterClient } from '@orpc/server' +import type { ORPCContext, ORPCContextValue } from './react-context' +import type { ORPCHooks } from './react-hooks' +import type { ORPCUtils } from './react-utils' +import type { UseQueries } from './use-queries/hook' +import { createORPCContext, useORPCContext } from './react-context' +import { createORPCHooks } from './react-hooks' +import { createORPCUtils } from './react-utils' +import { useQueriesFactory } from './use-queries/hook' -export type ORPCReactWithContractRouter = - ORPCHooksWithContractRouter & { - useContext: () => ORPCContextValue - useUtils: () => ORPCUtilsWithContractRouter - useQueries: UseQueriesWithContractRouter +export type ORPCReact> = + ORPCHooks & { + useContext: () => ORPCContextValue + useUtils: () => ORPCUtils + useQueries: UseQueries } -export type ORPCReactWithRouter> = - ORPCHooksWithRouter & { - useContext: () => ORPCContextValue - useUtils: () => ORPCUtilsWithRouter - useQueries: UseQueriesWithRouter - } - -export function createORPCReact< - TRouter extends ContractRouter | Router, ->(): { - orpc: TRouter extends Router - ? ORPCReactWithRouter - : TRouter extends ContractRouter - ? ORPCReactWithContractRouter - : never - ORPCContext: ORPCContext +export function createORPCReact>(): { + orpc: ORPCReact + ORPCContext: ORPCContext } { - const Context = createORPCContext() + const Context = createORPCContext() const useContext = () => useORPCContext(Context) const useUtils = () => createORPCUtils({ contextValue: useContext() }) // eslint-disable-next-line react-hooks/rules-of-hooks @@ -70,7 +43,7 @@ export function createORPCReact< return new Proxy(value, { get(_, key) { - return Reflect.get(nextHooks, key) + return Reflect.get(nextHooks as any, key) }, }) }, diff --git a/packages/react/src/tanstack-key.ts b/packages/react/src/tanstack-key.ts index 723b49d85..14038ca49 100644 --- a/packages/react/src/tanstack-key.ts +++ b/packages/react/src/tanstack-key.ts @@ -1,11 +1,7 @@ -import type { SchemaInput } from '@orpc/contract' import type { PartialDeep } from '@orpc/shared' import type { MutationKey, QueryKey } from '@tanstack/react-query' import type { ProcedureHooks } from './procedure-hooks' -import type { - ORPCHooksWithContractRouter, - ORPCHooksWithRouter, -} from './react-hooks' +import type { ORPCHooks } from './react-hooks' import { getORPCPath } from './orpc-path' export type QueryType = 'query' | 'infinite' | undefined @@ -17,14 +13,13 @@ export interface GetQueryKeyOptions { export function getQueryKey< T extends - | ORPCHooksWithContractRouter - | ORPCHooksWithRouter - | ProcedureHooks, + | ORPCHooks + | ProcedureHooks, >( orpc: T, options?: GetQueryKeyOptions< - T extends ProcedureHooks - ? PartialDeep> + T extends ProcedureHooks + ? PartialDeep : unknown >, ): QueryKey { @@ -51,9 +46,8 @@ export function getQueryKeyFromPath( export function getMutationKey< T extends - | ORPCHooksWithContractRouter - | ORPCHooksWithRouter - | ProcedureHooks, + | ORPCHooks + | ProcedureHooks, >(orpc: T): MutationKey { const path = getORPCPath(orpc) return getMutationKeyFromPath(path) diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 754b2874c..aee47a5bd 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,9 +1,3 @@ -import type { Schema, SchemaInput } from '@orpc/contract' +export type SchemaInputForInfiniteQuery = Omit -export type SchemaInputForInfiniteQuery = Omit< - SchemaInput, - 'cursor' -> - -export type InferCursor = - SchemaInput extends { cursor?: any } ? SchemaInput['cursor'] : never +export type InferCursor = TInput extends { cursor?: any } ? TInput['cursor'] : never diff --git a/packages/react/src/use-queries/builder.test-d.ts b/packages/react/src/use-queries/builder.test-d.ts index 28195617f..3998456b0 100644 --- a/packages/react/src/use-queries/builder.test-d.ts +++ b/packages/react/src/use-queries/builder.test-d.ts @@ -1,11 +1,9 @@ -import type { SchemaOutput } from '@orpc/contract' import type { Promisable } from '@orpc/shared' -import type { UserFindInputSchema, UserSchema } from '../../tests/orpc' import { orpcClient } from '../../tests/orpc' import { createUseQueriesBuilder } from './builder' it('createUseQueriesBuilder', () => { - const builder = createUseQueriesBuilder>({ + const builder = createUseQueriesBuilder({ client: orpcClient.user.find, path: ['user', 'find'], }) diff --git a/packages/react/src/use-queries/builder.test.ts b/packages/react/src/use-queries/builder.test.ts index abb40ae02..769d41da4 100644 --- a/packages/react/src/use-queries/builder.test.ts +++ b/packages/react/src/use-queries/builder.test.ts @@ -1,10 +1,8 @@ -import type { SchemaOutput } from '@orpc/contract' -import type { UserFindInputSchema, UserSchema } from '../../tests/orpc' import { orpcClient } from '../../tests/orpc' import { createUseQueriesBuilder } from './builder' it('createUseQueriesBuilder', async () => { - const builder = createUseQueriesBuilder>({ + const builder = createUseQueriesBuilder({ client: orpcClient.user.find, path: ['user', 'find'], }) diff --git a/packages/react/src/use-queries/builder.ts b/packages/react/src/use-queries/builder.ts index 73e9a2bd6..46b59c283 100644 --- a/packages/react/src/use-queries/builder.ts +++ b/packages/react/src/use-queries/builder.ts @@ -1,5 +1,4 @@ -import type { Schema, SchemaInput, SchemaOutput } from '@orpc/contract' -import type { Caller } from '@orpc/server' +import type { ProcedureClient } from '@orpc/server' import type { SetOptional } from '@orpc/shared' import type { DefaultError, @@ -22,26 +21,18 @@ type UseQueryOptionsForUseQueries< placeholderData?: TQueryFnData | QueriesPlaceholderDataFunction } -export interface UseQueriesBuilder< - TInputSchema extends Schema, - TOutputSchema extends Schema, - TFuncOutput extends SchemaOutput, -> { +export interface UseQueriesBuilder { ( - input: SchemaInput, + input: TInput, options?: SetOptional< - UseQueryOptionsForUseQueries>, + UseQueryOptionsForUseQueries, 'queryFn' | 'queryKey' >, - ): UseQueryOptionsForUseQueries> + ): UseQueryOptionsForUseQueries } -export interface CreateUseQueriesBuilderOptions< - TInputSchema extends Schema, - TOutputSchema extends Schema, - TFuncOutput extends SchemaOutput, -> { - client: Caller, SchemaOutput> +export interface CreateUseQueriesBuilderOptions { + client: ProcedureClient /** * The path of procedure on server @@ -49,18 +40,9 @@ export interface CreateUseQueriesBuilderOptions< path: string[] } -export function createUseQueriesBuilder< - TInputSchema extends Schema = undefined, - TOutputSchema extends Schema = undefined, - TFuncOutput extends - SchemaOutput = SchemaOutput, ->( - options: CreateUseQueriesBuilderOptions< - TInputSchema, - TOutputSchema, - TFuncOutput - >, -): UseQueriesBuilder { +export function createUseQueriesBuilder( + options: CreateUseQueriesBuilderOptions, +): UseQueriesBuilder { return (input, options_) => { return { queryKey: getQueryKeyFromPath(options.path, { input, type: 'query' }), diff --git a/packages/react/src/use-queries/builders.test-d.ts b/packages/react/src/use-queries/builders.test-d.ts index 62cccc85f..5becb2803 100644 --- a/packages/react/src/use-queries/builders.test-d.ts +++ b/packages/react/src/use-queries/builders.test-d.ts @@ -1,5 +1,3 @@ -import type { SchemaOutput } from '@orpc/contract' -import type { UserFindInputSchema, UserListInputSchema, UserListOutputSchema, UserSchema } from '../../tests/orpc' import { orpcClient } from '../../tests/orpc' import { createUseQueriesBuilder } from './builder' import { createUseQueriesBuilders } from './builders' @@ -10,14 +8,14 @@ it('createUseQueriesBuilders', () => { }) expectTypeOf(builder.user.find).toEqualTypeOf( - createUseQueriesBuilder>({ + createUseQueriesBuilder({ client: orpcClient.user.find, path: ['user', 'find'], }), ) expectTypeOf(builder.user.list).toEqualTypeOf( - createUseQueriesBuilder>({ + createUseQueriesBuilder({ client: orpcClient.user.list, path: ['user', 'list'], }), diff --git a/packages/react/src/use-queries/builders.test.tsx b/packages/react/src/use-queries/builders.test.tsx index 8bc22be2d..d0ee0d18f 100644 --- a/packages/react/src/use-queries/builders.test.tsx +++ b/packages/react/src/use-queries/builders.test.tsx @@ -1,16 +1,14 @@ -import type { SchemaOutput } from '@orpc/contract' -import type { appRouter, UserFindInputSchema, UserListInputSchema, UserListOutputSchema, UserSchema } from '../../tests/orpc' import { orpcClient } from '../../tests/orpc' import { createUseQueriesBuilder } from './builder' import { createUseQueriesBuilders } from './builders' it('createUseQueriesBuilders', async () => { - const builder = createUseQueriesBuilders({ + const builder = createUseQueriesBuilders({ client: orpcClient, }) const e1 = builder.user.find({ id: '123' }) - const a1 = createUseQueriesBuilder>({ + const a1 = createUseQueriesBuilder({ client: orpcClient.user.find, path: ['user', 'find'], })({ id: '123' }) @@ -20,7 +18,7 @@ it('createUseQueriesBuilders', async () => { expect(await (e1 as any).queryFn({})).toEqual(await (a1 as any).queryFn({})) const e2 = builder.user.list({}) - const a2 = createUseQueriesBuilder>({ + const a2 = createUseQueriesBuilder({ client: orpcClient.user.list, path: ['user', 'list'], })({}) diff --git a/packages/react/src/use-queries/builders.ts b/packages/react/src/use-queries/builders.ts index 3ded05c5e..afed01a7c 100644 --- a/packages/react/src/use-queries/builders.ts +++ b/packages/react/src/use-queries/builders.ts @@ -1,48 +1,16 @@ -import type { RouterClient } from '@orpc/client' -import type { - ContractProcedure, - ContractRouter, - SchemaOutput, -} from '@orpc/contract' -import type { Procedure, Router } from '@orpc/server' +import type { ProcedureClient, RouterClient } from '@orpc/server' import type {} from '@tanstack/react-query' import { createUseQueriesBuilder, type UseQueriesBuilder } from './builder' -export type UseQueriesBuildersWithContractRouter< - TRouter extends ContractRouter, -> = { - [K in keyof TRouter]: TRouter[K] extends ContractProcedure< - infer UInputSchema, - infer UOutputSchema - > - ? UseQueriesBuilder< - UInputSchema, - UOutputSchema, - SchemaOutput - > - : TRouter[K] extends ContractRouter - ? UseQueriesBuildersWithContractRouter - : never -} - -export type UseQueriesBuildersWithRouter> = { - [K in keyof TRouter]: TRouter[K] extends Procedure< - any, - any, - infer UInputSchema, - infer UOutputSchema, - infer UFuncOutput - > - ? UseQueriesBuilder - : TRouter[K] extends Router - ? UseQueriesBuildersWithRouter - : never -} +export type UseQueriesBuilders> = + T extends ProcedureClient + ? UseQueriesBuilder + : { + [K in keyof T]: T[K] extends RouterClient ? UseQueriesBuilders : never + } -export interface CreateUseQueriesBuildersOptions< - TRouter extends Router | ContractRouter, -> { - client: RouterClient +export interface CreateUseQueriesBuildersOptions> { + client: T /** * The path of router on server @@ -50,15 +18,9 @@ export interface CreateUseQueriesBuildersOptions< path?: string[] } -export function createUseQueriesBuilders< - TRouter extends Router | ContractRouter, ->( - options: CreateUseQueriesBuildersOptions, -): TRouter extends Router - ? UseQueriesBuildersWithRouter - : TRouter extends ContractRouter - ? UseQueriesBuildersWithContractRouter - : never { +export function createUseQueriesBuilders>( + options: CreateUseQueriesBuildersOptions, +): UseQueriesBuilders { const path = options.path ?? [] const client = options.client as any diff --git a/packages/react/src/use-queries/hook.ts b/packages/react/src/use-queries/hook.ts index 3ebb1b50e..07899e125 100644 --- a/packages/react/src/use-queries/hook.ts +++ b/packages/react/src/use-queries/hook.ts @@ -1,51 +1,33 @@ -import type { ContractRouter } from '@orpc/contract' -import type { Router } from '@orpc/server' -import { - type QueriesOptions, - type QueriesResults, - useQueries, +import type { RouterClient } from '@orpc/server' +import type { + QueriesOptions, + QueriesResults, } from '@tanstack/react-query' -import { type ORPCContext, useORPCContext } from '../react-context' -import { - createUseQueriesBuilders, - type UseQueriesBuildersWithContractRouter, - type UseQueriesBuildersWithRouter, -} from './builders' +import type { ORPCContext } from '../react-context' +import type { UseQueriesBuilders } from './builders' +import { useQueries } from '@tanstack/react-query' +import { useORPCContext } from '../react-context' +import { createUseQueriesBuilders } from './builders' -export interface UseQueriesWithContractRouter { - = [], TCombinedResult = QueriesResults>( +export interface UseQueries> { + = [], UCombinedResult = QueriesResults>( build: ( - builders: UseQueriesBuildersWithContractRouter, - ) => [...QueriesOptions], - combine?: (result: QueriesResults) => TCombinedResult, - ): TCombinedResult + builders: UseQueriesBuilders, + ) => [...QueriesOptions], + combine?: (result: QueriesResults) => UCombinedResult, + ): UCombinedResult } -export interface UseQueriesWithRouter> { - = [], TCombinedResult = QueriesResults>( - build: ( - builders: UseQueriesBuildersWithRouter, - ) => [...QueriesOptions], - combine?: (result: QueriesResults) => TCombinedResult, - ): TCombinedResult -} - -export interface UseQueriesFactoryOptions< - TRouter extends Router | ContractRouter, -> { - context: ORPCContext +export interface UseQueriesFactoryOptions> { + context: ORPCContext } -export function useQueriesFactory | ContractRouter>( - options: UseQueriesFactoryOptions, -): TRouter extends Router - ? UseQueriesWithRouter - : TRouter extends ContractRouter - ? UseQueriesWithContractRouter - : never { +export function useQueriesFactory>( + options: UseQueriesFactoryOptions, +): UseQueries { const Hook = (build: any, combine?: any): any => { const orpc = useORPCContext(options.context) - const builders = createUseQueriesBuilders({ client: orpc.client as any }) + const builders = createUseQueriesBuilders({ client: orpc.client }) return useQueries({ queries: build(builders), @@ -53,5 +35,5 @@ export function useQueriesFactory | ContractRouter>( }) } - return Hook as any + return Hook } diff --git a/packages/react/tests/orpc.tsx b/packages/react/tests/orpc.tsx index 3dc6cdb6a..e45b9a60f 100644 --- a/packages/react/tests/orpc.tsx +++ b/packages/react/tests/orpc.tsx @@ -1,4 +1,5 @@ -import { createORPCClient } from '@orpc/client' +import type { RouterClient } from '@orpc/server' +import { createORPCFetchClient } from '@orpc/client' import { os } from '@orpc/server' import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' @@ -92,7 +93,7 @@ export const appRouter = orpcServer.router({ }, }) -export const orpcClient = createORPCClient({ +export const orpcClient = createORPCFetchClient({ baseURL: 'http://localhost:3000', async fetch(...args) { @@ -107,7 +108,7 @@ export const orpcClient = createORPCClient({ }, }) -export const { orpc, ORPCContext } = createORPCReact() +export const { orpc, ORPCContext } = createORPCReact>() export const queryClient = new QueryClient({ defaultOptions: { diff --git a/packages/server/package.json b/packages/server/package.json index 2e4163028..0b8fdfefd 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -56,6 +56,6 @@ "@orpc/transformer": "workspace:*" }, "devDependencies": { - "@orpc/openapi": "workspace:*" + "zod": "^3.24.1" } } diff --git a/packages/server/src/builder.test-d.ts b/packages/server/src/builder.test-d.ts new file mode 100644 index 000000000..54d825ea5 --- /dev/null +++ b/packages/server/src/builder.test-d.ts @@ -0,0 +1,210 @@ +import type { ChainableImplementer } from './implementer-chainable' +import type { DecoratedLazy } from './lazy-decorated' +import type { Middleware, MiddlewareMeta } from './middleware' +import type { DecoratedMiddleware } from './middleware-decorated' +import type { ANY_PROCEDURE, Procedure } from './procedure' +import type { ProcedureBuilder } from './procedure-builder' +import type { DecoratedProcedure } from './procedure-decorated' +import type { AdaptedRouter, RouterBuilder } from './router-builder' +import type { Meta, WELL_CONTEXT } from './types' +import { oc } from '@orpc/contract' +import { z } from 'zod' +import { Builder } from './builder' + +const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + +const builder = new Builder<{ auth: boolean }, { db: string }>({}) + +describe('self chainable', () => { + it('define context', () => { + expectTypeOf(builder.context()).toEqualTypeOf>() + expectTypeOf(builder.context<{ db: string }>()).toEqualTypeOf>() + expectTypeOf(builder.context<{ auth: boolean }>()).toEqualTypeOf>() + }) + + it('use middleware', () => { + expectTypeOf( + builder.use({} as Middleware<{ auth: boolean }, undefined, unknown, unknown>), + ).toEqualTypeOf>() + expectTypeOf( + builder.use({} as Middleware<{ auth: boolean }, { dev: string }, unknown, unknown>), + ).toEqualTypeOf>() + expectTypeOf( + builder.use({} as Middleware), + ).toEqualTypeOf>() + + // @ts-expect-error - context is not match + builder.use({} as Middleware<{ auth: 'invalid' }, undefined, unknown, unknown>) + + // @ts-expect-error - extra context is conflict with context + builder.use({} as Middleware) + + // @ts-expect-error - expected input is not match with unknown + builder.use({} as Middleware) + + // @ts-expect-error - expected output is not match with unknown + builder.use({} as Middleware) + + // @ts-expect-error - invalid middleware + builder.use(() => {}) + }) +}) + +describe('create middleware', () => { + it('works', () => { + const mid = builder.middleware((input, context, meta) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & { db: string }>() + expectTypeOf(meta).toEqualTypeOf>() + + return meta.next({ + context: { + dev: true, + }, + }) + }) + + expectTypeOf(mid).toEqualTypeOf< + DecoratedMiddleware<{ auth: boolean } & { db: string }, { dev: boolean }, unknown, any> + >() + + // @ts-expect-error - conflict extra context and context + builder.middleware((input, context, meta) => meta.next({ + context: { + auth: 'invalid', + }, + })) + }) +}) + +describe('to ProcedureBuilder', () => { + it('route', () => { + expectTypeOf(builder.route({ path: '/test', method: 'GET' })).toEqualTypeOf< + ProcedureBuilder<{ auth: boolean }, { db: string }, undefined, undefined> + >() + + // @ts-expect-error - invalid path + builder.route({ path: '' }) + + // @ts-expect-error - invalid method + builder.route({ method: '' }) + }) + + it('input', () => { + expectTypeOf(builder.input(schema, { val: '123' })).toEqualTypeOf< + ProcedureBuilder<{ auth: boolean }, { db: string }, typeof schema, undefined> + >() + + builder.input(schema) + // @ts-expect-error - invalid example + builder.input(schema, { val: 123 }) + // @ts-expect-error - invalid schema + builder.input({}) + }) + + it('output', () => { + expectTypeOf(builder.output(schema, { val: 123 })).toEqualTypeOf< + ProcedureBuilder<{ auth: boolean }, { db: string }, undefined, typeof schema> + >() + + builder.output(schema) + // @ts-expect-error - invalid example + builder.output(schema, { val: '123' }) + // @ts-expect-error - invalid schema + builder.output({}) + }) +}) + +describe('to DecoratedProcedure', () => { + it('func', () => { + expectTypeOf(builder.func((input, context, meta) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & { db: string }>() + expectTypeOf(meta).toEqualTypeOf() + + return 456 + })).toMatchTypeOf< + DecoratedProcedure<{ auth: boolean }, { db: string }, undefined, undefined, number> + >() + }) +}) + +describe('to RouterBuilder', () => { + it('prefix', () => { + expectTypeOf(builder.prefix('/test')).toEqualTypeOf< + RouterBuilder<{ auth: boolean }, { db: string }> + >() + + // @ts-expect-error invalid prefix + builder.prefix('') + }) + + it('tags', () => { + expectTypeOf(builder.tag('test', 'test2')).toEqualTypeOf< + RouterBuilder<{ auth: boolean }, { db: string }> + >() + + // @ts-expect-error invalid tags + builder.tag(123) + }) +}) + +it('to AdaptedRouter', () => { + const ping = {} as Procedure<{ auth: boolean, db: string }, undefined, undefined, undefined, unknown> + const router = { + ping, + nested: { + ping, + }, + } + expectTypeOf(builder.router(router)).toEqualTypeOf< + AdaptedRouter<{ auth: boolean }, typeof router> + >() + + // @ts-expect-error - context is not match + builder.router({ ping: {} as Procedure<{ invalid: true }, undefined, undefined, undefined, unknown> }) +}) + +it('to DecoratedLazy', () => { + const ping = {} as Procedure<{ auth: boolean, db: string }, undefined, undefined, undefined, unknown> + const router = { + ping, + nested: { + ping, + }, + } + + expectTypeOf( + builder.lazy(() => Promise.resolve({ default: router })), + ).toEqualTypeOf< + DecoratedLazy> + >() + + // @ts-expect-error - context is not match + builder.lazy(() => Promise.resolve({ default: { + ping: {} as Procedure<{ invalid: true }, undefined, undefined, undefined, unknown>, + } })) +}) + +it('to ChainableImplementer', () => { + const schema = z.object({ val: z.string().transform(val => Number(val)) }) + + const ping = oc.input(schema).output(schema) + const pong = oc.route({ method: 'GET', path: '/ping' }) + + const contract = oc.router({ + ping, + pong, + nested: { + ping, + pong, + }, + }) + + expectTypeOf(builder.contract(contract)).toEqualTypeOf< + ChainableImplementer<{ auth: boolean }, { db: string }, typeof contract> + >() + + /// @ts-expect-error - context is not match + builder.contract({} as ANY_PROCEDURE) +}) diff --git a/packages/server/src/builder.test.ts b/packages/server/src/builder.test.ts index d693215a2..e00e9d79b 100644 --- a/packages/server/src/builder.test.ts +++ b/packages/server/src/builder.test.ts @@ -1,373 +1,176 @@ -import type { - Builder, - DecoratedMiddleware, - DecoratedProcedure, - Meta, - MiddlewareMeta, -} from '.' -import { oc } from '@orpc/contract' import { z } from 'zod' -import { - isProcedure, - os, - ProcedureBuilder, - ProcedureImplementer, - RouterImplementer, -} from '.' +import { Builder } from './builder' +import { createChainableImplementer } from './implementer-chainable' +import { isProcedure } from './procedure' +import { ProcedureBuilder } from './procedure-builder' import { RouterBuilder } from './router-builder' -it('context method', () => { - expectTypeOf< - typeof os extends Builder ? TContext : never - >().toEqualTypeOf>() +vi.mock('./router-builder', () => ({ + RouterBuilder: vi.fn(() => ({ + router: vi.fn(() => ({ mocked: true })), + lazy: vi.fn(() => ({ mocked: true })), + })), +})) - const os2 = os.context<{ foo: 'bar' }>() +vi.mock('./implementer-chainable', () => ({ + createChainableImplementer: vi.fn(() => ({ mocked: true })), +})) - expectTypeOf< - typeof os2 extends Builder ? TContext : never - >().toEqualTypeOf<{ foo: 'bar' }>() +beforeEach(() => { + vi.clearAllMocks() +}) - const os3 = os.context<{ foo: 'bar' }>().context() +const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) - expectTypeOf< - typeof os3 extends Builder ? TContext : never - >().toEqualTypeOf<{ foo: 'bar' }>() +const mid = vi.fn() +const builder = new Builder({ + middlewares: [mid], }) -describe('use middleware', () => { - type Context = { auth: boolean } - - const osw = os.context() +describe('self chainable', () => { + it('define context', () => { + const applied = builder.context() - it('infer types', () => { - osw.use((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf() - expectTypeOf(meta).toEqualTypeOf>() + expect(applied).not.toBe(builder) + expect(applied).toBeInstanceOf(Builder) - return meta.next({}) - }) + expect(applied['~orpc'].middlewares).toEqual(undefined) }) - it('can map context', () => { - osw - .use((_, __, meta) => { - return meta.next({ context: { userId: '1' } }) - }) - .use((_, context, meta) => { - expectTypeOf(context).toMatchTypeOf() + it('use middleware', () => { + const builder = new Builder({ + }) - return meta.next({}) - }) - }) + const mid1 = vi.fn() + const mid2 = vi.fn() + const mid3 = vi.fn() - it('can map input', () => { - osw - // @ts-expect-error mismatch input - .use((input: { postId: string }) => {}) - .use( - (input: { postId: string }, _, meta) => { - return meta.next({ context: { user: '1' } }) - }, - (input) => { - expectTypeOf(input).toEqualTypeOf() - return { postId: '1' } - }, - ) - .func((_, context) => { - expectTypeOf(context).toMatchTypeOf<{ user: string }>() - }) + const applied = builder.use(mid1).use(mid2).use(mid3) + expect(applied).not.toBe(builder) + expect(applied).toBeInstanceOf(Builder) + expect(applied['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) }) }) describe('create middleware', () => { - it('infer types', () => { - const mid = os - .context<{ auth: boolean }>() - .middleware((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf>() - - return meta.next({ }) - }) - - expectTypeOf(mid).toEqualTypeOf< - DecoratedMiddleware<{ auth: boolean }, undefined, unknown, any> - >() - }) + it('works', () => { + const fn = vi.fn() + const mid = builder.middleware(fn) as any - it('map context', () => { - const mid = os.context<{ auth: boolean }>().middleware((_, __, meta) => { - return meta.next({ context: { userId: '1' } }) - }) + fn.mockReturnValueOnce('__mocked__') + expect(mid).toBeTypeOf('function') + expect(mid(1, 2, 3)).toBe('__mocked__') - expectTypeOf(mid).toEqualTypeOf< - DecoratedMiddleware< - { auth: boolean }, - { userId: string }, - unknown, - any - > - >() + expect(fn).toBeCalledTimes(1) + expect(fn).toBeCalledWith(1, 2, 3) }) }) -it('router method', () => { - const pingContract = oc.input(z.string()).output(z.string()) - const userFindContract = oc - .input(z.object({ id: z.string() })) - .output(z.object({ name: z.string() })) - - const contract = oc.router({ - ping: pingContract, - user: { - find: userFindContract, - }, +describe('to ProcedureBuilder', () => { + it('route', () => { + const route = { path: '/test', method: 'GET', description: '124', tags: ['hi ho'] } as const + const result = builder.route(route) - user2: oc.router({ - find: userFindContract, - }), - - router: userFindContract, + expect(result).instanceOf(ProcedureBuilder) + expect(result['~orpc'].middlewares).toEqual([mid]) + expect(result['~orpc'].contract['~orpc'].route).toBe(route) }) - const osw = os.contract(contract) - - expect(osw.ping).instanceOf(ProcedureImplementer) - expect(osw.ping.zz$pi.contract).toEqual(pingContract) + it('input', () => { + const example = { val: '123' } + const result = builder.input(schema, example) - expect(osw.user).instanceOf(RouterImplementer) + expect(result).instanceOf(ProcedureBuilder) + expect(result['~orpc'].middlewares).toEqual([mid]) + expect(result['~orpc'].contract['~orpc'].InputSchema).toBe(schema) + expect(result['~orpc'].contract['~orpc'].inputExample).toBe(example) + }) - expect(osw.user.find).instanceOf(ProcedureImplementer) - expect(osw.user.find.zz$pi.contract).toEqual(userFindContract) + it('output', () => { + const example = { val: 123 } + const result = builder.output(schema, example) - // Because of the router keyword is special, we can't use instanceof - expect(osw.router.zz$pi.contract).toEqual(userFindContract) - expect( - osw.router.func(() => { - return { name: '' } - }), - ).toSatisfy(isProcedure) + expect(result).instanceOf(ProcedureBuilder) + expect(result['~orpc'].middlewares).toEqual([mid]) + expect(result['~orpc'].contract['~orpc'].OutputSchema).toBe(schema) + expect(result['~orpc'].contract['~orpc'].outputExample).toBe(example) + }) }) -describe('define procedure builder', () => { - const osw = os.context<{ auth: boolean }>() - const schema1 = z.object({}) - const example1 = {} - const schema2 = z.object({ a: z.string() }) - const example2 = { a: '' } - - it('input method', () => { - const builder = osw.input(schema1, example1) - - expectTypeOf(builder).toEqualTypeOf< - ProcedureBuilder<{ auth: boolean }, undefined, typeof schema1, undefined> - >() - - expect(builder).instanceOf(ProcedureBuilder) - expect(builder.zz$pb.middlewares).toBe(undefined) - expect(builder.zz$pb).toMatchObject({ - contract: { - '~orpc': { - InputSchema: schema1, - inputExample: example1, - }, - }, - }) - }) +describe('to DecoratedProcedure', () => { + it('func', () => { + const fn = vi.fn() + const result = builder.func(fn) - it('output method', () => { - const builder = osw.output(schema2, example2) - - expectTypeOf(builder).toEqualTypeOf< - ProcedureBuilder<{ auth: boolean }, undefined, undefined, typeof schema2> - >() - - expect(builder).instanceOf(ProcedureBuilder) - expect(builder.zz$pb.middlewares).toBe(undefined) - expect(builder.zz$pb).toMatchObject({ - contract: { - '~orpc': { - OutputSchema: schema2, - outputExample: example2, - }, - }, - }) + expect(result).toSatisfy(isProcedure) + expect(result['~orpc'].middlewares).toEqual([mid]) + expect(result['~orpc'].func).toBe(fn) }) +}) - it('route method', () => { - const builder = osw.route({ - method: 'GET', - path: '/test', - deprecated: true, - description: 'des', - summary: 'sum', - tags: ['cccc'], - }) +describe('to RouterBuilder', () => { + it('prefix', () => { + vi.mocked(RouterBuilder).mockReturnValueOnce({ mocked: true } as any) + expect(builder.prefix('/test')).toEqual({ mocked: true }) - expectTypeOf(builder).toEqualTypeOf< - ProcedureBuilder<{ auth: boolean }, undefined, undefined, undefined> - >() - - expect(builder).instanceOf(ProcedureBuilder) - expect(builder.zz$pb.middlewares).toBe(undefined) - expect(builder.zz$pb).toMatchObject({ - contract: { - '~orpc': { - route: { - method: 'GET', - path: '/test', - deprecated: true, - description: 'des', - summary: 'sum', - tags: ['cccc'], - }, - }, - }, - }) + expect(RouterBuilder).toBeCalledTimes(1) + expect(RouterBuilder).toBeCalledWith(expect.objectContaining({ + middlewares: [mid], + prefix: '/test', + })) }) - it('with middlewares', () => { - const mid = os.middleware((_, __, meta) => { - return meta.next({ - context: { - userId: 'string', - }, - }) - }) - - const mid2 = os.middleware((_, __, meta) => { - return meta.next({ - context: { - mid2: true, - }, - }) - }) + it('tag', () => { + vi.mocked(RouterBuilder).mockReturnValueOnce({ mocked: true } as any) + expect(builder.tag('tag1', 'tag2')).toEqual({ mocked: true }) - const osw = os.context<{ auth: boolean }>().use(mid).use(mid2) - - const builder1 = osw.input(schema1) - const builder2 = osw.output(schema2) - const builder3 = osw.route({ method: 'GET', path: '/test' }) - - expectTypeOf(builder1).toEqualTypeOf< - ProcedureBuilder< - { auth: boolean }, - { userId: string } & { mid2: boolean }, - typeof schema1, - undefined - > - >() - - expectTypeOf(builder2).toEqualTypeOf< - ProcedureBuilder< - { auth: boolean }, - { userId: string } & { mid2: boolean }, - undefined, - typeof schema2 - > - >() - - expectTypeOf(builder3).toEqualTypeOf< - ProcedureBuilder< - { auth: boolean }, - { userId: string } & { mid2: boolean }, - undefined, - undefined - > - >() - - expect(builder1.zz$pb.middlewares).toEqual([mid, mid2]) - expect(builder2.zz$pb.middlewares).toEqual([mid, mid2]) - expect(builder3.zz$pb.middlewares).toEqual([mid, mid2]) + expect(RouterBuilder).toBeCalledTimes(1) + expect(RouterBuilder).toBeCalledWith(expect.objectContaining({ + middlewares: [mid], + tags: ['tag1', 'tag2'], + })) }) }) -describe('handler method', () => { - it('without middlewares', () => { - const osw = os.context<{ auth: boolean }>() - - const procedure = osw.func((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf() - }) - - expectTypeOf(procedure).toEqualTypeOf< - DecoratedProcedure< - { auth: boolean }, - undefined, - undefined, - undefined, - void - > - >() - - expect(isProcedure(procedure)).toBe(true) - expect(procedure.zz$p.middlewares).toBe(undefined) - }) - - it('with middlewares', () => { - const mid = os.middleware((_, __, meta) => { - return meta.next({ - context: { - userId: 'string', - }, - }) - }) +it('to AdaptedRouter', () => { + const ping = vi.fn() as any + const router = { + ping, + nested: { + ping, + }, + } - const osw = os.context<{ auth: boolean }>().use(mid) + expect(builder.router(router)).toEqual({ mocked: true }) - const procedure = osw.func((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toMatchTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf() - }) + expect(RouterBuilder).toBeCalledTimes(1) + expect(RouterBuilder).toBeCalledWith(expect.objectContaining({ + middlewares: [mid], + })) - expectTypeOf(procedure).toEqualTypeOf< - DecoratedProcedure< - { auth: boolean }, - { userId: string }, - undefined, - undefined, - void - > - >() - - expect(isProcedure(procedure)).toBe(true) - expect(procedure.zz$p.middlewares).toEqual([mid]) - }) + const routerBuilder = vi.mocked(RouterBuilder).mock.results[0]?.value + expect(vi.mocked(routerBuilder.router)).toBeCalledTimes(1) + expect(vi.mocked(routerBuilder.router)).toBeCalledWith(router) }) -it('prefix', () => { - const builder = os - .context<{ auth: boolean }>() - .use((_, __, meta) => { - return meta.next({ context: { userId: '1' } }) - }) - .prefix('/api') +it('to DecoratedLazy', () => { + const loader = vi.fn() as any - expectTypeOf(builder).toEqualTypeOf< - RouterBuilder<{ auth: boolean }, { userId: string }> - >() + expect(builder.lazy(loader)).toEqual({ mocked: true }) + expect(RouterBuilder).toBeCalledTimes(1) + expect(RouterBuilder).toBeCalledWith(expect.objectContaining({ + middlewares: [mid], + })) - expect(builder).instanceOf(RouterBuilder) - expect(builder.zz$rb.prefix).toEqual('/api') + const routerBuilder = vi.mocked(RouterBuilder).mock.results[0]?.value + expect(vi.mocked(routerBuilder.lazy)).toBeCalledTimes(1) + expect(vi.mocked(routerBuilder.lazy)).toBeCalledWith(loader) }) -it('tags', () => { - const builder = os - .context<{ auth: boolean }>() - .use((_, __, meta) => { - return meta.next({ context: { userId: '1' } }) - }) - .tags('user', 'user2') - - expectTypeOf(builder).toEqualTypeOf< - RouterBuilder<{ auth: boolean }, { userId: string }> - >() +it('to ChainableImplementer', () => { + const contract = vi.fn() as any - expect(builder).instanceOf(RouterBuilder) - expect(builder.zz$rb.tags).toEqual(['user', 'user2']) + expect(builder.contract(contract)).toEqual({ mocked: true }) + expect(createChainableImplementer).toBeCalledTimes(1) + expect(createChainableImplementer).toBeCalledWith(contract, [mid]) }) diff --git a/packages/server/src/builder.ts b/packages/server/src/builder.ts index f8dda80b8..2b4d1f4b0 100644 --- a/packages/server/src/builder.ts +++ b/packages/server/src/builder.ts @@ -1,104 +1,71 @@ -import type { - ANY_CONTRACT_PROCEDURE, - ContractRouter, - HTTPPath, - RouteOptions, - Schema, - SchemaInput, - SchemaOutput, -} from '@orpc/contract' -import type { IsEqual } from '@orpc/shared' -import type { DecoratedLazy } from './lazy' -import type { DecoratedProcedure, Procedure, ProcedureFunc } from './procedure' -import type { HandledRouter, Router } from './router' -import type { Context, MergeContext } from './types' -import { - ContractProcedure, - isContractProcedure, -} from '@orpc/contract' -import { - type DecoratedMiddleware, - decorateMiddleware, - type MapInputMiddleware, - type Middleware, -} from './middleware' -import { decorateProcedure } from './procedure' +import type { ANY_CONTRACT_PROCEDURE, ContractRouter, HTTPPath, RouteOptions, Schema, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { FlattenLazy } from './lazy' +import type { Middleware } from './middleware' +import type { DecoratedMiddleware } from './middleware-decorated' +import type { Router } from './router' +import type { AdaptedRouter } from './router-builder' +import type { Context, MergeContext, WELL_CONTEXT } from './types' +import { ContractProcedure } from '@orpc/contract' +import { type ChainableImplementer, createChainableImplementer } from './implementer-chainable' +import { decorateMiddleware } from './middleware-decorated' +import { Procedure, type ProcedureFunc } from './procedure' import { ProcedureBuilder } from './procedure-builder' -import { ProcedureImplementer } from './procedure-implementer' +import { type DecoratedProcedure, decorateProcedure } from './procedure-decorated' import { RouterBuilder } from './router-builder' -import { - type ChainedRouterImplementer, - chainRouterImplementer, -} from './router-implementer' + +export interface BuilderDef { + middlewares?: Middleware, Partial | undefined, unknown, any>[] +} export class Builder { - constructor( - public zz$b: { - middlewares?: Middleware[] - } = {}, - ) { } + '~type' = 'Builder' as const + '~orpc': BuilderDef - /** - * Self chainable - */ + constructor(def: BuilderDef) { + this['~orpc'] = def + } - context(): IsEqual extends true - ? Builder - : Builder { - return this as any + context(): Builder { + return new Builder({}) } - use< - UExtraContext extends - | Partial>> - | undefined = undefined, - >( + use> | undefined = undefined>( middleware: Middleware< MergeContext, - UExtraContext, + U, unknown, unknown >, - ): Builder> + ): Builder> { + return new Builder({ + ...this['~orpc'], + middlewares: [...(this['~orpc'].middlewares ?? []), middleware as any], + }) + } - use< - UExtraContext extends - | Partial>> - | undefined = undefined, - UMappedInput = unknown, + middleware< + UExtraContext extends Context & Partial> | undefined = undefined, + TInput = unknown, + TOutput = any, >( middleware: Middleware< MergeContext, UExtraContext, - UMappedInput, - unknown + TInput, + TOutput >, - mapInput: MapInputMiddleware, - ): Builder> - - use( - middleware: Middleware, - mapInput?: MapInputMiddleware, - ): Builder { - const middleware_ = mapInput - ? decorateMiddleware(middleware).mapInput(mapInput) - : middleware - - return new Builder({ - ...this.zz$b, - middlewares: [...(this.zz$b.middlewares || []), middleware_], - }) + ): DecoratedMiddleware< + MergeContext, + UExtraContext, + TInput, + TOutput + > { + return decorateMiddleware(middleware) } - /** - * Convert to ContractProcedureBuilder - */ - - route( - route: RouteOptions, - ): ProcedureBuilder { + route(route: RouteOptions): ProcedureBuilder { return new ProcedureBuilder({ - middlewares: this.zz$b.middlewares, + middlewares: this['~orpc'].middlewares, contract: new ContractProcedure({ route, InputSchema: undefined, @@ -112,7 +79,7 @@ export class Builder { example?: SchemaInput, ): ProcedureBuilder { return new ProcedureBuilder({ - middlewares: this.zz$b.middlewares, + middlewares: this['~orpc'].middlewares, contract: new ContractProcedure({ OutputSchema: undefined, InputSchema: schema, @@ -126,7 +93,7 @@ export class Builder { example?: SchemaOutput, ): ProcedureBuilder { return new ProcedureBuilder({ - middlewares: this.zz$b.middlewares, + middlewares: this['~orpc'].middlewares, contract: new ContractProcedure({ InputSchema: undefined, OutputSchema: schema, @@ -135,112 +102,48 @@ export class Builder { }) } - /** - * Convert to Procedure - */ func( - func: ProcedureFunc< - TContext, - TExtraContext, - undefined, - undefined, - UFuncOutput - >, - ): DecoratedProcedure< - TContext, - TExtraContext, - undefined, - undefined, - UFuncOutput - > { - return decorateProcedure({ - zz$p: { - middlewares: this.zz$b.middlewares, - contract: new ContractProcedure({ - InputSchema: undefined, - OutputSchema: undefined, - }), - func, - }, - }) - } - - /** - * Convert to ProcedureImplementer | RouterBuilder - */ - - contract( - contract: UContract, - ): UContract extends ContractProcedure< - infer UInputSchema, - infer UOutputSchema - > - ? ProcedureImplementer - : UContract extends ContractRouter - ? ChainedRouterImplementer - : never { - if (isContractProcedure(contract)) { - return new ProcedureImplementer({ - contract, - middlewares: this.zz$b.middlewares, - }) as any - } - - return chainRouterImplementer( - contract as ContractRouter, - this.zz$b.middlewares, - ) as any - } - - /** - * Create ExtendedMiddleware - */ - - // TODO: TOutput always any, infer not work at all, because TOutput used inside middleware params, - // solution (maybe): create new generic for .output() method - middleware( - middleware: Middleware< - MergeContext, - UExtraContext, - TInput, - TOutput - >, - ): DecoratedMiddleware< - MergeContext, - UExtraContext, - TInput, - TOutput - > { - return decorateMiddleware(middleware) + func: ProcedureFunc, + ): DecoratedProcedure { + return decorateProcedure(new Procedure({ + middlewares: this['~orpc'].middlewares, + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + }), + func, + })) } prefix(prefix: HTTPPath): RouterBuilder { return new RouterBuilder({ - ...this.zz$b, + middlewares: this['~orpc'].middlewares, prefix, }) } - tags(...tags: string[]): RouterBuilder { + tag(...tags: string[]): RouterBuilder { return new RouterBuilder({ - ...this.zz$b, + middlewares: this['~orpc'].middlewares, tags, }) } - /** - * Create DecoratedRouter - */ - router>( - router: URouter, - ): HandledRouter { - return new RouterBuilder(this.zz$b).router(router) + router, any>>( + router: U, + ): AdaptedRouter { + return new RouterBuilder(this['~orpc']).router(router) } - lazy | Procedure>( + lazy, any>>( loader: () => Promise<{ default: U }>, - ): DecoratedLazy { - // TODO: replace with a more solid solution - return new RouterBuilder(this.zz$b).lazy(loader as any) as any + ): AdaptedRouter> { + return new RouterBuilder(this['~orpc']).lazy(loader) + } + + contract( + contract: U, + ): ChainableImplementer { + return createChainableImplementer(contract, this['~orpc'].middlewares) } } diff --git a/packages/server/src/fetch/handle-request.test-d.ts b/packages/server/src/fetch/handle-request.test-d.ts new file mode 100644 index 000000000..cff1d0c29 --- /dev/null +++ b/packages/server/src/fetch/handle-request.test-d.ts @@ -0,0 +1,71 @@ +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 new file mode 100644 index 000000000..f28e7beee --- /dev/null +++ b/packages/server/src/fetch/handle-request.test.ts @@ -0,0 +1,94 @@ +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.ts b/packages/server/src/fetch/handle-request.ts similarity index 67% rename from packages/server/src/fetch/handle.ts rename to packages/server/src/fetch/handle-request.ts index 8f6ea70b6..238b7918c 100644 --- a/packages/server/src/fetch/handle.ts +++ b/packages/server/src/fetch/handle-request.ts @@ -1,13 +1,13 @@ -import type { Router } from '../router' +import type { Context } from '../types' import type { FetchHandler, FetchHandlerOptions } from './types' import { ORPCError } from '@orpc/shared/error' -export type HandleFetchRequestOptions> = FetchHandlerOptions & { +export type HandleFetchRequestOptions = FetchHandlerOptions & { handlers: readonly [FetchHandler, ...FetchHandler[]] } -export async function handleFetchRequest< TRouter extends Router>( - options: HandleFetchRequestOptions, +export async function handleFetchRequest( + options: HandleFetchRequestOptions, ) { for (const handler of options.handlers) { const response = await handler(options) diff --git a/packages/server/src/fetch/handle.test.ts b/packages/server/src/fetch/handle.test.ts deleted file mode 100644 index 0bf292eb6..000000000 --- a/packages/server/src/fetch/handle.test.ts +++ /dev/null @@ -1,670 +0,0 @@ -import { createOpenAPIServerHandler, createOpenAPIServerlessHandler } from '@orpc/openapi/fetch' -import { ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_VALUE } from '@orpc/shared' -import { oz } from '@orpc/zod' -import { describe, expect, it } from 'vitest' -import { z } from 'zod' -import { ORPCError, os } from '..' -import { handleFetchRequest } from './handle' -import { createORPCHandler } from './handler' - -const router = os.router({ - throw: os.func(() => { - throw new Error('test') - }), - ping: os.func(() => { - return 'ping' - }), - ping2: os.route({ method: 'GET', path: '/ping2' }).func(() => { - return 'ping2' - }), -}) - -describe('simple', () => { - const osw = os.context<{ auth?: boolean }>() - const router = osw.router({ - ping: osw.func(async () => 'pong'), - ping2: osw - .route({ method: 'GET', path: '/ping2' }) - .func(async () => 'pong2'), - }) - - it('200: public url', async () => { - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerlessHandler()], - prefix: '/orpc', - request: new Request('http://localhost/orpc/ping', { - method: 'POST', - }), - context: () => ({ auth: true }), - }) - - expect(response.status).toBe(200) - expect(await response.json()).toEqual('pong') - - const response2 = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerlessHandler()], - prefix: '/orpc', - request: new Request('http://localhost/orpc/ping2', { - method: 'GET', - }), - context: { auth: true }, - }) - - expect(response2.status).toBe(200) - expect(await response2.json()).toEqual('pong2') - }) - - it('200: internal url', async () => { - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerlessHandler()], - request: new Request('http://localhost/ping', { - method: 'POST', - }), - context: { auth: true }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toEqual('pong') - - const response2 = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerlessHandler()], - prefix: '/orpc', - request: new Request('http://localhost/orpc/ping2'), - context: { auth: true }, - }) - - expect(response2.status).toBe(200) - expect(await response2.json()).toEqual('pong2') - }) - - it('404', async () => { - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - prefix: '/orpc', - request: new Request('http://localhost/orpc/pingp', { - method: 'POST', - }), - context: { auth: true }, - }) - - expect(response.status).toBe(404) - }) -}) - -describe('procedure throw error', () => { - it('unknown error', async () => { - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - request: new Request('https://local.com/throw', { method: 'POST' }), - }) - - expect(response.status).toBe(500) - expect(await response.json()).toEqual({ - code: 'INTERNAL_SERVER_ERROR', - status: 500, - message: 'Internal server error', - }) - }) - - it('orpc error', async () => { - const router = os.router({ - ping: os.func(() => { - throw new ORPCError({ code: 'TIMEOUT' }) - }), - }) - - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - request: new Request('https://local.com/ping', { method: 'POST' }), - }) - - expect(response.status).toBe(408) - expect(await response.json()).toEqual({ - code: 'TIMEOUT', - status: 408, - message: '', - }) - }) - - it('orpc error with data', async () => { - const router = os.router({ - ping: os.func(() => { - throw new ORPCError({ - code: 'PAYLOAD_TOO_LARGE', - message: 'test', - data: { max: '10mb' }, - }) - }), - }) - - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - request: new Request('https://local.com/ping', { method: 'POST' }), - }) - - expect(response.status).toBe(413) - expect(await response.json()).toEqual({ - code: 'PAYLOAD_TOO_LARGE', - status: 413, - message: 'test', - data: { max: '10mb' }, - }) - }) - - it('orpc error with custom status', async () => { - const router = os.router({ - ping: os.func(() => { - throw new ORPCError({ - code: 'PAYLOAD_TOO_LARGE', - status: 100, - }) - }), - - ping2: os.func(() => { - throw new ORPCError({ - code: 'PAYLOAD_TOO_LARGE', - status: 488, - }) - }), - }) - - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - request: new Request('https://local.com/ping', { method: 'POST' }), - }) - - expect(response.status).toBe(500) - expect(await response.json()).toEqual({ - code: 'INTERNAL_SERVER_ERROR', - status: 500, - message: 'Internal server error', - }) - - const response2 = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - request: new Request('https://local.com/ping2', { method: 'POST' }), - }) - - expect(response2.status).toBe(488) - expect(await response2.json()).toEqual({ - code: 'PAYLOAD_TOO_LARGE', - status: 488, - message: '', - }) - }) - - it('input validation error', async () => { - const router = os.router({ - ping: os - .input(z.object({})) - .output(z.string()) - .func(() => { - return 'unnoq' - }), - }) - - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - request: new Request('https://local.com/ping', { method: 'POST' }), - }) - - expect(response.status).toBe(400) - expect(await response.json()).toEqual({ - code: 'BAD_REQUEST', - status: 400, - message: 'Validation input failed', - issues: [ - { - code: 'invalid_type', - expected: 'object', - message: 'Required', - path: [], - received: 'undefined', - }, - ], - }) - }) - - it('output validation error', async () => { - const router = os.router({ - ping: os - .input(z.string()) - .output(z.string()) - .func(() => { - return 12344 as any - }), - }) - - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - request: new Request('https://local.com/ping', { - method: 'POST', - body: '"hi"', - }), - }) - - expect(response.status).toBe(500) - expect(await response.json()).toEqual({ - code: 'INTERNAL_SERVER_ERROR', - status: 500, - message: 'Validation output failed', - }) - }) -}) - -describe('file upload', () => { - const router = os.router({ - signal: os.input(z.instanceof(Blob)).func((input) => { - return input - }), - multiple: os - .input( - z.object({ first: z.instanceof(Blob), second: z.instanceof(Blob) }), - ) - .func((input) => { - return input - }), - }) - - const blob1 = new Blob(['hello'], { type: 'text/plain;charset=utf-8' }) - const blob2 = new Blob(['"world"'], { type: 'image/png' }) - const blob3 = new Blob(['unnoq'], { type: 'application/octet-stream' }) - - it('single file', async () => { - const rForm = new FormData() - rForm.set('meta', JSON.stringify([])) - rForm.set('maps', JSON.stringify([[]])) - rForm.set('0', blob3) - - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - prefix: '/orpc', - request: new Request('http://localhost/orpc/signal', { - method: 'POST', - body: rForm, - headers: { - [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, - }, - }), - }) - - expect(response.status).toBe(200) - const form = await response.formData() - - const file0 = form.get('0') as File - expect(file0).toBeInstanceOf(File) - expect(file0.name).toBe('blob') - expect(file0.type).toBe('application/octet-stream') - expect(await file0.text()).toBe('unnoq') - }) - - it('multiple file', async () => { - const form = new FormData() - form.set('data', JSON.stringify({ first: blob1, second: blob2 })) - form.set('meta', JSON.stringify([])) - form.set('maps', JSON.stringify([['first'], ['second']])) - form.set('0', blob1) - form.set('1', blob2) - - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - prefix: '/orpc', - request: new Request('http://localhost/orpc/multiple', { - method: 'POST', - body: form, - headers: { - [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, - }, - }), - }) - - expect(response.status).toBe(200) - - const form_ = await response.formData() - const file0 = form_.get('0') as File - const file1 = form_.get('1') as File - - expect(file0).toBeInstanceOf(File) - expect(file0.name).toBe('blob') - expect(file0.type).toBe('text/plain;charset=utf-8') - expect(await file0.text()).toBe('hello') - - expect(file1).toBeInstanceOf(File) - expect(file1.name).toBe('blob') - expect(file1.type).toBe('image/png') - expect(await file1.text()).toBe('"world"') - }) -}) - -describe('accept header', () => { - const router = os.router({ - ping: os.func(async () => 'pong'), - }) - - it('application/json', async () => { - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - prefix: '/orpc', - request: new Request('http://localhost/orpc/ping', { - method: 'POST', - headers: { - Accept: 'application/json', - }, - }), - }) - - expect(response.headers.get('Content-Type')).toEqual('application/json') - - expect(await response.json()).toEqual('pong') - }) - - it('multipart/form-data', async () => { - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - prefix: '/orpc', - request: new Request('http://localhost/orpc/ping', { - method: 'POST', - headers: { - Accept: 'multipart/form-data', - }, - }), - }) - - expect(response.headers.get('Content-Type')).toContain( - 'multipart/form-data', - ) - - const form = await response.formData() - expect(form.get('')).toEqual('pong') - }) - - it('application/x-www-form-urlencoded', async () => { - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - prefix: '/orpc', - request: new Request('http://localhost/orpc/ping', { - method: 'POST', - headers: { - Accept: 'application/x-www-form-urlencoded', - }, - }), - }) - - expect(response.headers.get('Content-Type')).toEqual( - 'application/x-www-form-urlencoded', - ) - - const params = new URLSearchParams(await response.text()) - expect(params.get('')).toEqual('pong') - }) - - it('*/*', async () => { - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - prefix: '/orpc', - request: new Request('http://localhost/orpc/ping', { - method: 'POST', - headers: { - Accept: '*/*', - }, - }), - }) - - expect(response.headers.get('Content-Type')).toEqual('application/json') - expect(await response.json()).toEqual('pong') - }) - - it('invalid', async () => { - const response = await handleFetchRequest({ - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()], - prefix: '/orpc', - request: new Request('http://localhost/orpc/ping', { - method: 'POST', - headers: { - Accept: 'invalid', - }, - }), - }) - - expect(response.headers.get('Content-Type')).toEqual('application/json') - expect(await response.json()).toEqual({ - code: 'NOT_ACCEPTABLE', - message: 'Unsupported content-type: invalid', - status: 406, - }) - }) -}) - -describe('dynamic params', () => { - const router = os.router({ - deep: os - .route({ - method: 'POST', - path: '/{id}/{id2}', - }) - .input( - z.object({ - id: z.number(), - id2: z.string(), - file: oz.file(), - }), - ) - .func(input => input), - - find: os - .route({ - method: 'GET', - path: '/{id}', - }) - .input( - z.object({ - id: z.number(), - }), - ) - .func(input => input), - }) - - const handlers = [ - { - router, - handlers: [createORPCHandler(), createOpenAPIServerHandler()] as const, - }, - { - router, - handlers: [createORPCHandler(), createOpenAPIServerlessHandler()] as const, - }, - ] - - it.each(handlers)('should handle dynamic params', async ({ router, handlers }) => { - const response = await handleFetchRequest({ - router, - handlers, - request: new Request('http://localhost/123'), - }) - - expect(response.status).toEqual(200) - expect(response.headers.get('Content-Type')).toEqual('application/json') - expect(await response.json()).toEqual({ id: 123 }) - }) - - it.each(handlers)('should handle deep dynamic params', async ({ handlers }) => { - const form = new FormData() - form.append('file', new Blob(['hello']), 'hello.txt') - - const response = await handleFetchRequest({ - router, - handlers, - request: new Request('http://localhost/123/dfdsfds', { - method: 'POST', - body: form, - }), - }) - - expect(response.status).toEqual(200) - const rForm = await response.formData() - expect(rForm.get('id')).toEqual('123') - expect(rForm.get('id2')).toEqual('dfdsfds') - }) -}) - -describe('can control method on POST request', () => { - const router = os.router({ - update: os - .route({ - method: 'PUT', - path: '/{id}', - }) - .input( - z.object({ - id: z.number(), - file: oz.file(), - }), - ) - .func(input => input), - }) - - const handlers = [ - [createORPCHandler(), createOpenAPIServerHandler()], - [createORPCHandler(), createOpenAPIServerlessHandler()], - ] as const - - it.each(handlers)('work', async (...handlers) => { - const form = new FormData() - form.set('file', new File(['hello'], 'hello.txt')) - - const response = await handleFetchRequest({ - router, - handlers, - request: new Request('http://localhost/123', { - method: 'POST', - body: form, - }), - }) - - expect(response.status).toEqual(404) - - const response2 = await handleFetchRequest({ - router, - handlers, - request: new Request('http://localhost/123?method=PUT', { - method: 'POST', - body: form, - }), - }) - - expect(response2.status).toEqual(200) - }) -}) - -it('hooks', async () => { - const onSuccess = vi.fn() - const onError = vi.fn() - - const router = { - ping: os.input(z.object({ value: z.string() })).func(input => input.value), - } - - const handlers = [ - createORPCHandler(), - createOpenAPIServerHandler(), - ] as const - - const context = { auth: true } - - const request = new Request('http://localhost/ping', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ value: 'hello' }), - }) - - const response = await handleFetchRequest({ - router, - request, - handlers, - onSuccess, - onError, - context, - }) - - expect(response.status).toEqual(200) - expect(onSuccess).toHaveBeenCalledTimes(1) - expect(onSuccess).toBeCalledWith({ input: request, output: response, status: 'success' }, context, {}) - expect(onError).toHaveBeenCalledTimes(0) - - onSuccess.mockClear() - onError.mockClear() - - const errorRequest = new Request('http://localhost/ping', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ value: 1233 }), - }) - - const errorResponse = await handleFetchRequest({ - router, - request: errorRequest, - handlers, - onSuccess, - onError, - context, - }) - - expect(errorResponse.status).toEqual(400) - expect(onSuccess).toHaveBeenCalledTimes(0) - expect(onError).toHaveBeenCalledTimes(1) - expect(onError).toBeCalledWith({ input: errorRequest, error: expect.any(Error), status: 'error' }, context, {}) -}) - -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 handleFetchRequest({ - router: { ping }, - request: new Request('http://localhost/ping', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ value: '123' }), - }), - signal, - onSuccess, - handlers: [createOpenAPIServerHandler()], - }) - - 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/server/src/fetch/handler.test.ts b/packages/server/src/fetch/handler.test.ts deleted file mode 100644 index 73226d8d7..000000000 --- a/packages/server/src/fetch/handler.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_VALUE } from '@orpc/shared' -import { z } from 'zod' -import { os } from '..' -import { createORPCHandler } from './handler' - -describe('oRPCHandler', () => { - const handler = createORPCHandler() - - 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: { - [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ data: { value: '123' }, meta: [] }), - }), - }) - - expect(response?.status).toEqual(200) - expect(await response?.json()).toEqual({ data: '123', meta: [] }) - }) - - it('should handle request - lazy', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/lazyRouter/ping', { - method: 'POST', - headers: { - [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ data: { value: '123' }, meta: [] }), - }), - }) - - expect(response?.status).toEqual(200) - expect(await response?.json()).toEqual({ data: '123', meta: [] }) - }) - - it('should handle request - lazy - lazy', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/lazyRouter/lazyRouter/ping', { - method: 'POST', - headers: { - [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ data: { value: '123' }, meta: [] }), - }), - }) - - expect(response?.status).toEqual(200) - expect(await response?.json()).toEqual({ data: '123', meta: [] }) - }) - - it('should throw error - not found', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/pingp', { - method: 'POST', - headers: { - [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, - }, - body: JSON.stringify({ data: { value: '123' }, meta: [] }), - }), - }) - - expect(response?.status).toEqual(404) - expect(await response?.json()).toEqual({ data: { code: 'NOT_FOUND', message: 'Not found', status: 404 }, meta: [] }) - }) - - it('should throw error - not found - lazy', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/lazyRouter/not_found', { - method: 'POST', - headers: { - [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, - }, - body: JSON.stringify({ data: { value: '123' }, meta: [] }), - }), - }) - - expect(response?.status).toEqual(404) - expect(await response?.json()).toEqual({ data: { code: 'NOT_FOUND', message: 'Not found', status: 404 }, meta: [] }) - }) - - it('should throw error - invalid input', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/ping', { - method: 'POST', - headers: { - [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, - }, - body: JSON.stringify({ data: { value: 123 }, meta: [] }), - }), - }) - - expect(response?.status).toEqual(400) - expect(await response?.json()).toEqual({ - data: { - code: 'BAD_REQUEST', - status: 400, - message: 'Validation input failed', - issues: [ - { - code: 'invalid_type', - expected: 'string', - received: 'number', - path: [ - 'value', - ], - message: 'Expected string, received number', - }, - ], - }, - meta: [], - }) - }) - - it('should throw error - invalid input - lazy', async () => { - const response = await handler({ - router, - request: new Request('http://localhost/lazyRouter/ping', { - method: 'POST', - headers: { - [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, - }, - body: JSON.stringify({ data: { value: 123 }, meta: [] }), - }), - }) - - expect(response?.status).toEqual(400) - expect(await response?.json()).toEqual({ - data: { - code: 'BAD_REQUEST', - status: 400, - message: 'Validation input failed', - issues: [ - { - code: 'invalid_type', - expected: 'string', - received: 'number', - path: [ - 'value', - ], - message: 'Expected string, received number', - }, - ], - }, - meta: [], - }) - }) - - 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: { - [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, - }, - body: JSON.stringify({ data: { value: 123 }, meta: [] }), - }), - }) - - expect(response?.status).toEqual(400) - expect(await response?.json()).toEqual({ - data: { - code: 'BAD_REQUEST', - status: 400, - message: 'Validation input failed', - issues: [ - { - code: 'invalid_type', - expected: 'string', - received: 'number', - path: [ - 'value', - ], - message: 'Expected string, received number', - }, - ], - }, - meta: [], - }) - }) - - 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: { - [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ data: { value: '123' }, meta: [] }), - }), - 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/server/src/fetch/index.ts b/packages/server/src/fetch/index.ts index 969d7042e..11433a67d 100644 --- a/packages/server/src/fetch/index.ts +++ b/packages/server/src/fetch/index.ts @@ -1,3 +1,3 @@ -export * from './handle' -export * from './handler' +export * from './handle-request' +export * from './orpc-handler' 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 new file mode 100644 index 000000000..e9544444a --- /dev/null +++ b/packages/server/src/fetch/orpc-handler.test-d.ts @@ -0,0 +1,21 @@ +import { handleFetchRequest } from './handle-request' +import { createORPCHandler } from './orpc-handler' + +it('assignable to handlers', () => { + handleFetchRequest({ + request: new Request('https://example.com', {}), + router: {}, + handlers: [ + createORPCHandler(), + ], + }) + + handleFetchRequest({ + request: new Request('https://example.com', {}), + router: {}, + handlers: [ + // @ts-expect-error - invalid handler + createORPCHandler, + ], + }) +}) diff --git a/packages/server/src/fetch/orpc-handler.test.ts b/packages/server/src/fetch/orpc-handler.test.ts new file mode 100644 index 000000000..6916e571f --- /dev/null +++ b/packages/server/src/fetch/orpc-handler.test.ts @@ -0,0 +1,202 @@ +import { ContractProcedure } from '@orpc/contract' +import { ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_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' + +vi.mock('../procedure-client', () => ({ + createProcedureClient: vi.fn(() => vi.fn()), +})) + +describe('createORPCHandler', () => { + const ping = new Procedure({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + }), + func: vi.fn(), + }) + const pong = new Procedure({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + }), + 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 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 mockRequest = new Request('https://example.com/not_found', { + headers: new Headers({ [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE }), + }) + + const response = await handler({ + request: mockRequest, + router, + context: undefined, + signal: undefined, + }) + + 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 = createORPCHandler() + + 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 }), + method: 'POST', + body: JSON.stringify({ data: { value: '123' }, meta: [] }), + }) + + const response = await handler({ + request: mockRequest, + router, + }) + + expect(response?.status).toBe(200) + + const body = await response?.json() + expect(body).toEqual({ data: '__mocked__', meta: [] }) + + expect(caller).toBeCalledTimes(1) + expect(caller).toBeCalledWith({ value: '123' }, { signal: undefined }) + }) + + it('should handle deserialization errors and return a 400 response', async () => { + const handler = createORPCHandler() + + const mockRequest = new Request('https://example.com/ping', { + method: 'POST', + headers: new Headers({ [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, 'Content-Type': 'application/json' }), + body: '{ invalid json', + }) + + const response = await handler({ + request: mockRequest, + router, + }) + + expect(response?.status).toBe(400) + + const body = await response?.text() + expect(body).toContain('Cannot parse request') + }) + + it('should handle unexpected errors and return a 500 response', async () => { + const handler = createORPCHandler() + + 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 }), + method: 'POST', + body: JSON.stringify({ data: { value: '123' }, meta: [] }), + }) + + const response = await handler({ + request: mockRequest, + router, + context: undefined, + signal: undefined, + }) + + expect(response?.status).toBe(500) + + const body = await response?.text() + expect(body).toContain('Internal server error') + }) + + it('support signal', async () => { + const handler = createORPCHandler() + + 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 }), + method: 'POST', + body: JSON.stringify({ data: { value: '123' }, meta: [] }), + }) + + const controller = new AbortController() + const signal = controller.signal + + const response = await handler({ + request: mockRequest, + router, + signal, + }) + + expect(response?.status).toBe(200) + + const body = await response?.json() + expect(body).toEqual({ data: '__mocked__', meta: [] }) + + expect(caller).toBeCalledTimes(1) + expect(caller).toBeCalledWith({ value: '123' }, { signal }) + }) + + it('hooks', async () => { + const handler = createORPCHandler() + + const mockRequest = new Request('https://example.com/not_found', { + headers: new Headers({ [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE }), + method: 'POST', + body: JSON.stringify({ data: { value: '123' }, meta: [] }), + }) + + const onStart = vi.fn() + const onSuccess = vi.fn() + const onError = vi.fn() + + const response = await handler({ + request: mockRequest, + router, + onStart, + onSuccess, + onError, + }) + + expect(response?.status).toBe(404) + + expect(onStart).toBeCalledTimes(1) + expect(onSuccess).toBeCalledTimes(0) + expect(onError).toBeCalledTimes(1) + }) +}) diff --git a/packages/server/src/fetch/handler.ts b/packages/server/src/fetch/orpc-handler.ts similarity index 56% rename from packages/server/src/fetch/handler.ts rename to packages/server/src/fetch/orpc-handler.ts index 7a564ad64..281c507e4 100644 --- a/packages/server/src/fetch/handler.ts +++ b/packages/server/src/fetch/orpc-handler.ts @@ -1,12 +1,12 @@ import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE } from '../procedure' -import type { Router } from '../router' import type { FetchHandler } from './types' import { executeWithHooks, ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_VALUE, trim, value } from '@orpc/shared' import { ORPCError } from '@orpc/shared/error' import { ORPCDeserializer, ORPCSerializer } from '@orpc/transformer' -import { isLazy } from '../lazy' +import { unlazy } from '../lazy' import { isProcedure } from '../procedure' -import { createProcedureCaller } from '../procedure-caller' +import { createProcedureClient } from '../procedure-client' +import { type ANY_ROUTER, getRouterChild } from '../router' const serializer = new ORPCSerializer() const deserializer = new ORPCDeserializer() @@ -23,17 +23,17 @@ export function createORPCHandler(): FetchHandler { const url = new URL(options.request.url) const pathname = `/${trim(url.pathname.replace(options.prefix ?? '', ''), '/')}` - const match = resolveORPCRouter(options.router, pathname) + const match = await resolveRouterMatch(options.router, pathname) if (!match) { throw new ORPCError({ code: 'NOT_FOUND', message: 'Not found' }) } - const input = await deserializeRequest(options.request) + const input = await parseRequestInput(options.request) - const caller = createProcedureCaller({ + const caller = createProcedureClient({ context, - procedure: match.procedure as any, + procedure: match.procedure, path: match.path, }) @@ -58,58 +58,60 @@ export function createORPCHandler(): FetchHandler { }, }) } - catch (e) { - const error = e instanceof ORPCError - ? e - : new ORPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Internal server error', - cause: e, - }) - - const { body, headers } = serializer.serialize(error.toJSON()) - - return new Response(body, { - status: error.status, - headers, - }) + catch (error) { + return handleErrorResponse(error) } } } -function resolveORPCRouter(router: Router, pathname: string): { +async function resolveRouterMatch( + router: ANY_ROUTER, + pathname: string, +): Promise<{ path: string[] procedure: ANY_PROCEDURE | ANY_LAZY_PROCEDURE -} | undefined { - const path = trim(pathname, '/').split('/').map(decodeURIComponent) - - let current: Router | ANY_PROCEDURE | ANY_LAZY_PROCEDURE | undefined = router - for (const segment of path) { - if ((typeof current !== 'object' && typeof current !== 'function') || !current) { - current = undefined - break - } +} | undefined> { + const pathSegments = trim(pathname, '/').split('/').map(decodeURIComponent) + + const match = getRouterChild(router, ...pathSegments) + const { default: maybeProcedure } = await unlazy(match) - current = (current as any)[segment] + if (!isProcedure(maybeProcedure)) { + return undefined } - return isProcedure(current) || isLazy(current) - ? { - procedure: current, - path, - } - : undefined + return { + procedure: maybeProcedure, + path: pathSegments, + } } -async function deserializeRequest(request: Request): Promise { +async function parseRequestInput(request: Request): Promise { try { return await deserializer.deserialize(request) } - catch (e) { + catch (error) { throw new ORPCError({ code: 'BAD_REQUEST', message: 'Cannot parse request. Please check the request body and Content-Type header.', - cause: e, + 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/types.ts b/packages/server/src/fetch/types.ts index c9bf2a6fa..4b58f7851 100644 --- a/packages/server/src/fetch/types.ts +++ b/packages/server/src/fetch/types.ts @@ -1,41 +1,33 @@ -/// - -import type { Hooks, PartialOnUndefinedDeep, Value } from '@orpc/shared' +import type { HTTPPath } from '@orpc/contract' +import type { Hooks, Value } from '@orpc/shared' import type { Router } from '../router' -import type { CallerOptions } from '../types' +import type { Context, WithSignal } from '../types' -export type FetchHandlerOptions< - TRouter extends Router, -> = { +export type FetchHandlerOptions = + { /** * The `router` used for handling the request and routing, * */ - router: TRouter + router: Router - /** - * The request need to be handled. - */ - request: Request + /** + * The request need to be handled. + */ + request: Request - /** - * Remove the prefix from the request path. - * - * @example /orpc - * @example /api - */ - prefix?: string -} & PartialOnUndefinedDeep<{ - /** - * The context used to handle the request. - */ - context: Value< - TRouter extends Router ? UContext : never - > -}> -& CallerOptions -& Hooks ? UContext : never, CallerOptions> + /** + * Remove the prefix from the request path. + * + * @example /orpc + * @example /api + */ + prefix?: HTTPPath + } + & NoInfer<(undefined extends T ? { context?: Value } : { context: Value })> + & WithSignal + & Hooks -export type FetchHandler = >( - options: FetchHandlerOptions +export type FetchHandler = ( + options: FetchHandlerOptions ) => Promise diff --git a/packages/server/src/hidden.test.ts b/packages/server/src/hidden.test.ts new file mode 100644 index 000000000..729204e83 --- /dev/null +++ b/packages/server/src/hidden.test.ts @@ -0,0 +1,97 @@ +import { oc } from '@orpc/contract' +import { deepSetLazyRouterPrefix, getLazyRouterPrefix, getRouterContract, setRouterContract } from './hidden' + +describe('setRouterContract', () => { + const ping = oc.route({}) + const baseContract = { ping } + const nestedContract = { ping, nested: { ping } } + + it('sets contract on empty object', () => { + const obj = {} + const router = setRouterContract(obj, baseContract) + expect(getRouterContract(router)).toBe(baseContract) + }) + + it('preserves original object properties', () => { + const obj = { existingProp: 'value' } + const router = setRouterContract(obj, baseContract) + expect(router.existingProp).toBe('value') + expect(getRouterContract(router)).toBe(baseContract) + }) + + it('handles nested contracts', () => { + const obj = { nested: { value: 42 } } + const router = setRouterContract(obj, nestedContract) + expect(getRouterContract(router)).toBe(nestedContract) + expect(router.nested.value).toBe(42) + expect(getRouterContract(router.nested)).toBeUndefined() + }) + + it('allows contract overwriting', () => { + const obj = {} + const router1 = setRouterContract(obj, baseContract) + const router2 = setRouterContract(router1, nestedContract) + expect(getRouterContract(router2)).toBe(nestedContract) + }) +}) + +describe('deepSetLazyRouterPrefix', () => { + it('sets prefix on root object', () => { + const obj = { value: 1 } + const prefixed = deepSetLazyRouterPrefix(obj, '/api') + expect(getLazyRouterPrefix(prefixed)).toBe('/api') + expect(prefixed.value).toBe(1) + }) + + it('sets prefix on all nested objects', () => { + const obj = { + l1: { + l2: { + l3: { value: 42 }, + }, + }, + } + const prefixed = deepSetLazyRouterPrefix(obj, '/api') + expect(getLazyRouterPrefix(prefixed)).toBe('/api') + expect(getLazyRouterPrefix(prefixed.l1)).toBe('/api') + expect(getLazyRouterPrefix(prefixed.l1.l2)).toBe('/api') + expect(getLazyRouterPrefix(prefixed.l1.l2.l3)).toBe('/api') + expect(prefixed.l1.l2.l3.value).toBe(42) + }) + + it('handles functions in objects', () => { + const obj = { + fn: () => 42, + nested: { fn: () => 43 }, + } + const prefixed = deepSetLazyRouterPrefix(obj, '/api') + expect(getLazyRouterPrefix(prefixed.fn)).toBe('/api') + expect(getLazyRouterPrefix(prefixed.nested.fn)).toBe('/api') + expect(prefixed.fn()).toBe(42) + expect(prefixed.nested.fn()).toBe(43) + }) + + it('allows prefix override', () => { + const obj = { value: 1 } + const prefixed1 = deepSetLazyRouterPrefix(obj, '/api') + const prefixed2 = deepSetLazyRouterPrefix(prefixed1, '/v2') + + expect(getLazyRouterPrefix(prefixed1)).toBe('/api') + expect(getLazyRouterPrefix(prefixed2)).toBe('/v2') + expect(prefixed2.value).toBe(1) + }) + + it('handles nested prefix override', () => { + const obj = { + l1: { value: 1 }, + l2: { value: 2 }, + } + const prefixed1 = deepSetLazyRouterPrefix(obj, '/api') + const prefixed2 = deepSetLazyRouterPrefix(prefixed1.l1, '/v2') + + expect(getLazyRouterPrefix(prefixed1)).toBe('/api') + expect(getLazyRouterPrefix(prefixed1.l2)).toBe('/api') + expect(getLazyRouterPrefix(prefixed2)).toBe('/v2') + expect(prefixed2.value).toBe(1) + }) +}) diff --git a/packages/server/src/hidden.ts b/packages/server/src/hidden.ts new file mode 100644 index 000000000..59198c9cd --- /dev/null +++ b/packages/server/src/hidden.ts @@ -0,0 +1,42 @@ +import type { ContractRouter, HTTPPath } from '@orpc/contract' + +const ROUTER_CONTRACT_SYMBOL = Symbol('ORPC_ROUTER_CONTRACT') + +export function setRouterContract(obj: T, contract: ContractRouter): T { + return new Proxy(obj, { + get(target, key) { + if (key === ROUTER_CONTRACT_SYMBOL) { + return contract + } + + return Reflect.get(target, key) + }, + }) +} + +export function getRouterContract(obj: object): ContractRouter | undefined { + return (obj as any)[ROUTER_CONTRACT_SYMBOL] +} + +const LAZY_ROUTER_PREFIX_SYMBOL = Symbol('ORPC_LAZY_ROUTER_PREFIX') + +export function deepSetLazyRouterPrefix(router: T, prefix: HTTPPath): T { + return new Proxy(router, { + get(target, key) { + if (key !== LAZY_ROUTER_PREFIX_SYMBOL) { + const val = Reflect.get(target, key) + if (val && (typeof val === 'object' || typeof val === 'function')) { + return deepSetLazyRouterPrefix(val, prefix) + } + + return val + } + + return prefix + }, + }) +} + +export function getLazyRouterPrefix(obj: object): HTTPPath | undefined { + return (obj as any)[LAZY_ROUTER_PREFIX_SYMBOL] +} diff --git a/packages/server/src/implementer-chainable.test-d.ts b/packages/server/src/implementer-chainable.test-d.ts new file mode 100644 index 000000000..63630b156 --- /dev/null +++ b/packages/server/src/implementer-chainable.test-d.ts @@ -0,0 +1,121 @@ +import type { ChainableImplementer } from './implementer-chainable' +import type { Middleware } from './middleware' +import type { ProcedureImplementer } from './procedure-implementer' +import type { RouterImplementer } from './router-implementer' +import type { WELL_CONTEXT } from './types' +import { oc } from '@orpc/contract' +import { z } from 'zod' +import { createChainableImplementer } from './implementer-chainable' + +const schema = z.object({ val: z.string().transform(val => Number(val)) }) + +const ping = oc.input(schema).output(schema) +const pong = oc.route({ method: 'GET', path: '/ping' }) + +const contract = oc.router({ + ping, + pong, + nested: { + ping, + pong, + }, +}) + +describe('ChainableImplementer', () => { + it('with procedure', () => { + expectTypeOf(createChainableImplementer(ping)).toEqualTypeOf< + ProcedureImplementer + >() + + expectTypeOf(createChainableImplementer(pong)).toEqualTypeOf< + ProcedureImplementer + >() + }) + + it('with router', () => { + const implementer = createChainableImplementer(contract) + + expectTypeOf(implementer).toMatchTypeOf< + Omit, '~type' | '~orpc'> + >() + + expectTypeOf(implementer.ping).toEqualTypeOf< + ProcedureImplementer + >() + + expectTypeOf(implementer.pong).toEqualTypeOf< + ProcedureImplementer + >() + + expectTypeOf(implementer.nested).toMatchTypeOf< + Omit, '~type' | '~orpc'> + >() + + expectTypeOf(implementer.nested.ping).toEqualTypeOf< + ProcedureImplementer + >() + + expectTypeOf(implementer.nested.pong).toEqualTypeOf< + ProcedureImplementer + >() + }) + + it('not expose properties of router implementer', () => { + const implementer = createChainableImplementer(contract) + + expectTypeOf(implementer).not.toHaveProperty('~orpc') + expectTypeOf(implementer).not.toHaveProperty('~type') + expectTypeOf(implementer.router).not.toHaveProperty('~orpc') + expectTypeOf(implementer.router).not.toHaveProperty('~type') + }) + + it('works on conflicted', () => { + const contract = oc.router({ + use: ping, + router: { + use: ping, + router: pong, + }, + }) + + const implementer = createChainableImplementer(contract) + + expectTypeOf(implementer).toMatchTypeOf< + Omit, '~type' | '~orpc'> + >() + + expectTypeOf(implementer.use).toMatchTypeOf< + ProcedureImplementer + >() + + expectTypeOf(implementer.router).toMatchTypeOf< + Omit, '~type' | '~orpc'> + >() + + expectTypeOf(implementer.router.use).toMatchTypeOf< + ProcedureImplementer + >() + + expectTypeOf(implementer.router.router).toMatchTypeOf< + ProcedureImplementer + >() + }) +}) + +describe('createChainableImplementer', () => { + it('with procedure', () => { + const implementer = createChainableImplementer(ping) + expectTypeOf(implementer).toEqualTypeOf>() + }) + + it('with router', () => { + const implementer = createChainableImplementer(contract) + expectTypeOf(implementer).toEqualTypeOf>() + }) + + it('with middlewares', () => { + const mid = {} as Middleware<{ auth: boolean }, { db: string }, unknown, unknown> + const implementer = createChainableImplementer(contract, [mid]) + expectTypeOf(implementer).toEqualTypeOf>() + }) +}) diff --git a/packages/server/src/implementer-chainable.test.ts b/packages/server/src/implementer-chainable.test.ts new file mode 100644 index 000000000..f2bc10b00 --- /dev/null +++ b/packages/server/src/implementer-chainable.test.ts @@ -0,0 +1,116 @@ +import { oc } from '@orpc/contract' +import { z } from 'zod' +import { createChainableImplementer } from './implementer-chainable' +import { ProcedureImplementer } from './procedure-implementer' +import { RouterImplementer } from './router-implementer' + +describe('createChainableImplementer', () => { + const schema = z.object({ val: z.string().transform(val => Number(val)) }) + + const ping = oc.input(schema).output(schema) + const pong = oc.route({ method: 'GET', path: '/ping' }) + + const contract = oc.router({ + ping, + pong, + nested: { + ping, + pong, + }, + }) + + const mid1 = vi.fn() + const mid2 = vi.fn() + const mid3 = vi.fn() + + it('with procedure', () => { + const implementer = createChainableImplementer(ping, [mid1, mid2]) + + expect(implementer).toBeInstanceOf(ProcedureImplementer) + expect(implementer['~orpc'].middlewares).toEqual([mid1, mid2]) + expect(implementer['~orpc'].contract).toBe(ping) + }) + + it('with router', () => { + const implementer = createChainableImplementer(contract, [mid1, mid2]) + + expect(implementer.use(mid3)['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) + expect(implementer.use(mid3)['~orpc'].contract).toBe(contract) + + expect(implementer.ping).toBeInstanceOf(ProcedureImplementer) + expect(implementer.ping['~orpc'].middlewares).toEqual([mid1, mid2]) + expect(implementer.ping['~orpc'].contract).toBe(ping) + + expect(implementer.pong).toBeInstanceOf(ProcedureImplementer) + expect(implementer.pong['~orpc'].middlewares).toEqual([mid1, mid2]) + expect(implementer.pong['~orpc'].contract).toBe(pong) + + expect(implementer.nested.use(mid3)['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) + expect(implementer.nested.use(mid3)['~orpc'].contract).toBe(contract.nested) + + expect(implementer.nested.ping).toBeInstanceOf(ProcedureImplementer) + expect(implementer.nested.ping['~orpc'].middlewares).toEqual([mid1, mid2]) + expect(implementer.nested.ping['~orpc'].contract).toBe(contract.nested.ping) + + expect(implementer.nested.pong).toBeInstanceOf(ProcedureImplementer) + expect(implementer.nested.pong['~orpc'].middlewares).toEqual([mid1, mid2]) + expect(implementer.nested.pong['~orpc'].contract).toBe(contract.nested.pong) + }) + + describe('on conflicted', () => { + const contract = oc.router({ + 'use': ping, + 'router': { + use: ping, + router: pong, + }, + '~orpc': { + use: ping, + router: pong, + }, + '~type': { + use: ping, + router: pong, + }, + }) + + const implementer = createChainableImplementer(contract, [mid1, mid2]) + + it('still works', () => { + expect(implementer.use(mid3)['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) + expect(implementer.use(mid3)['~orpc'].contract).toBe(contract) + + expect(implementer.use).toBeTypeOf('function') + expect(implementer.use.use(mid3)).toBeInstanceOf(ProcedureImplementer) + expect(implementer.use.use(mid3)['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) + expect(implementer.use['~orpc'].contract).toBe(ping) + + expect(implementer.router).toBeTypeOf('function') + expect(implementer.router.use(mid3)).toBeInstanceOf(RouterImplementer) + expect(implementer.router.use(mid3)['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) + expect(implementer.router.use(mid3)['~orpc'].contract).toBe(contract.router) + + expect(implementer.router.router).toBeTypeOf('function') + expect(implementer.router.router.use(mid3)).toBeInstanceOf(ProcedureImplementer) + expect(implementer.router.router.use(mid3)['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) + expect(implementer.router.router['~orpc'].contract).toBe(contract.router.router) + + expect(implementer.router.use).toBeTypeOf('function') + expect(implementer.router.use.use(mid3)).toBeInstanceOf(ProcedureImplementer) + expect(implementer.router.use.use(mid3)['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) + expect(implementer.router.use['~orpc'].contract).toBe(contract.router.use) + + expect(implementer['~orpc'].use).toBeTypeOf('function') + expect(implementer['~orpc'].use.use(mid3)).toBeInstanceOf(ProcedureImplementer) + expect(implementer['~orpc'].use.use(mid3)['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) + expect(implementer['~orpc'].use['~orpc'].contract).toBe(contract.router.use) + }) + + it('not recursive on symbol', () => { + expect((implementer as any)[Symbol('something')]).toBeUndefined() + expect((implementer.use as any)[Symbol('something')]).toBeUndefined() + expect((implementer.router as any)[Symbol('something')]).toBeUndefined() + expect((implementer.router.use as any)[Symbol('something')]).toBeUndefined() + }) + }) +}) diff --git a/packages/server/src/implementer-chainable.ts b/packages/server/src/implementer-chainable.ts new file mode 100644 index 000000000..643773ad2 --- /dev/null +++ b/packages/server/src/implementer-chainable.ts @@ -0,0 +1,61 @@ +import type { Middleware } from './middleware' +import type { Context, MergeContext, WELL_CONTEXT } from './types' +import { type ContractProcedure, type ContractRouter, isContractProcedure } from '@orpc/contract' +import { createCallableObject } from '@orpc/shared' +import { ProcedureImplementer } from './procedure-implementer' +import { RouterImplementer } from './router-implementer' + +export type ChainableImplementer< + TContext extends Context, + TExtraContext extends Context, + TContract extends ContractRouter, +> = TContract extends ContractProcedure + ? ProcedureImplementer + : { + [K in keyof TContract]: TContract[K] extends ContractRouter ? ChainableImplementer : never + } & Omit, '~type' | '~orpc'> + +export function createChainableImplementer< + TContext extends Context = WELL_CONTEXT, + TExtraContext extends Context = undefined, + TContract extends ContractRouter = any, +>( + contract: TContract, + middlewares?: Middleware, Partial | undefined, unknown, any>[], +): ChainableImplementer { + if (isContractProcedure(contract)) { + const implementer = new ProcedureImplementer({ + contract, + middlewares, + }) + + return implementer as any + } + + const chainable = {} as ChainableImplementer + + for (const key in contract) { + (chainable as any)[key] = createChainableImplementer(contract[key]!, middlewares) + } + + const routerImplementer = new RouterImplementer({ contract, middlewares }) + + const merged = new Proxy(chainable, { + get(target, key) { + const next = Reflect.get(target, key) as ChainableImplementer | undefined + const method = Reflect.get(routerImplementer, key) + + if (typeof key !== 'string' || typeof method !== 'function') { + return next + } + + if (!next) { + return method.bind(routerImplementer) + } + + return createCallableObject(next, method.bind(routerImplementer)) + }, + }) + + return merged as any +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1af06f06e..4ffbfb393 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,18 +1,24 @@ +import type { WELL_CONTEXT } from './types' import { Builder } from './builder' export * from './builder' +export * from './hidden' +export * from './implementer-chainable' export * from './lazy' +export * from './lazy-decorated' export * from './middleware' +export * from './middleware-decorated' export * from './procedure' export * from './procedure-builder' -export * from './procedure-caller' +export * from './procedure-client' +export * from './procedure-decorated' export * from './procedure-implementer' export * from './router' export * from './router-builder' -export * from './router-caller' +export * from './router-client' export * from './router-implementer' export * from './types' export * from './utils' export * from '@orpc/shared/error' -export const os = new Builder, undefined>() +export const os = new Builder({}) diff --git a/packages/server/src/lazy-decorated.test-d.ts b/packages/server/src/lazy-decorated.test-d.ts new file mode 100644 index 000000000..6e30a7ea4 --- /dev/null +++ b/packages/server/src/lazy-decorated.test-d.ts @@ -0,0 +1,111 @@ +import type { ANY_PROCEDURE, ANY_ROUTER, DecoratedProcedure, Procedure, ProcedureClient, WELL_CONTEXT } from '.' +import type { Lazy } from './lazy' +import type { DecoratedLazy } from './lazy-decorated' +import { z } from 'zod' +import { lazy } from './lazy' +import { decorateLazy } from './lazy-decorated' + +const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + +const ping = {} as Procedure +const pong = {} as DecoratedProcedure + +const lazyPing = lazy(() => Promise.resolve({ default: ping })) +const lazyPong = lazy(() => Promise.resolve({ default: pong })) + +const router = { + ping, + pong, + nested: { + ping, + pong, + }, +} + +const lazyRouter = lazy(() => Promise.resolve({ + default: { + ping: lazyPing, + pong, + nested: lazy(() => Promise.resolve({ + default: { + ping, + pong: lazyPong, + }, + })), + }, +})) + +describe('DecoratedLazy', () => { + it('with procedure', () => { + const decorated = {} as DecoratedLazy + + expectTypeOf(decorated).toMatchTypeOf>() + + expectTypeOf(decorated).toMatchTypeOf< + ProcedureClient + >() + }) + + it('with router', () => { + const decorated = {} as DecoratedLazy + + expectTypeOf(decorated).toMatchTypeOf>() + expectTypeOf({ router: decorated }).toMatchTypeOf() + + expectTypeOf(decorated.ping).toMatchTypeOf>() + expectTypeOf(decorated.ping).toMatchTypeOf >() + + expectTypeOf(decorated.pong).toMatchTypeOf>() + expectTypeOf(decorated.pong).toMatchTypeOf>() + + expectTypeOf(decorated.nested).toMatchTypeOf>() + expectTypeOf({ router: decorated.nested }).toMatchTypeOf() + + expectTypeOf(decorated.nested.ping).toMatchTypeOf>() + expectTypeOf(decorated.nested.ping).toMatchTypeOf>() + + expectTypeOf(decorated.nested.pong).toMatchTypeOf>() + expectTypeOf(decorated.nested.pong).toMatchTypeOf>() + }) + + it('flat lazy', () => { + expectTypeOf>().toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf>() + expectTypeOf>>().toEqualTypeOf>() + + expectTypeOf['ping']>().toEqualTypeOf['ping']>() + expectTypeOf['pong']>().toEqualTypeOf['pong']>() + expectTypeOf['nested']['ping']>().toEqualTypeOf['nested']['ping']>() + expectTypeOf['nested']['pong']>().toEqualTypeOf['nested']['pong']>() + + // @ts-expect-error - lazy loader is diff + expectTypeOf['nested']>().toEqualTypeOf['nested']>() + }) + + it('not callable when context is required', () => { + const d1 = {} as DecoratedLazy + const d2 = {} as DecoratedLazy + const d3 = {} as DecoratedLazy> + const d4 = {} as DecoratedLazy> + + d1() + d3() + + // @ts-expect-error --- cannot call on router level + d2() + // @ts-expect-error --- context is required + d4() + }) +}) + +it('decorateLazy', () => { + expectTypeOf(decorateLazy(lazyPing)).toEqualTypeOf>() + expectTypeOf(decorateLazy(lazyPong)).toEqualTypeOf>() + expectTypeOf(decorateLazy(lazy(() => Promise.resolve({ default: router })))).toEqualTypeOf>() + + // @ts-expect-error - invalid lazy + decorateLazy(ping) + + // @ts-expect-error - invalid lazy + decorateLazy(router) +}) diff --git a/packages/server/src/lazy-decorated.test.ts b/packages/server/src/lazy-decorated.test.ts new file mode 100644 index 000000000..83aae92c5 --- /dev/null +++ b/packages/server/src/lazy-decorated.test.ts @@ -0,0 +1,102 @@ +import { ContractProcedure } from '@orpc/contract' +import { z } from 'zod' +import { isLazy, lazy, unlazy } from './lazy' +import { decorateLazy } from './lazy-decorated' +import { Procedure } from './procedure' +import { createProcedureClient } from './procedure-client' + +vi.mock('./procedure-client', () => ({ + createProcedureClient: vi.fn(() => vi.fn()), +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('decorated lazy', () => { + const schema = z.object({ val: z.string().transform(val => Number(val)) }) + + const ping = new Procedure({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: undefined, + }), + func: vi.fn(), + middlewares: [], + }) + + const lazyPing = lazy(() => Promise.resolve({ default: ping })) + + it('still a lazy', async () => { + expect(decorateLazy(lazyPing)).toSatisfy(isLazy) + + expect((await unlazy(decorateLazy(lazyPing))).default).toBe(ping) + + const l2 = lazy(() => Promise.resolve({ default: { ping } })) + expect(decorateLazy(l2)).toSatisfy(isLazy) + expect((await unlazy(decorateLazy(l2))).default.ping).toBe(ping) + + const l3 = lazy(() => Promise.resolve({ default: { ping: lazyPing } })) + expect(decorateLazy(l3)).toSatisfy(isLazy) + expect((await unlazy(decorateLazy(l3))).default.ping).toBe(lazyPing) + }) + + it('return undefined when not exists child', () => { + const decorated = decorateLazy(lazy(() => Promise.resolve({ default: { ping: { pong: lazyPing } } }))) as any + + const child = decorated.ping.pong.peng.pang.p + + expect(child).toBeInstanceOf(Function) + expect(child).toSatisfy(isLazy) + + expect(unlazy(child)).resolves.toEqual({ default: undefined }) + }) + + describe('callable', () => { + const nested = { ping: lazyPing } + const router = { nested } + /** decorated lazy is recursive proxy no does need to care what is the original on logic test, (typed will do it) */ + const lazied = lazy(() => Promise.resolve({ default: lazy(() => Promise.resolve({ default: router })) })) + + const controller = new AbortController() + const signal = controller.signal + + const caller = vi.fn(() => '__mocked__') + vi.mocked(createProcedureClient).mockReturnValue(caller as any) + + it('on root', async () => { + const decorated = decorateLazy(lazied) as any + expect(decorated).toBeInstanceOf(Function) + + expect(createProcedureClient).toHaveBeenCalledTimes(1) + expect(createProcedureClient).toHaveBeenCalledWith({ + procedure: expect.any(Object), + context: undefined, + }) + expect(vi.mocked(createProcedureClient).mock.calls[0]![0].procedure).toSatisfy(isLazy) + expect(unlazy(vi.mocked(createProcedureClient).mock.calls[0]![0].procedure as any)).rejects.toThrow('Expected a lazy but got lazy') + + expect(await decorated({ val: '1' }, { signal })).toBe('__mocked__') + expect(caller).toHaveBeenCalledTimes(1) + expect(caller).toHaveBeenCalledWith({ val: '1' }, { signal }) + }) + + it('on nested', async () => { + const decorated = decorateLazy(lazied).nested.ping as any + expect(decorated).toBeInstanceOf(Function) + + expect(createProcedureClient).toHaveBeenCalledTimes(3) + expect(createProcedureClient).toHaveBeenNthCalledWith(3, { + procedure: expect.any(Object), + context: undefined, + }) + expect(vi.mocked(createProcedureClient).mock.calls[2]![0].procedure).toSatisfy(isLazy) + const unwrapped = await unlazy(vi.mocked(createProcedureClient).mock.calls[2]![0].procedure as any) + expect(unwrapped.default).toBe(ping) + + expect(await decorated({ val: '1' }, { signal })).toBe('__mocked__') + expect(caller).toHaveBeenCalledTimes(1) + expect(caller).toHaveBeenCalledWith({ val: '1' }, { signal }) + }) + }) +}) diff --git a/packages/server/src/lazy-decorated.ts b/packages/server/src/lazy-decorated.ts new file mode 100644 index 000000000..a258de516 --- /dev/null +++ b/packages/server/src/lazy-decorated.ts @@ -0,0 +1,46 @@ +import type { SchemaInput, SchemaOutput } from '@orpc/contract' +import type { Lazy } from './lazy' +import type { Procedure } from './procedure' +import type { ProcedureClient } from './procedure-client' +import { flatLazy } from './lazy' +import { createLazyProcedureFormAnyLazy } from './lazy-utils' +import { createProcedureClient } from './procedure-client' +import { type ANY_ROUTER, getRouterChild } from './router' + +export type DecoratedLazy = T extends Lazy + ? DecoratedLazy + : Lazy + & ( + T extends Procedure + ? undefined extends UContext + ? ProcedureClient, SchemaOutput> + : unknown + : { + [K in keyof T]: T[K] extends object ? DecoratedLazy : never + } + ) + +export function decorateLazy>(lazied: T): DecoratedLazy { + const flattenLazy = flatLazy(lazied) + + const procedureProcedureClient = createProcedureClient({ + procedure: createLazyProcedureFormAnyLazy(flattenLazy), + context: undefined, + }) + + Object.assign(procedureProcedureClient, flattenLazy) + + const recursive = new Proxy(procedureProcedureClient, { + get(target, key) { + if (typeof key !== 'string') { + return Reflect.get(target, key) + } + + const next = getRouterChild(flattenLazy, key) + + return decorateLazy(next) + }, + }) + + return recursive as any +} diff --git a/packages/server/src/lazy-utils.test-d.ts b/packages/server/src/lazy-utils.test-d.ts new file mode 100644 index 000000000..13fe13e9c --- /dev/null +++ b/packages/server/src/lazy-utils.test-d.ts @@ -0,0 +1,10 @@ +import type { Lazy } from './lazy' +import type { ANY_PROCEDURE } from './procedure' +import { lazy } from './lazy' +import { createLazyProcedureFormAnyLazy } from './lazy-utils' + +it('createLazyProcedureFormAnyLazy return a Lazy', async () => { + const lazyPing = lazy(() => Promise.resolve({ default: {} as unknown })) + const lazyProcedure = createLazyProcedureFormAnyLazy(lazyPing) + expectTypeOf(lazyProcedure).toEqualTypeOf>() +}) diff --git a/packages/server/src/lazy-utils.test.ts b/packages/server/src/lazy-utils.test.ts new file mode 100644 index 000000000..7bd1c4247 --- /dev/null +++ b/packages/server/src/lazy-utils.test.ts @@ -0,0 +1,37 @@ +import { ContractProcedure } from '@orpc/contract' +import { isLazy, lazy, unlazy } from './lazy' +import { createLazyProcedureFormAnyLazy } from './lazy-utils' +import { Procedure } from './procedure' + +describe('createLazyProcedureFormAnyLazy', () => { + const ping = new Procedure({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + }), + func: vi.fn(), + }) + + it('return a Lazy', async () => { + const lazyPing = lazy(() => Promise.resolve({ default: ping })) + + const lazyProcedure = createLazyProcedureFormAnyLazy(lazyPing) + + expect(lazyProcedure).toSatisfy(isLazy) + expect(unlazy(lazyProcedure)).resolves.toEqual({ default: ping }) + }) + + it('throw un unlazy non-procedure', () => { + const lazyPing = lazy(() => Promise.resolve({ default: {} as unknown })) + const lazyProcedure = createLazyProcedureFormAnyLazy(lazyPing) + + expect(unlazy(lazyProcedure)).rejects.toThrow('Expected a lazy but got lazy') + }) + + it('flat lazy', () => { + const lazyPing = lazy(() => Promise.resolve({ default: lazy(() => Promise.resolve({ default: ping })) })) + const lazyProcedure = createLazyProcedureFormAnyLazy(lazyPing) + + expect(unlazy(lazyProcedure)).resolves.toEqual({ default: ping }) + }) +}) diff --git a/packages/server/src/lazy-utils.ts b/packages/server/src/lazy-utils.ts new file mode 100644 index 000000000..881f97846 --- /dev/null +++ b/packages/server/src/lazy-utils.ts @@ -0,0 +1,21 @@ +import type { Lazy } from './lazy' +import { flatLazy, lazy, unlazy } from './lazy' +import { type ANY_PROCEDURE, isProcedure } from './procedure' + +export function createLazyProcedureFormAnyLazy(lazied: Lazy): Lazy { + const lazyProcedure = lazy(async () => { + const { default: maybeProcedure } = await unlazy(flatLazy(lazied)) + + if (!isProcedure(maybeProcedure)) { + throw new Error(` + Expected a lazy but got lazy. + This should be caught by TypeScript compilation. + Please report this issue if this makes you feel uncomfortable. + `) + } + + return { default: maybeProcedure } + }) + + return lazyProcedure +} diff --git a/packages/server/src/lazy.test-d.ts b/packages/server/src/lazy.test-d.ts index 8899341da..4094e9459 100644 --- a/packages/server/src/lazy.test-d.ts +++ b/packages/server/src/lazy.test-d.ts @@ -1,122 +1,53 @@ -import type { ANY_PROCEDURE, Router } from '.' -import type { Lazy } from './lazy' -import { z } from 'zod' -import { os } from '.' -import { createLazy, decorateLazy } from './lazy' +import type { ANY_LAZY, FlattenLazy, Lazy } from './lazy' +import type { Procedure } from './procedure' +import type { WELL_CONTEXT } from './types' +import { flatLazy, isLazy, lazy, unlazy } from './lazy' -const router = { - ping: os.input(z.string()).func(() => 'pong'), - pong: os.func(() => 'ping'), -} -const lazyPing = createLazy(() => Promise.resolve({ default: router.ping })) -const lazyPong = createLazy(() => Promise.resolve({ default: router.pong })) -const lazyRouter = createLazy(() => Promise.resolve({ default: router })) -const nestedLazyRouter = createLazy(() => Promise.resolve({ default: lazyRouter })) -const complexLazyRouter = createLazy(() => Promise.resolve({ - default: { - ...router, - lazyRouter, - nestedLazyRouter, - }, -})) +const procedure = {} as Procedure -describe('DecoratedLazy', () => { - it('with procedure', () => { - const decorated = decorateLazy(lazyPing) +const router = { procedure } - type IsLazyProcedure = typeof decorated extends Lazy ? true : false - expectTypeOf().toEqualTypeOf() +it('lazy', () => { + expectTypeOf( + lazy(() => Promise.resolve({ default: procedure })), + ).toMatchTypeOf>() - expectTypeOf(decorated).toMatchTypeOf< - (input: string) => Promise - >() - - expectTypeOf(decorated('test')).toMatchTypeOf>() - }) - - it('with router', () => { - const decorated = decorateLazy(lazyRouter) - - type IsRouter = typeof decorated extends Router ? true : false - expectTypeOf().toEqualTypeOf() - - expectTypeOf(decorated).toMatchTypeOf<{ - ping: (input: string) => Promise - pong: () => Promise - }>() - - expectTypeOf(decorated.ping).toMatchTypeOf<(input: string) => Promise>() - expectTypeOf(decorated.ping('test')).toMatchTypeOf>() - - expectTypeOf(decorated.pong).toMatchTypeOf<() => Promise>() - expectTypeOf(decorated.pong()).toMatchTypeOf>() - }) - - it('with nested router', () => { - const decorated = decorateLazy(nestedLazyRouter) - - type IsRouter = typeof decorated extends Router ? true : false - expectTypeOf().toEqualTypeOf() - - expectTypeOf(decorated).toMatchTypeOf<{ - ping: (input: string) => Promise - pong: () => Promise - }>() - - expectTypeOf(decorated.ping).toMatchTypeOf<(input: string) => Promise>() - expectTypeOf(decorated.ping('test')).toMatchTypeOf>() - - expectTypeOf(decorated.pong).toMatchTypeOf<() => Promise>() - expectTypeOf(decorated.pong()).toMatchTypeOf>() - }) - - it('with complex router', () => { - const decorated = decorateLazy(complexLazyRouter) - - type IsRouter = typeof decorated extends Router ? true : false - expectTypeOf().toEqualTypeOf() - - expectTypeOf(decorated).toMatchTypeOf<{ - ping: (input: string) => Promise - pong: () => Promise - }>() - - expectTypeOf(decorated.nestedLazyRouter).toMatchTypeOf<{ - ping: (input: string) => Promise - pong: () => Promise - }>() - - expectTypeOf(decorated.nestedLazyRouter).toMatchTypeOf<{ - ping: (input: string) => Promise - pong: () => Promise - }>() - - expectTypeOf(decorated.ping).toMatchTypeOf<(input: string) => Promise>() - expectTypeOf(decorated.ping('test')).toMatchTypeOf>() + expectTypeOf( + lazy(() => Promise.resolve({ default: router })), + ).toMatchTypeOf>() +}) - expectTypeOf(decorated.pong).toMatchTypeOf<() => Promise>() - expectTypeOf(decorated.pong()).toMatchTypeOf>() +it('isLazy', () => { + const item = {} as unknown - expectTypeOf(decorated.lazyRouter).toMatchTypeOf<{ - ping: (input: string) => Promise - pong: () => Promise - }>() + if (isLazy(item)) { + expectTypeOf(item).toEqualTypeOf() + } +}) - expectTypeOf(decorated.lazyRouter.ping).toMatchTypeOf<(input: string) => Promise>() - expectTypeOf(decorated.lazyRouter.ping('test')).toMatchTypeOf>() +it('unwrapLazy', () => { + expectTypeOf( + unlazy(lazy(() => Promise.resolve({ default: procedure }))), + ).toMatchTypeOf>() - expectTypeOf(decorated.lazyRouter.pong).toMatchTypeOf<() => Promise>() - expectTypeOf(decorated.lazyRouter.pong()).toMatchTypeOf>() + expectTypeOf( + unlazy(lazy(() => Promise.resolve({ default: router }))), + ).toMatchTypeOf>() +}) - expectTypeOf(decorated.nestedLazyRouter).toMatchTypeOf<{ - ping: (input: string) => Promise - pong: () => Promise - }>() +it('FlattenLazy', () => { + expectTypeOf>>>().toMatchTypeOf>() + expectTypeOf < FlattenLazy>>>>().toMatchTypeOf>() +}) - expectTypeOf(decorated.nestedLazyRouter.ping).toMatchTypeOf<(input: string) => Promise>() - expectTypeOf(decorated.nestedLazyRouter.ping('test')).toMatchTypeOf>() +it('flatLazy', () => { + expectTypeOf( + flatLazy(lazy(() => Promise.resolve({ default: lazy(() => Promise.resolve({ default: procedure })) }))), + ).toMatchTypeOf>() - expectTypeOf(decorated.nestedLazyRouter.pong).toMatchTypeOf<() => Promise>() - expectTypeOf(decorated.nestedLazyRouter.pong()).toMatchTypeOf>() - }) + expectTypeOf( + flatLazy(lazy(() => Promise.resolve({ default: lazy(() => Promise.resolve({ + default: lazy(() => Promise.resolve({ default: router })), + })) }))), + ).toMatchTypeOf>() }) diff --git a/packages/server/src/lazy.test.ts b/packages/server/src/lazy.test.ts index 330dbd536..cb7aa52e0 100644 --- a/packages/server/src/lazy.test.ts +++ b/packages/server/src/lazy.test.ts @@ -1,121 +1,48 @@ -import { describe, expect, it, vi } from 'vitest' -import { z } from 'zod' -import { os } from '.' -import { - createFlattenLazy, - createLazy, - decorateLazy, - isLazy, - LAZY_LOADER_SYMBOL, - loadLazy, -} from './lazy' - -describe('createLazy', () => { - it('should create a lazy object with a loader function', () => { - const mockLoader = vi.fn().mockResolvedValue({ default: 'test' }) - const lazyObj = createLazy(mockLoader) - - expect(lazyObj[LAZY_LOADER_SYMBOL]).toBe(mockLoader) - }) +import type { WELL_CONTEXT } from './types' +import { ContractProcedure } from '@orpc/contract' +import { flatLazy, isLazy, lazy, LAZY_LOADER_SYMBOL, unlazy } from './lazy' +import { Procedure } from './procedure' + +const procedure = new Procedure({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + }), + func: vi.fn(), + middlewares: [], }) -describe('loadLazy', () => { - it('should call the loader function and return the result', async () => { - const mockLoader = vi.fn().mockResolvedValue({ default: 'loaded value' }) - const lazyObj = createLazy(mockLoader) +const router = { procedure } - const result = await loadLazy(lazyObj) - - expect(mockLoader).toHaveBeenCalledOnce() - expect(result).toEqual({ default: 'loaded value' }) - }) -}) +it('lazy', () => { + const procedureLoader = () => Promise.resolve({ default: procedure }) + const routerLoader = () => Promise.resolve({ default: router }) -describe('isLazy', () => { - it('should return true for a lazy object', () => { - const lazyObj = createLazy(() => Promise.resolve({ default: 'test' })) - expect(isLazy(lazyObj)).toBe(true) - }) + expect(lazy(procedureLoader)).toSatisfy(isLazy) + expect(lazy(routerLoader)).toSatisfy(isLazy) - it('should return false for non-lazy objects', () => { - expect(isLazy(null)).toBe(false) - expect(isLazy(undefined)).toBe(false) - expect(isLazy({})).toBe(false) - expect(isLazy({ someOtherSymbol: () => { } })).toBe(false) - }) + expect(lazy(procedureLoader)[LAZY_LOADER_SYMBOL]).toBe(procedureLoader) + expect(lazy(routerLoader)[LAZY_LOADER_SYMBOL]).toBe(routerLoader) }) -describe('createFlattenLazy', () => { - it('should flatten nested lazy objects', async () => { - const innerMostLoader = vi.fn().mockResolvedValue({ default: 'final value' }) - const innerLoader = vi.fn().mockResolvedValue({ - default: createLazy(innerMostLoader), - }) - const outerLoader = vi.fn().mockResolvedValue({ - default: createLazy(innerLoader), - }) - - const flattenedLazy = createFlattenLazy(createLazy(outerLoader)) - - const result = await loadLazy(flattenedLazy) - - expect(outerLoader).toHaveBeenCalledOnce() - expect(innerLoader).toHaveBeenCalledOnce() - expect(innerMostLoader).toHaveBeenCalledOnce() - expect(result).toEqual({ default: 'final value' }) - }) - - it('should handle single-level lazy objects', async () => { - const loader = vi.fn().mockResolvedValue({ default: 'simple value' }) - const flattenedLazy = createFlattenLazy(createLazy(loader)) - - const result = await loadLazy(flattenedLazy) - - expect(loader).toHaveBeenCalledOnce() - expect(result).toEqual({ default: 'simple value' }) - }) +it('isLazy', () => { + expect(lazy(() => Promise.resolve({ default: procedure }))).toSatisfy(isLazy) + expect(lazy(() => Promise.resolve({ default: router }))).toSatisfy(isLazy) + expect({}).not.toSatisfy(isLazy) + expect(undefined).not.toSatisfy(isLazy) }) -describe('decorateLazy', () => { - const ping = os.input(z.string()).func(() => 'pong') - const pong = os.func(() => 'ping') - - const router = { - ping: createLazy(() => Promise.resolve({ default: ping })), - pong: createLazy(() => Promise.resolve({ default: pong })), - nested: { - ping: createLazy(() => Promise.resolve({ default: ping })), - pong: createLazy(() => Promise.resolve({ default: pong })), - }, - complex: createLazy(() => Promise.resolve({ - default: { - ping, - pong: createLazy(() => Promise.resolve({ default: pong })), - }, - })), - } - - it('should create a proxy for nested lazy loading', async () => { - const decoratedLazy = decorateLazy(createLazy(() => Promise.resolve({ default: router }))) +it('unwrapLazy', async () => { + const lazied = lazy(() => Promise.resolve({ default: 'root' })) - // Test method access - const methodResult = await decoratedLazy.ping('test') - expect(methodResult).toBe('pong') - - // Test nested method access - const nestedResult = await decoratedLazy.nested.pong('test') - expect(nestedResult).toBe('ping') - }) - - it('should create a proxy for complex lazy loading', async () => { - const decoratedLazy = decorateLazy(createLazy(() => Promise.resolve({ default: router }))) + expect(unlazy(lazied)).resolves.toEqual({ default: 'root' }) + expect((await unlazy(lazy(() => Promise.resolve({ default: lazied })))).default).toSatisfy(isLazy) +}) - // Test method access - const methodResult = await decoratedLazy.complex.ping('test') - expect(methodResult).toBe('pong') +it('flatLazy', () => { + const lazied = lazy(() => Promise.resolve({ default: 'root' })) - // Test nested method access - const nestedResult = await decoratedLazy.complex.pong('test') - expect(nestedResult).toBe('ping') - }) + expect(flatLazy(lazied)[LAZY_LOADER_SYMBOL]()).resolves.toEqual({ default: 'root' }) + expect(flatLazy(lazy(() => Promise.resolve({ default: lazied })))[LAZY_LOADER_SYMBOL]()).resolves.toEqual({ default: 'root' }) + expect(flatLazy(lazy(() => Promise.resolve({ default: lazy(() => Promise.resolve({ default: lazied })) })))[LAZY_LOADER_SYMBOL]()).resolves.toEqual({ default: 'root' }) }) diff --git a/packages/server/src/lazy.ts b/packages/server/src/lazy.ts index 460a524a9..05dee30ff 100644 --- a/packages/server/src/lazy.ts +++ b/packages/server/src/lazy.ts @@ -1,25 +1,19 @@ -import type { Procedure } from './procedure' -import type { ProcedureCaller } from './procedure-caller' -import { createProcedureCaller } from './procedure-caller' - export const LAZY_LOADER_SYMBOL: unique symbol = Symbol('ORPC_LAZY_LOADER') export interface Lazy { [LAZY_LOADER_SYMBOL]: () => Promise<{ default: T }> } +export type Lazyable = T | Lazy + export type ANY_LAZY = Lazy -export function createLazy(loader: () => Promise<{ default: T }>): Lazy { +export function lazy(loader: () => Promise<{ default: T }>): Lazy { return { [LAZY_LOADER_SYMBOL]: loader, } } -export function loadLazy(lazy: Lazy): Promise<{ default: T }> { - return lazy[LAZY_LOADER_SYMBOL]() -} - export function isLazy(item: unknown): item is ANY_LAZY { return ( (typeof item === 'object' || typeof item === 'function') @@ -29,65 +23,28 @@ export function isLazy(item: unknown): item is ANY_LAZY { ) } +export function unlazy>(lazied: T): Promise<{ default: T extends Lazy ? U : T }> { + return isLazy(lazied) ? lazied[LAZY_LOADER_SYMBOL]() : Promise.resolve({ default: lazied }) +} + export type FlattenLazy = T extends Lazy ? FlattenLazy : Lazy -export function createFlattenLazy(lazy: Lazy): FlattenLazy { +export function flatLazy(lazied: T): FlattenLazy { const flattenLoader = async () => { - let current = await loadLazy(lazy) + let current = await unlazy(lazied) while (true) { if (!isLazy(current.default)) { break } - current = await loadLazy(current.default) + current = await unlazy(current.default) } return current } - const flattenLazy = { - [LAZY_LOADER_SYMBOL]: flattenLoader, - } - - return flattenLazy as any -} - -export type DecoratedLazy = T extends Lazy - ? DecoratedLazy - : ( - T extends Procedure ? Lazy & (undefined extends UContext ? ProcedureCaller : unknown) - : T extends Record - ? { - [K in keyof T]: DecoratedLazy - } /** Notice: this still a lazy, but type not work when I & Lazy, maybe it's a bug, should improve */ - : Lazy - ) - -export function decorateLazy(lazy: Lazy): DecoratedLazy { - const flattenLazy = createFlattenLazy(lazy) - - const procedureCaller = createProcedureCaller({ - procedure: flattenLazy as any, - context: undefined as any, - }) - - Object.assign(procedureCaller, flattenLazy) - - const recursive = new Proxy(procedureCaller, { - get(target, key) { - if (typeof key !== 'string') { - return Reflect.get(target, key) - } - - return decorateLazy(createLazy(async () => { - const current = await loadLazy(flattenLazy) - return { default: (current.default as any)[key] } - })) - }, - }) - - return recursive as any + return lazy(flattenLoader) as any } diff --git a/packages/server/src/middleware-decorated.test-d.ts b/packages/server/src/middleware-decorated.test-d.ts new file mode 100644 index 000000000..afb06ef7b --- /dev/null +++ b/packages/server/src/middleware-decorated.test-d.ts @@ -0,0 +1,89 @@ +import type { Middleware, MiddlewareMeta } from './middleware' +import type { DecoratedMiddleware } from './middleware-decorated' +import type { WELL_CONTEXT } from './types' +import { decorateMiddleware } from './middleware-decorated' + +describe('decorateMiddleware', () => { + const decorated = decorateMiddleware( + (input: { name: string }, context: { user?: string }, meta) => meta.next({ context: { auth: true as const, user: 'string' } }), + ) + + it('assignable to middleware', () => { + const decorated = decorateMiddleware((input: { input: 'input' }, context, meta) => meta.next({})) + const mid: Middleware = decorated + + const decorated2 = decorateMiddleware((input, context, meta: MiddlewareMeta<'output'>) => meta.next({ context: { extra: true } })) + const mid2: Middleware = decorated2 + }) + + it('can map input', () => { + const mapped = decorated.mapInput((input: 'something') => ({ name: input })) + + expectTypeOf(mapped).toEqualTypeOf< + DecoratedMiddleware<{ user?: string }, { auth: true, user: string }, 'something', unknown> + >() + }) + + it('can concat', () => { + const mapped = decorated.concat( + (input: { age: number }, context, meta) => meta.next({ context: { db: true } }), + ) + + expectTypeOf(mapped).toEqualTypeOf< + DecoratedMiddleware< + { user?: string }, + { auth: true, user: string } & { db: boolean }, + { name: string } & { age: number }, + unknown + > + >() + }) + + it('can concat with map input', () => { + const mapped = decorated.concat( + (input: { age: number }, context, meta) => meta.next({ context: { db: true } }), + (input: { year: number }) => ({ age: 123 }), + ) + + expectTypeOf(mapped).toEqualTypeOf< + DecoratedMiddleware< + { user?: string }, + { auth: true, user: string } & { db: boolean }, + { name: string } & { year: number }, + unknown + > + >() + + decorated.concat( + (input: { age: number }, context, meta) => meta.next({ context: { db: true } }), + // @ts-expect-error - invalid return input + (input: { year: number }) => ({ age: '123' }), + ) + }) + + it('can concat and prevent conflict on context', () => { + const mapped = decorated.concat( + (input, context, meta) => meta.next({ context: { db: true } }), + ) + + expectTypeOf(mapped).toEqualTypeOf< + DecoratedMiddleware< + { user?: string }, + { auth: true, user: string } & { db: boolean }, + { name: string }, + unknown + > + >() + + decorated.concat( + // @ts-expect-error - user is not assignable to existing user context + (input, context, meta) => meta.next({ context: { user: true } }), + ) + + decorated.concat( + // @ts-expect-error - user is not assignable to existing user context + (input, context, meta) => meta.next({ context: { user: true } }), + () => 'anything', + ) + }) +}) diff --git a/packages/server/src/middleware-decorated.test.ts b/packages/server/src/middleware-decorated.test.ts new file mode 100644 index 000000000..e14df7ae8 --- /dev/null +++ b/packages/server/src/middleware-decorated.test.ts @@ -0,0 +1,84 @@ +import { decorateMiddleware } from './middleware-decorated' + +describe('decorateMiddleware', () => { + it('just a function', () => { + const fn = vi.fn() + const decorated = decorateMiddleware(fn) as any + + fn.mockReturnValueOnce('__mocked__') + + expect(decorated('input')).toBe('__mocked__') + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith('input') + }) + + it('can map input', () => { + const fn = vi.fn() + const map = vi.fn() + const decorated = decorateMiddleware(fn).mapInput(map) as any + + fn.mockReturnValueOnce('__mocked__') + map.mockReturnValueOnce('__input__') + + expect(decorated('something')).toBe('__mocked__') + + expect(map).toHaveBeenCalledTimes(1) + expect(map).toHaveBeenCalledWith('something') + + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith('__input__') + }) + + it('can concat', async () => { + const fn = vi.fn() + const fn2 = vi.fn() + const next = vi.fn() + + const decorated = decorateMiddleware((input, context, meta) => { + fn(input, context, meta) + return meta.next({ context: { auth: true } }) + }).concat((input, context, meta) => { + fn2(input, context, meta) + return meta.next({}) + }) as any + + next.mockReturnValueOnce('__mocked__') + + expect((await decorated('input', undefined, { next }))).toBe('__mocked__') + + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith('input', undefined, { next: expect.any(Function) }) + + expect(fn2).toHaveBeenCalledTimes(1) + expect(fn2).toHaveBeenCalledWith('input', { auth: true }, { next }) + }) + + it('can concat with map input', async () => { + const fn = vi.fn() + const fn2 = vi.fn() + const map = vi.fn() + const next = vi.fn() + + const decorated = decorateMiddleware((input, context, meta) => { + fn(input, context, meta) + return meta.next({ context: { auth: true } }) + }).concat((input, context, meta) => { + fn2(input, context, meta) + return meta.next({}) + }, map) as any + + map.mockReturnValueOnce({ name: 'input' }) + next.mockReturnValueOnce('__mocked__') + + expect((await decorated('input', undefined, { next }))).toBe('__mocked__') + + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith('input', undefined, { next: expect.any(Function) }) + + expect(map).toHaveBeenCalledTimes(1) + expect(map).toHaveBeenCalledWith('input') + + expect(fn2).toHaveBeenCalledTimes(1) + expect(fn2).toHaveBeenCalledWith({ name: 'input' }, { auth: true }, { next }) + }) +}) diff --git a/packages/server/src/middleware-decorated.ts b/packages/server/src/middleware-decorated.ts new file mode 100644 index 000000000..eb28bbd00 --- /dev/null +++ b/packages/server/src/middleware-decorated.ts @@ -0,0 +1,87 @@ +import type { ANY_MAP_INPUT_MIDDLEWARE, ANY_MIDDLEWARE, MapInputMiddleware, Middleware, MiddlewareMeta } from './middleware' +import type { Context, MergeContext, WELL_CONTEXT } from './types' +import { mergeContext } from './utils' + +export interface DecoratedMiddleware< + TContext extends Context, + TExtraContext extends Context, + TInput, + TOutput, +> extends Middleware { + concat: (< + UExtraContext extends Context & Partial> | undefined = undefined, + UInput = unknown, + >( + middleware: Middleware< + MergeContext, + UExtraContext, + UInput & TInput, + TOutput + >, + ) => DecoratedMiddleware< + TContext, + MergeContext, + UInput & TInput, + TOutput + >) & (< + UExtraContext extends Context & Partial> | undefined = undefined, + UInput = TInput, + UMappedInput = unknown, + >( + middleware: Middleware< + MergeContext, + UExtraContext, + UMappedInput, + TOutput + >, + mapInput: MapInputMiddleware, + ) => DecoratedMiddleware< + TContext, + MergeContext, + UInput & TInput, + TOutput + >) + + mapInput: ( + map: MapInputMiddleware, + ) => DecoratedMiddleware +} + +export function decorateMiddleware< + TContext extends Context = WELL_CONTEXT, + TExtraContext extends Context = undefined, + TInput = unknown, + TOutput = unknown, +>( + middleware: Middleware, +): DecoratedMiddleware { + const decorated = middleware as DecoratedMiddleware + + decorated.mapInput = (mapInput) => { + const mapped = decorateMiddleware( + (input, ...rest) => middleware(mapInput(input as any), ...rest as [any, any]), + ) + + return mapped as any + } + + decorated.concat = (concatMiddleware: ANY_MIDDLEWARE, mapInput?: ANY_MAP_INPUT_MIDDLEWARE) => { + const mapped = mapInput + ? decorateMiddleware(concatMiddleware).mapInput(mapInput) + : concatMiddleware + + const concatted = decorateMiddleware((input, context, meta, ...rest) => { + const next: MiddlewareMeta['next'] = async (options) => { + return mapped(input, mergeContext(context, options.context), meta, ...rest) + } + + const merged = middleware(input as any, context as any, { ...meta, next }, ...rest) + + return merged + }) + + return concatted as any + } + + return decorated +} diff --git a/packages/server/src/middleware.test-d.ts b/packages/server/src/middleware.test-d.ts new file mode 100644 index 000000000..dc4fd5a51 --- /dev/null +++ b/packages/server/src/middleware.test-d.ts @@ -0,0 +1,86 @@ +import type { Middleware, MiddlewareMeta } from './middleware' + +describe('middleware', () => { + it('just a function', () => { + const mid: Middleware<{ auth: boolean }, undefined, unknown, unknown> = (input, context, meta) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() + expectTypeOf(meta).toEqualTypeOf>() + + return meta.next({}) + } + + const mid2: Middleware<{ auth: boolean }, undefined, unknown, unknown> = async (input, context, meta) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() + expectTypeOf(meta).toEqualTypeOf>() + + return await meta.next({}) + } + + // @ts-expect-error - missing return type + const mid3: Middleware<{ auth: boolean }, undefined, unknown, unknown> = (input, context, meta) => { + } + + // @ts-expect-error - missing return type + const mid4: Middleware<{ auth: boolean }, undefined, unknown, unknown> = async (input, context, meta) => { + } + }) + + it('require return valid extra context', () => { + const mid0: Middleware = (_, __, meta) => { + return meta.next({ }) + } + + const mid: Middleware = (_, __, meta) => { + return meta.next({ context: { userId: '1' } }) + } + + // @ts-expect-error invalid extra context + const mid2: Middleware = (_, __, meta) => { + return meta.next({ context: { userId: 1 } }) + } + + const mid3: Middleware = (_, __, meta) => { + // @ts-expect-error missing extra context + return meta.next({}) + } + }) + + it('can type input', () => { + const mid: Middleware = (input, context, meta) => { + expectTypeOf(input).toEqualTypeOf<{ id: string }>() + + return meta.next({}) + } + }) + + it('can type output', () => { + const mid: Middleware = async (_, context, meta) => { + const result = await meta.next({}) + + expectTypeOf(result.output).toEqualTypeOf<{ id: string }>() + + return meta.output({ id: '1' }) + } + + // @ts-expect-error invalid output + const mid2: Middleware = async (_, context, meta) => { + return meta.output({ id: 123 }) + } + }) + + it('can infer types from function', () => { + const func = (input: 'input', context: { context: 'context' }, meta: MiddlewareMeta<'output'>) => { + return meta.next({ context: { extra: 'extra' as const } }) + } + + type Inferred = typeof func extends Middleware + ? [TContext, TExtraContext, TInput, TOutput] + : never + + expectTypeOf().toEqualTypeOf< + [{ context: 'context' }, { extra: 'extra' }, 'input', 'output'] + >() + }) +}) diff --git a/packages/server/src/middleware.test.ts b/packages/server/src/middleware.test.ts deleted file mode 100644 index b76e3ce97..000000000 --- a/packages/server/src/middleware.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { DecoratedMiddleware, Middleware, MiddlewareMeta } from './middleware' -import { os } from '.' -import { decorateMiddleware } from './middleware' - -describe('middleware', () => { - it('just a function', () => { - const mid: Middleware< - { auth: boolean }, - { userId: string }, - unknown, - unknown - > = (input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf>() - - return meta.next({ context: { userId: '1' } }) - } - }) - - it('expect required return if has extra context', () => { - const mid: Middleware< - { auth: boolean }, - { userId: string }, - unknown, - unknown - > = (input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf>() - - return meta.next({ context: { userId: '1' } }) - } - - // @ts-expect-error mid must call next - const mid2: Middleware< - { auth: boolean }, - { userId: string }, - unknown, - unknown - > = (input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf>() - } - - // @ts-expect-error mid return invalid context - const mid3: Middleware< - { auth: boolean }, - { userId: string }, - unknown, - unknown - > = (input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf>() - - return meta.next({ - context: { - valid: false, - }, - }) - } - }) -}) - -describe('decorateMiddleware', () => { - it('infer types', () => { - const mid = decorateMiddleware< - { auth: boolean }, - { userId: string }, - { id: string }, - { name: string } - >((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf<{ id: string }>() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf>() - - return meta.next({ - context: { - userId: '1', - }, - }) - }) - - expectTypeOf(mid).toEqualTypeOf< - DecoratedMiddleware< - { auth: boolean }, - { userId: string }, - { id: string }, - { name: string } - > - >() - - expectTypeOf(mid).toMatchTypeOf< - Middleware< - { auth: boolean }, - { userId: string }, - { id: string }, - { name: string } - > - >() - }) - - it('concat: infer types', () => { - const mid = decorateMiddleware< - { auth: boolean }, - undefined, - { id: string }, - { name: string } - >((_, __, meta) => meta.next({})).concat(async (input, context, meta) => { - expectTypeOf(input).toEqualTypeOf<{ id: string }>() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf>() - - return meta.next({ - context: { - userId: '1', - }, - }) - }) - - expectTypeOf(mid).toEqualTypeOf< - DecoratedMiddleware< - { auth: boolean }, - { userId: string }, - { id: string }, - { name: string } - > - >() - }) - - it('concat: can expect input', () => { - const mid = decorateMiddleware< - { auth: boolean }, - undefined, - unknown, - unknown - >((_, __, meta) => meta.next({})) - .concat((input: { id: string }, _, meta) => meta.next({})) - .concat((input: { status: string }, _, meta) => meta.next({})) - - expectTypeOf(mid).toEqualTypeOf< - DecoratedMiddleware< - { auth: boolean }, - undefined, - { id: string } & { status: string }, - unknown - > - >() - - // MID2 isn't usable because input type is wrong - const mid2 = mid.concat((input: { id: number }, _, meta) => meta.next({})) - expectTypeOf(mid2).toMatchTypeOf< - DecoratedMiddleware< - { auth: boolean }, - undefined, - { id: never, status: string }, - unknown - > - >() - }) - - it('concat: deep copy', () => { - const middleware = decorateMiddleware((_, __, meta) => meta.next({})) - const mid2 = middleware.concat((_, __, meta) => meta.next({})) - expect(mid2).not.toBe(middleware) - }) - - it('concat: can map input', async () => { - const middleware = decorateMiddleware< - { auth: boolean }, - undefined, - unknown, - unknown - >((_, __, meta) => meta.next({})) - - const mid2 = middleware.concat( - (input: { postId: number }, _, meta) => meta.next({ context: { a: 'a' } }), - input => ({ postId: 12455 }), - ) - - // mid2 input is unknown, because it's map input does not expect anything - expectTypeOf(mid2).toEqualTypeOf< - DecoratedMiddleware<{ auth: boolean }, { a: string }, unknown, unknown> - >() - - const fn = vi.fn() - const mid3 = middleware.concat( - (input: { postId: string }, _, meta) => { - fn() - expect(input).toEqual({ postId: '123' }) - - return meta.next({}) - }, - (input: { postId: number }) => { - fn() - expect(input).toEqual({ postId: 123 }) - return { - postId: `${input.postId}`, - } - }, - ) - - await mid3({ postId: 123 }, {} as any, { next: () => {} } as any) - expect(fn).toHaveBeenCalledTimes(2) - - // INPUT now follow expect types from map not from middleware - expectTypeOf(mid3).toMatchTypeOf< - DecoratedMiddleware< - { auth: boolean }, - undefined, - { postId: number }, - unknown - > - >() - }) - - it('mapInput', async () => { - const fn = vi.fn() - - const mid = decorateMiddleware< - undefined, - undefined, - { id: string }, - unknown - >(fn).mapInput((input: { postId: string }) => { - return { id: input.postId } - }) - - expectTypeOf(mid).toEqualTypeOf< - DecoratedMiddleware - >() - - await mid({ postId: '1' }, undefined, {} as any) - - expect(fn).toHaveBeenCalledWith({ id: '1' }, undefined, {}) - }) -}) - -it('middleware can output', async () => { - let mid2Called = false - let handlerCalled = false - const ping = os - .use((input, ctx, meta) => { - return meta.output('from middleware') - }) - .use((input, ctx, meta) => { - mid2Called = true - return meta.output('from middleware 2') - }) - .func(() => { - handlerCalled = true - return 'from handler' - }) - - expect(await ping(undefined)).toBe('from middleware') - expect(mid2Called).toBeFalsy() - expect(handlerCalled).toBeFalsy() -}) diff --git a/packages/server/src/middleware.ts b/packages/server/src/middleware.ts index b1ce754ba..3db7d9ff5 100644 --- a/packages/server/src/middleware.ts +++ b/packages/server/src/middleware.ts @@ -1,15 +1,12 @@ import type { Promisable } from '@orpc/shared' -import type { Context, MergeContext, Meta } from './types' -import { mergeContext } from './utils' +import type { Context, Meta } from './types' export type MiddlewareResult = Promisable<{ output: TOutput context: TExtraContext }> -export interface MiddlewareMeta< - TOutput, -> extends Meta { +export interface MiddlewareMeta extends Meta { next: ( options: UExtraContext extends undefined ? { context?: UExtraContext } : { context: UExtraContext } ) => MiddlewareResult @@ -31,106 +28,10 @@ export interface Middleware< > } +export type ANY_MIDDLEWARE = Middleware + export interface MapInputMiddleware { (input: TInput): TMappedInput } -export interface DecoratedMiddleware< - TContext extends Context, - TExtraContext extends Context, - TInput, - TOutput, -> extends Middleware { - concat: (< - UExtraContext extends Partial>> | undefined = undefined, - UInput = TInput, - >( - middleware: Middleware< - MergeContext, - UExtraContext, - UInput & TInput, - TOutput - >, - ) => DecoratedMiddleware< - TContext, - MergeContext, - TInput & UInput, - TOutput - >) & (< - UExtraContext extends Partial>> | undefined = undefined, - UInput = TInput, - UMappedInput = unknown, - >( - middleware: Middleware< - MergeContext, - UExtraContext, - UMappedInput, - TOutput - >, - mapInput: MapInputMiddleware, - ) => DecoratedMiddleware< - TContext, - MergeContext, - TInput & UInput, - TOutput - >) - - mapInput: ( - map: MapInputMiddleware, - ) => DecoratedMiddleware -} - -const decoratedMiddlewareSymbol = Symbol('🔒decoratedMiddleware') - -export function decorateMiddleware< - TContext extends Context, - TExtraContext extends Context, - TInput, - TOutput, ->( - middleware: Middleware, -): DecoratedMiddleware { - if (Reflect.get(middleware, decoratedMiddlewareSymbol)) { - return middleware as any - } - - const concat = ( - concatMiddleware: Middleware, - mapInput?: MapInputMiddleware, - ): Middleware => { - const concatMiddleware_ = mapInput - ? decorateMiddleware(concatMiddleware).mapInput(mapInput) - : concatMiddleware - - return decorateMiddleware(async (input, context, meta, ...rest) => { - const input_ = input as any - const context_ = context as any - const meta_ = meta as any - - const next: MiddlewareMeta['next'] = async (options) => { - return concatMiddleware_(input_, mergeContext(context_, options.context), meta_, ...rest) - } - - const m1 = await middleware(input_, context_, { - ...meta_, - next, - }, ...rest) - - return m1 - }) - } - - const mapInput = ( - map: MapInputMiddleware, - ): DecoratedMiddleware => { - return decorateMiddleware((input, ...rest) => - middleware(map(input), ...rest), - ) - } - - return Object.assign(middleware, { - [decoratedMiddlewareSymbol]: true, - concat: concat as any, - mapInput, - }) -} +export type ANY_MAP_INPUT_MIDDLEWARE = MapInputMiddleware diff --git a/packages/server/src/procedure-builder.test-d.ts b/packages/server/src/procedure-builder.test-d.ts new file mode 100644 index 000000000..fc5fca351 --- /dev/null +++ b/packages/server/src/procedure-builder.test-d.ts @@ -0,0 +1,179 @@ +import type { RouteOptions } from '@orpc/contract' +import type { Middleware } from './middleware' +import type { DecoratedProcedure } from './procedure-decorated' +import type { ProcedureImplementer } from './procedure-implementer' +import type { Meta, WELL_CONTEXT } from './types' +import { ContractProcedure } from '@orpc/contract' +import { z } from 'zod' +import { ProcedureBuilder } from './procedure-builder' + +describe('self chainable', () => { + const builder = new ProcedureBuilder<{ id?: string }, undefined, undefined, undefined>({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + }), + middlewares: [], + }) + + const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + + it('route', () => { + expectTypeOf(builder.route).toEqualTypeOf((route: RouteOptions) => builder) + }) + + it('input', () => { + expectTypeOf(builder.input(schema)) + .toEqualTypeOf>() + + expectTypeOf(builder.input(schema, { id: '1' })) + .toEqualTypeOf>() + + // @ts-expect-error - invalid schema + builder.input({}) + + // @ts-expect-error - invalid example + builder.input(schema, {}) + + // @ts-expect-error - invalid example + builder.input(schema, { id: 1 }) + }) + + it('output', () => { + expectTypeOf(builder.output(schema)) + .toEqualTypeOf>() + + expectTypeOf(builder.output(schema, { id: 1 })) + .toEqualTypeOf>() + + // @ts-expect-error - invalid schema + builder.output({}) + + // @ts-expect-error - invalid example + builder.output(schema, {}) + + // @ts-expect-error - invalid example + builder.output(schema, { id: '1' }) + }) +}) + +describe('to ProcedureImplementer', () => { + const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + + const builder = new ProcedureBuilder<{ id?: string } | undefined, undefined, typeof schema, typeof schema>({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: schema, + }), + middlewares: [], + }) + + it('use middleware', () => { + const implementer = builder.use(async (input, context, meta) => { + expectTypeOf(context).toEqualTypeOf<{ id?: string } | undefined>() + expectTypeOf(input).toEqualTypeOf<{ id: number }>() + + const result = await meta.next({}) + + expectTypeOf(result.output).toEqualTypeOf<{ id: string }>() + + return meta.next({ context: { id: '1', extra: true } }) + }) + + expectTypeOf(implementer).toEqualTypeOf< + ProcedureImplementer<{ id?: string } | undefined, { id: string, extra: boolean }, typeof schema, typeof schema> + >() + }) + + it('use middleware with map input', () => { + const mid: Middleware = (input, context, meta) => { + return meta.next({ + context: { id: 'string', extra: true }, + }) + } + + const implementer = builder.use(mid, (input) => { + expectTypeOf(input).toEqualTypeOf<{ id: number }>() + return input.id + }) + + expectTypeOf(implementer).toEqualTypeOf< + ProcedureImplementer<{ id?: string } | undefined, { id: string, extra: boolean }, typeof schema, typeof schema> + >() + + // @ts-expect-error - invalid input + builder.use(mid) + + // @ts-expect-error - invalid mapped input + builder.use(mid, input => input) + }) + + it('use middleware prevent conflict on context', () => { + builder.use((input, context, meta) => meta.next({})) + builder.use((input, context, meta) => meta.next({ context: { id: '1' } })) + builder.use((input, context, meta) => meta.next({ context: { id: '1', extra: true } })) + builder.use((input, context, meta) => meta.next({ context: { auth: true } })) + + builder.use((input, context, meta) => meta.next({}), () => 'anything') + builder.use((input, context, meta) => meta.next({ context: { id: '1' } }), () => 'anything') + builder.use((input, context, meta) => meta.next({ context: { id: '1', extra: true } }), () => 'anything') + builder.use((input, context, meta) => meta.next({ context: { auth: true } }), () => 'anything') + + // @ts-expect-error - conflict with context + builder.use((input, context, meta) => meta.next({ context: { id: 1 } })) + + // @ts-expect-error - conflict with context + builder.use((input, context, meta) => meta.next({ context: { id: 1, extra: true } })) + + // @ts-expect-error - conflict with context + builder.use((input, context, meta) => meta.next({ context: { id: 1 } }), () => 'anything') + + // @ts-expect-error - conflict with context + builder.use((input, context, meta) => meta.next({ context: { id: 1, extra: true } }), () => 'anything') + }) + + it('not allow use middleware with output is typed', () => { + const mid1 = {} as Middleware + const mid2 = {} as Middleware + const mid3 = {} as Middleware + + builder.use(mid1) + + // @ts-expect-error - required used any for output + builder.use(mid2) + // @ts-expect-error - typed output is not allow because builder is not know output yet + builder.use(mid3) + }) +}) + +describe('to DecoratedProcedure', () => { + const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + + const builder = new ProcedureBuilder<{ id?: string } | undefined, undefined, typeof schema, typeof schema>({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: schema, + }), + middlewares: [], + }) + + it('func', () => { + const procedure = builder.func(async (input, context, meta) => { + expectTypeOf(context).toEqualTypeOf<{ id?: string } | undefined>() + expectTypeOf(input).toEqualTypeOf<{ id: number }>() + expectTypeOf(meta).toEqualTypeOf() + + return { id: '1' } + }) + + expectTypeOf(procedure).toEqualTypeOf< + DecoratedProcedure<{ id?: string } | undefined, undefined, typeof schema, typeof schema, { id: string }> + >() + + // @ts-expect-error - invalid output + builder.func(async (input, context, meta) => ({ id: 1 })) + + // @ts-expect-error - invalid output + builder.func(async (input, context, meta) => (true)) + }) +}) diff --git a/packages/server/src/procedure-builder.test.ts b/packages/server/src/procedure-builder.test.ts index 12846a6cf..cd8de53f6 100644 --- a/packages/server/src/procedure-builder.test.ts +++ b/packages/server/src/procedure-builder.test.ts @@ -1,225 +1,111 @@ -import type { MiddlewareMeta } from './middleware' -import type { ProcedureImplementer } from './procedure-implementer' -import type { Meta } from './types' import { ContractProcedure } from '@orpc/contract' import { z } from 'zod' -import { os } from '.' -import { type DecoratedProcedure, isProcedure } from './procedure' +import { isProcedure } from './procedure' import { ProcedureBuilder } from './procedure-builder' +import { ProcedureImplementer } from './procedure-implementer' + +describe('self chainable', () => { + const builder = new ProcedureBuilder<{ id?: string }, undefined, undefined, undefined>({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + }), + middlewares: [], + }) -const schema1 = z.object({ id: z.string() }) -const example1 = { id: '1' } -const schema2 = z.object({ name: z.string() }) -const example2 = { name: 'unnoq' } - -const builder = new ProcedureBuilder< - { auth: boolean }, - undefined, - undefined, - undefined ->({ - contract: new ContractProcedure({ - InputSchema: undefined, - OutputSchema: undefined, - }), -}) - -it('input', () => { - const builder2 = builder.input(schema1, example1) + const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + const example = { id: '1' } + const out_example = { id: 1 } - expectTypeOf(builder2).toEqualTypeOf< - ProcedureBuilder<{ auth: boolean }, undefined, typeof schema1, undefined> - >() + it('route', () => { + const route = { method: 'GET', path: '/test', deprecated: true, description: 'des', summary: 'sum', tags: ['hi'] } as const + const routed = builder.route(route) - expect(builder2.zz$pb).toMatchObject({ - contract: { - '~orpc': { - InputSchema: schema1, - inputExample: example1, - }, - }, + expect(routed).not.toBe(builder) + expect(routed).toBeInstanceOf(ProcedureBuilder) + expect(routed['~orpc'].contract['~orpc'].route).toBe(route) }) -}) -it('output', () => { - const builder2 = builder.output(schema2, example2) + it('input', () => { + const input_ed = builder.input(schema, example) - expectTypeOf(builder2).toEqualTypeOf< - ProcedureBuilder<{ auth: boolean }, undefined, undefined, typeof schema2> - >() + expect(input_ed).not.toBe(builder) + expect(input_ed).toBeInstanceOf(ProcedureBuilder) + expect(input_ed['~orpc'].contract['~orpc'].InputSchema).toBe(schema) + expect(input_ed['~orpc'].contract['~orpc'].inputExample).toBe(example) + }) - expect(builder2.zz$pb).toMatchObject({ - contract: { - '~orpc': { - OutputSchema: schema2, - outputExample: example2, - }, - }, + it('output', () => { + const output_ed = builder.output(schema, out_example) + + expect(output_ed).not.toBe(builder) + expect(output_ed).toBeInstanceOf(ProcedureBuilder) + expect(output_ed['~orpc'].contract['~orpc'].OutputSchema).toBe(schema) + expect(output_ed['~orpc'].contract['~orpc'].outputExample).toBe(out_example) }) }) -it('route', () => { - const builder2 = builder.route({ - method: 'GET', - path: '/test', - deprecated: true, - description: 'des', - summary: 'sum', - tags: ['hi'], - }) +describe('to ProcedureImplementer', () => { + const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) - expectTypeOf(builder2).toEqualTypeOf< - ProcedureBuilder<{ auth: boolean }, undefined, undefined, undefined> - >() - - expect(builder2.zz$pb).toMatchObject({ - contract: { - '~orpc': { - route: { - method: 'GET', - path: '/test', - deprecated: true, - description: 'des', - summary: 'sum', - tags: ['hi'], - }, - }, - }, + const global_mid = vi.fn() + const builder = new ProcedureBuilder<{ id?: string } | undefined, undefined, typeof schema, typeof schema>({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: schema, + }), + middlewares: [global_mid], }) -}) -describe('use middleware', () => { - it('infer types', () => { - const implementer = builder - .use((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf>() - - return meta.next({ - context: { - userId: '1', - }, - }) - }) - .use((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf< - { userId: string } & { auth: boolean } - >() - expectTypeOf(meta).toEqualTypeOf>() - - return meta.next({}) - }) - - expectTypeOf(implementer).toEqualTypeOf< - ProcedureImplementer< - { auth: boolean }, - { userId: string }, - undefined, - undefined - > - >() + it('use middleware', () => { + const mid = vi.fn() + + const implementer = builder.use(mid) + + expect(implementer).toBeInstanceOf(ProcedureImplementer) + expect(implementer['~orpc'].middlewares).toEqual([global_mid, mid]) }) - it('map middleware input', () => { - // @ts-expect-error mismatch input - builder.use((input: { postId: string }) => { - return { context: { a: 'a' } } - }) - - builder.use( - (input: { postId: string }, _, meta) => { - return meta.next({ context: { a: 'a' } }) - }, - // @ts-expect-error mismatch input - input => ({ postId: 12455 }), - ) - - builder.use( - (input: { postId: string }, context, meta) => meta.next({}), - input => ({ postId: '12455' }), - ) - - const implementer = builder.input(schema1).use( - (input: { id: number }, _, meta) => { - return meta.next({ - context: { - userId555: '1', - }, - }) - }, - input => ({ id: Number.parseInt(input.id) }), - ) - - expectTypeOf(implementer).toEqualTypeOf< - ProcedureImplementer< - { auth: boolean }, - { userId555: string }, - typeof schema1, - undefined - > - >() + it('use middleware with map input', () => { + const mid = vi.fn() + const map_input = vi.fn() + + const implementer = builder.use(mid, map_input) + expect(implementer).toBeInstanceOf(ProcedureImplementer) + expect(implementer['~orpc'].middlewares).toEqual([global_mid, expect.any(Function)]) + + map_input.mockReturnValueOnce('__input__') + mid.mockReturnValueOnce('__mid__') + + expect((implementer as any)['~orpc'].middlewares[1]('input')).toBe('__mid__') + + expect(map_input).toBeCalledTimes(1) + expect(map_input).toBeCalledWith('input') + + expect(mid).toBeCalledTimes(1) + expect(mid).toBeCalledWith('__input__') }) }) -describe('handler', () => { - it('infer types', () => { - const handler = builder.func((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf() - }) - - expectTypeOf(handler).toEqualTypeOf< - DecoratedProcedure< - { auth: boolean }, - undefined, - undefined, - undefined, - void - > - >() - - expect(isProcedure(handler)).toBe(true) +describe('to DecoratedProcedure', () => { + const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + + const global_mid = vi.fn() + const builder = new ProcedureBuilder<{ id?: string } | undefined, undefined, typeof schema, typeof schema>({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: schema, + }), + middlewares: [global_mid], }) - it('combine middlewares', () => { - const mid1 = os.middleware((input, context, meta) => { - return meta.next({ - context: { - userId: '1', - }, - }) - }) - - const mid2 = os.middleware((_, __, meta) => meta.next({})) - - const handler = builder - .use(mid1) - .use(mid2) - .func((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf< - { userId: string } & { auth: boolean } - >() - expectTypeOf(meta).toEqualTypeOf() - - return { - name: 'unnoq', - } - }) - - expectTypeOf(handler).toEqualTypeOf< - DecoratedProcedure< - { auth: boolean }, - { userId: string }, - undefined, - undefined, - { name: string } - > - >() - - expect(handler.zz$p.middlewares).toEqual([mid1, mid2]) + it('func', () => { + const func = vi.fn() + const procedure = builder.func(func) + + expect(procedure).toSatisfy(isProcedure) + + expect(procedure['~orpc'].func).toBe(func) + expect(procedure['~orpc'].middlewares).toEqual([global_mid]) }) }) diff --git a/packages/server/src/procedure-builder.ts b/packages/server/src/procedure-builder.ts index 52a257adc..189a3ba06 100644 --- a/packages/server/src/procedure-builder.ts +++ b/packages/server/src/procedure-builder.ts @@ -1,4 +1,5 @@ import type { MapInputMiddleware, Middleware } from './middleware' +import type { DecoratedProcedure } from './procedure-decorated' import type { Context, MergeContext } from './types' import { type ContractProcedure, @@ -9,104 +10,96 @@ import { type SchemaOutput, } from '@orpc/contract' import { - type DecoratedProcedure, - decorateProcedure, + Procedure, type ProcedureFunc, } from './procedure' +import { decorateProcedure } from './procedure-decorated' import { ProcedureImplementer } from './procedure-implementer' +export interface ProcedureBuilderDef< + TContext extends Context, + TExtraContext extends Context, + TInputSchema extends Schema, + TOutputSchema extends Schema, +> { + contract: ContractProcedure + middlewares?: Middleware, Partial | undefined, unknown, any>[] +} + export class ProcedureBuilder< TContext extends Context, TExtraContext extends Context, TInputSchema extends Schema, TOutputSchema extends Schema, > { - constructor( - public zz$pb: { - contract: ContractProcedure - middlewares?: Middleware[] - }, - ) {} + '~type' = 'ProcedureBuilder' as const + '~orpc': ProcedureBuilderDef - /** - * Self chainable - */ + constructor(def: ProcedureBuilderDef) { + this['~orpc'] = def + } - route( - opts: RouteOptions, - ): ProcedureBuilder { + route(route: RouteOptions): ProcedureBuilder { return new ProcedureBuilder({ - ...this.zz$pb, - contract: DecoratedContractProcedure.decorate(this.zz$pb.contract).route( - opts, - ), + ...this['~orpc'], + contract: DecoratedContractProcedure + .decorate(this['~orpc'].contract) + .route(route), }) } - input( - schema: USchema, - example?: SchemaInput, - ): ProcedureBuilder { + input( + schema: U, + example?: SchemaInput, + ): ProcedureBuilder { return new ProcedureBuilder({ - ...this.zz$pb, - contract: DecoratedContractProcedure.decorate(this.zz$pb.contract).input( - schema, - example, - ), + ...this['~orpc'], + contract: DecoratedContractProcedure + .decorate(this['~orpc'].contract) + .input(schema, example), }) } - output( - schema: USchema, - example?: SchemaOutput, - ): ProcedureBuilder { + output( + schema: U, + example?: SchemaOutput, + ): ProcedureBuilder { return new ProcedureBuilder({ - ...this.zz$pb, - contract: DecoratedContractProcedure.decorate(this.zz$pb.contract).output( - schema, - example, - ), + ...this['~orpc'], + contract: DecoratedContractProcedure + .decorate(this['~orpc'].contract) + .output(schema, example), }) } - /** - * Convert to ProcedureBuilder - */ - - use< - UExtraContext extends - | Partial>> - | undefined = undefined, - >( + use> | undefined = undefined>( middleware: Middleware< MergeContext, - UExtraContext, + U, SchemaOutput, SchemaInput >, ): ProcedureImplementer< TContext, - MergeContext, + MergeContext, TInputSchema, TOutputSchema > use< - UExtraContext extends - | Partial>> - | undefined = undefined, - UMappedInput = unknown, + UExtra extends Context & Partial> | undefined = undefined, + UInput = unknown, >( middleware: Middleware< MergeContext, - UExtraContext, - UMappedInput, + UExtra, + UInput, SchemaInput >, - mapInput: MapInputMiddleware, UMappedInput>, + mapInput: MapInputMiddleware, UInput>, ): ProcedureImplementer< TContext, - MergeContext, + MergeContext, TInputSchema, TOutputSchema > @@ -117,42 +110,24 @@ export class ProcedureBuilder< ): ProcedureImplementer { if (!mapInput) { return new ProcedureImplementer({ - contract: this.zz$pb.contract, - middlewares: this.zz$pb.middlewares, + contract: this['~orpc'].contract, + middlewares: this['~orpc'].middlewares, }).use(middleware) } return new ProcedureImplementer({ - contract: this.zz$pb.contract, - middlewares: this.zz$pb.middlewares, + contract: this['~orpc'].contract, + middlewares: this['~orpc'].middlewares, }).use(middleware, mapInput) } - /** - * Convert to Procedure - */ - - func>( - func: ProcedureFunc< - TContext, - TExtraContext, - TInputSchema, - TOutputSchema, - UFuncOutput - >, - ): DecoratedProcedure< - TContext, - TExtraContext, - TInputSchema, - TOutputSchema, - UFuncOutput - > { - return decorateProcedure({ - zz$p: { - middlewares: this.zz$pb.middlewares, - contract: this.zz$pb.contract, - func, - }, - }) + func>( + func: ProcedureFunc, + ): DecoratedProcedure { + return decorateProcedure(new Procedure({ + middlewares: this['~orpc'].middlewares, + contract: this['~orpc'].contract, + func, + })) } } diff --git a/packages/server/src/procedure-caller.test.ts b/packages/server/src/procedure-caller.test.ts deleted file mode 100644 index e042167d1..000000000 --- a/packages/server/src/procedure-caller.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { z } from 'zod' -import { createProcedureCaller, ORPCError, os } from '.' - -describe('createProcedureCaller', () => { - const path = ['ping'] - const context = { auth: true } - - const osw = os.context<{ auth?: boolean }>() - const procedure = osw - .input(z.object({ value: z.string().transform(v => Number(v)) })) - .output(z.object({ value: z.number().transform(v => v.toString()) })) - .func((input, context, meta) => { - expect(context).toEqual(context) - expect(meta.path).toBe(path) - - return input - }) - - it('infer context', () => { - createProcedureCaller({ - procedure, - // @ts-expect-error invalid context - context: { auth: 123 }, - }) - - createProcedureCaller({ - procedure, - context, - }) - }) - - it('with validate', async () => { - const caller = createProcedureCaller({ - procedure, - context: async () => context, - path, - }) - - expectTypeOf(caller).toMatchTypeOf< - (input: { value: string }) => Promise<{ - value: string - }> - >() - - expect(await caller({ value: '123' })).toEqual({ value: '123' }) - - // @ts-expect-error - invalid input - expect(caller({ value: {} })).rejects.toThrowError( - 'Validation input failed', - ) - }) - - it('without validate and schema', () => { - const procedure = osw.func(() => { - return { value: true } - }) - - const caller = createProcedureCaller({ - procedure, - context, - }) - - expectTypeOf(caller).toMatchTypeOf< - (value: unknown) => Promise<{ value: boolean }> - >() - - expect(caller({ value: 123 })).resolves.toEqual({ value: true }) - }) - - it('middlewares', () => { - const ref = { value: 0 } - - const mid1 = vi.fn( - osw.middleware(async (input: { id: string }, context, meta) => { - expect(input).toEqual({ id: '1' }) - - expect(ref.value).toBe(0) - ref.value++ - - try { - const result = await meta.next({ - context: { - userId: '1', - }, - }) - expect(ref.value).toBe(5) - ref.value++ - return result - } - finally { - expect(ref.value).toBe(6) - ref.value++ - } - }), - ) - - const mid2 = vi.fn( - osw.middleware(async (input, context, meta) => { - expect(ref.value).toBe(1) - ref.value++ - - try { - const result = await meta.next({}) - expect(ref.value).toBe(3) - ref.value++ - return result - } - finally { - expect(ref.value).toBe(4) - ref.value++ - } - }), - ) - - const ping = osw - .input(z.object({ id: z.string() })) - .use(mid1) - .use(mid2) - .func((input, context, meta) => { - expect(context).toEqual({ userId: '1', auth: false }) - - expect(ref.value).toBe(2) - ref.value++ - - return 'pong' - }) - - const caller = createProcedureCaller({ - procedure: ping, - context: { auth: false }, - }) - - expect(caller({ id: '1' })).resolves.toEqual('pong') - }) - - it('optional input when possible', async () => { - os.func(() => { })() - os.func(() => { })({}) - // @ts-expect-error input is required - expect(os.input(z.string()).func(() => { })()).rejects.toThrow() - os.input(z.string().optional()).func(() => { })() - // @ts-expect-error input is required - expect(os.input(z.object({})).func(() => { })()).rejects.toThrow() - os.input(z.object({}).optional()).func(() => { })() - os.input(z.unknown()).func(() => { })() - os.input(z.any()).func(() => { })() - // @ts-expect-error input is required - expect(os.input(z.boolean()).func(() => { })()).rejects.toThrow() - }) - - it('hooks', async () => { - const onStart = vi.fn() - const onSuccess = vi.fn() - const onError = vi.fn() - const onFinish = vi.fn() - const onExecute = vi.fn() - - const procedure = os.input(z.string()).func(() => 'output') - const context = { val: 'context' } - const caller = createProcedureCaller({ - procedure, - context, - path: ['cc'], - execute: async (input, context, meta) => { - onExecute(input, context, meta) - try { - const output = await meta.next() - onSuccess(output, context, meta) - return output - } - catch (e) { - onError(e, context, meta) - throw e - } - }, - onStart, - onSuccess, - onError, - onFinish, - }) - - const meta = { - path: ['cc'], - procedure, - } - - const metaFull = { - ...meta, - next: expect.any(Function), - } - - await caller('input') - expect(onExecute).toBeCalledTimes(1) - expect(onExecute).toHaveBeenCalledWith('input', context, metaFull) - expect(onStart).toBeCalledTimes(1) - expect(onStart).toHaveBeenCalledWith({ input: 'input', status: 'pending' }, context, meta) - expect(onSuccess).toBeCalledTimes(2) - expect(onSuccess).toHaveBeenNthCalledWith(1, { output: 'output', input: 'input', status: 'success' }, context, meta) - expect(onSuccess).toHaveBeenNthCalledWith(2, 'output', context, metaFull) - expect(onError).not.toBeCalled() - expect(onFinish).toBeCalledTimes(1) - expect(onFinish).toBeCalledWith({ output: 'output', input: 'input', status: 'success' }, context, meta) - - onSuccess.mockClear() - onError.mockClear() - onFinish.mockClear() - onExecute.mockClear() - - // @ts-expect-error - invalid input - await expect(caller(123)).rejects.toThrowError( - 'Validation input failed', - ) - - const meta2 = { - path: ['cc'], - procedure, - } - - const metaFull2 = { - ...meta2, - next: expect.any(Function), - } - - const error2 = new ORPCError({ - message: 'Validation input failed', - code: 'BAD_REQUEST', - cause: expect.any(Error), - }) - - expect(onExecute).toBeCalledTimes(1) - expect(onExecute).toHaveBeenCalledWith(123, context, metaFull2) - expect(onError).toBeCalledTimes(2) - expect(onError).toHaveBeenNthCalledWith(1, { input: 123, error: error2, status: 'error' }, context, meta2) - expect(onError).toHaveBeenNthCalledWith(2, error2, context, metaFull2) - expect(onSuccess).not.toBeCalled() - expect(onFinish).toBeCalledTimes(1) - expect(onFinish).toBeCalledWith({ input: 123, error: error2, status: 'error' }, context, meta2) - }) - - it('abort signal', async () => { - const controller = new AbortController() - const signal = controller.signal - - const procedure = os - .use(async (_, __, meta) => { - expect(meta.signal).toBe(signal) - - return meta.next({}) - }) - .func((_, __, meta) => { - expect(meta.signal).toBe(signal) - }) - - const caller = createProcedureCaller({ - procedure, - }) - - await caller(undefined, { signal }) - }) -}) diff --git a/packages/server/src/procedure-caller.ts b/packages/server/src/procedure-caller.ts deleted file mode 100644 index 140ae6936..000000000 --- a/packages/server/src/procedure-caller.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { SchemaInput, SchemaOutput } from '@orpc/contract' -import type { Hooks, PartialOnUndefinedDeep, Value } from '@orpc/shared' -import type { Lazy } from './lazy' -import type { MiddlewareMeta } from './middleware' -import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE, Procedure, WELL_DEFINED_PROCEDURE } from './procedure' -import type { Caller, Context, Meta } from './types' -import { executeWithHooks, trim, value } from '@orpc/shared' -import { ORPCError } from '@orpc/shared/error' -import { isLazy, loadLazy } from './lazy' -import { isProcedure } from './procedure' -import { mergeContext } from './utils' - -export type CreateProcedureCallerOptions< - T extends ANY_PROCEDURE | ANY_LAZY_PROCEDURE, -> = T extends -| Procedure -| Lazy> ? { - procedure: T - - /** - * This is helpful for logging and analytics. - * - * @internal - */ - path?: string[] -} & PartialOnUndefinedDeep<{ - /** - * The context used when calling the procedure. - */ - context: Value -}> & Hooks, UContext, { path: string[], procedure: ANY_PROCEDURE }> - : never - -export type ProcedureCaller< - TProcedure extends ANY_PROCEDURE | ANY_LAZY_PROCEDURE, -> = TProcedure extends -| Procedure -| Lazy> - ? Caller, SchemaOutput> - : never - -export function createProcedureCaller< - TProcedure extends ANY_PROCEDURE | ANY_LAZY_PROCEDURE, ->( - options: CreateProcedureCallerOptions, -): ProcedureCaller { - const caller: Caller = async (...args) => { - const [input, callerOptions] = args - - const path = options.path ?? [] - const procedure = await loadProcedure(options.procedure) as WELL_DEFINED_PROCEDURE - const context = await value(options.context) - - const execute = async () => { - const validInput = await (async () => { - const schema = procedure.zz$p.contract['~orpc'].InputSchema - if (!schema) { - return input - } - - const result = await schema['~standard'].validate(input) - - if (result.issues) { - throw new ORPCError({ - message: 'Validation input failed', - code: 'BAD_REQUEST', - issues: result.issues, - }) - } - - return result.value - })() - - const meta: Meta = { - path, - procedure, - signal: callerOptions?.signal, - } - - const middlewares = procedure.zz$p.middlewares ?? [] - let currentMidIndex = 0 - let currentContext: Context = context - - const next: MiddlewareMeta['next'] = async (nextOptions) => { - const mid = middlewares[currentMidIndex] - currentMidIndex += 1 - currentContext = mergeContext(currentContext, nextOptions.context) - - if (mid) { - return await mid(validInput, currentContext, { - ...meta, - next, - output: output => ({ output, context: undefined }), - }) - } - else { - return { - output: await await procedure.zz$p.func(validInput, currentContext, meta), - context: currentContext, - } - } - } - - const output = (await next({})).output - - const validOutput = await (async () => { - const schema = procedure.zz$p.contract['~orpc'].OutputSchema - if (!schema) { - return output - } - - const result = await schema['~standard'].validate(output) - if (result.issues) { - throw new ORPCError({ - message: 'Validation output failed', - code: 'INTERNAL_SERVER_ERROR', - }) - } - return result.value - })() - - return validOutput - } - - const output = await executeWithHooks({ - hooks: options, - input, - context, - meta: { - path, - procedure, - }, - execute, - }) - - return output - } - - return caller as any -} - -export async function loadProcedure(procedure: ANY_PROCEDURE | ANY_LAZY_PROCEDURE): Promise { - let loadedProcedure: ANY_PROCEDURE - - if (isLazy(procedure)) { - loadedProcedure = (await loadLazy(procedure)).default - } - else { - loadedProcedure = procedure - } - - if (!isProcedure(loadedProcedure)) { - throw new ORPCError({ - code: 'NOT_FOUND', - message: 'Not found', - cause: new Error(trim(` - This error should be caught by the typescript compiler. - But if you still see this error, it means that you trying to call a lazy router (expected to be a lazy procedure). - `)), - }) - } - - return loadedProcedure -} diff --git a/packages/server/src/procedure-client.test-d.ts b/packages/server/src/procedure-client.test-d.ts new file mode 100644 index 000000000..befc42a59 --- /dev/null +++ b/packages/server/src/procedure-client.test-d.ts @@ -0,0 +1,196 @@ +import type { Procedure } from './procedure' +import type { ProcedureClient } from './procedure-client' +import type { Meta, WELL_CONTEXT, WithSignal } from './types' +import { z } from 'zod' +import { lazy } from './lazy' +import { createProcedureClient } from './procedure-client' + +beforeEach(() => { + vi.resetAllMocks() +}) + +describe('ProcedureClient', () => { + const fn: ProcedureClient = async (input, options) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(options).toEqualTypeOf() + return 123 + } + + const fnWithOptionalInput: ProcedureClient = async (...args) => { + const [input, options] = args + + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(options).toEqualTypeOf() + return 123 + } + + it('just a function', () => { + expectTypeOf(fn).toEqualTypeOf<(input: string, options?: WithSignal) => Promise>() + expectTypeOf(fnWithOptionalInput).toMatchTypeOf<(input: string | undefined, options?: WithSignal) => Promise>() + }) + + it('infer correct input', () => { + fn('123') + fnWithOptionalInput('123') + + // @ts-expect-error - invalid input + fn(123) + // @ts-expect-error - invalid input + fnWithOptionalInput(123) + + // @ts-expect-error - invalid input + fn({}) + // @ts-expect-error - invalid input + fnWithOptionalInput({}) + }) + + it('accept signal', () => { + fn('123', { signal: new AbortSignal() }) + fnWithOptionalInput('123', { signal: new AbortSignal() }) + + // @ts-expect-error - invalid signal + fn('123', { signal: 1234 }) + // @ts-expect-error - invalid signal + fnWithOptionalInput('123', { signal: 1234 }) + }) + + it('can accept call without args', () => { + expectTypeOf(fnWithOptionalInput()).toEqualTypeOf>() + // @ts-expect-error - input is required + expectTypeOf(fn()).toEqualTypeOf>() + }) +}) + +describe('createProcedureClient', () => { + const schema = z.object({ val: z.string().transform(v => Number(v)) }) + const procedure = {} as Procedure + const procedureWithContext = {} as Procedure<{ userId?: string }, { db: string }, typeof schema, typeof schema, { val: string }> + + it('just a client', () => { + const client = createProcedureClient({ + procedure, + }) + + expectTypeOf(client).toEqualTypeOf>() + }) + + it('context can be optional and can be a sync or async function', () => { + createProcedureClient({ + procedure, + }) + + createProcedureClient({ + procedure, + context: undefined, + }) + + // @ts-expect-error - missing context + createProcedureClient({ + procedure: procedureWithContext, + }) + + createProcedureClient({ + procedure: procedureWithContext, + context: { userId: '123' }, + }) + + createProcedureClient({ + procedure: procedureWithContext, + // @ts-expect-error invalid context + context: { userId: 123 }, + }) + + createProcedureClient({ + procedure: procedureWithContext, + context: () => ({ userId: '123' }), + }) + + createProcedureClient({ + procedure: procedureWithContext, + // @ts-expect-error invalid context + context: () => ({ userId: 123 }), + }) + + createProcedureClient({ + procedure: procedureWithContext, + context: async () => ({ userId: '123' }), + }) + + createProcedureClient({ + procedure: procedureWithContext, + // @ts-expect-error invalid context + context: async () => ({ userId: 123 }), + }) + }) + + it('accept hooks', () => { + createProcedureClient({ + procedure, + + async execute(input, context, meta) { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf() + expectTypeOf(meta).toEqualTypeOf Promise<{ val: number }> }>() + + return { val: 123 } + }, + + onStart(state, context, meta) { + expectTypeOf(state).toEqualTypeOf<{ status: 'pending', input: unknown, output: undefined, error: undefined }>() + expectTypeOf(context).toEqualTypeOf() + expectTypeOf(meta).toEqualTypeOf() + }, + + onSuccess(state, context, meta) { + expectTypeOf(state).toEqualTypeOf<{ status: 'success', input: unknown, output: { val: number }, error: undefined }>() + expectTypeOf(context).toEqualTypeOf() + expectTypeOf(meta).toEqualTypeOf() + }, + + onError(state, context, meta) { + expectTypeOf(state).toEqualTypeOf<{ status: 'error', input: unknown, output: undefined, error: Error }>() + expectTypeOf(context).toEqualTypeOf() + expectTypeOf(meta).toEqualTypeOf() + }, + + onFinish(state, context, meta) { + expectTypeOf(state).toEqualTypeOf<{ status: 'success', input: unknown, output: { val: number }, error: undefined } | { status: 'error', input: unknown, output: undefined, error: Error }>() + expectTypeOf(context).toEqualTypeOf() + expectTypeOf(meta).toEqualTypeOf() + }, + }) + }) + + it('accept paths', () => { + createProcedureClient({ + procedure, + path: ['users'], + }) + + createProcedureClient({ + procedure, + // @ts-expect-error - invalid path + path: [123], + }) + }) +}) + +it('support lazy procedure', () => { + const schema = z.object({ val: z.string().transform(v => Number(v)) }) + const procedure = {} as Procedure<{ userId?: string }, undefined, typeof schema, typeof schema, { val: string }> + const lazied = lazy(() => Promise.resolve({ default: procedure })) + + const client = createProcedureClient({ + procedure: lazied, + context: async () => ({ userId: 'string' }), + path: ['users'], + + onSuccess(state, context, meta) { + expectTypeOf(state).toEqualTypeOf<{ status: 'success', input: unknown, output: { val: number }, error: undefined }>() + expectTypeOf(context).toEqualTypeOf<{ userId: string }>() + expectTypeOf(meta).toEqualTypeOf() + }, + }) + + expectTypeOf(client).toEqualTypeOf>() +}) diff --git a/packages/server/src/procedure-client.test.ts b/packages/server/src/procedure-client.test.ts new file mode 100644 index 000000000..cb592fb6f --- /dev/null +++ b/packages/server/src/procedure-client.test.ts @@ -0,0 +1,374 @@ +import type { WELL_CONTEXT } from './types' +import { ContractProcedure } from '@orpc/contract' +import { z } from 'zod' +import { isLazy, lazy, unlazy } from './lazy' +import { Procedure } from './procedure' +import { createProcedureClient } from './procedure-client' + +const schema = z.object({ val: z.string().transform(v => Number(v)) }) + +const func = vi.fn(() => ({ val: '123' })) +const mid1 = vi.fn((_, __, meta) => meta.next({})) +const mid2 = vi.fn((_, __, meta) => meta.next({})) + +const procedure = new Procedure({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: schema, + }), + func, + middlewares: [mid1, mid2], +}) + +const procedureCases = [ + ['without lazy', procedure], + ['with lazy', lazy(() => Promise.resolve({ default: procedure }))], +] as const + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe.each(procedureCases)('createProcedureClient - case %s', async (_, procedure) => { + const unwrappedProcedure = isLazy(procedure) ? (await unlazy(procedure)).default : procedure + + it('just a client', async () => { + const client = createProcedureClient({ + procedure, + }) + + await expect(client({ val: '123' })).resolves.toEqual({ val: 123 }) + + expect(func).toBeCalledTimes(1) + expect(func).toBeCalledWith({ val: 123 }, undefined, { path: [], procedure: unwrappedProcedure }) + + expect(mid1).toBeCalledTimes(1) + expect(mid1).toBeCalledWith({ val: 123 }, undefined, { path: [], procedure: unwrappedProcedure, next: expect.any(Function), output: expect.any(Function) }) + + expect(mid2).toBeCalledTimes(1) + expect(mid2).toBeCalledWith({ val: 123 }, undefined, { path: [], procedure: unwrappedProcedure, next: expect.any(Function), output: expect.any(Function) }) + }) + + it('validate input and output', () => { + const client = createProcedureClient({ + procedure, + }) + + // @ts-expect-error - invalid input + expect(client({ val: 123 })).rejects.toThrow('Input validation failed') + + // @ts-expect-error - invalid output + func.mockReturnValueOnce({ val: 1234 }) + expect(client({ val: '1234' })).rejects.toThrow('Output validation failed') + }) + + it('middleware can return output directly', async () => { + const client = createProcedureClient({ + procedure, + }) + + mid1.mockReturnValueOnce({ output: { val: '990' } }) + + await expect(client({ val: '123' })).resolves.toEqual({ val: 990 }) + + expect(mid1).toBeCalledTimes(1) + expect(mid2).toBeCalledTimes(0) + expect(func).toBeCalledTimes(0) + + vi.clearAllMocks() + + mid2.mockReturnValueOnce({ output: { val: '9900' } }) + + await expect(client({ val: '123' })).resolves.toEqual({ val: 9900 }) + + expect(mid1).toBeCalledTimes(1) + expect(mid2).toBeCalledTimes(1) + expect(func).toBeCalledTimes(0) + + expect(mid1).toReturnWith(Promise.resolve({ output: { val: '9900' }, context: undefined })) + }) + + it('output from middleware still be validated', async () => { + const client = createProcedureClient({ + procedure, + context: { userId: '123' }, + }) + + mid1.mockReturnValueOnce({ output: { val: 990 } }) + await expect(client({ val: '1234' })).rejects.toThrow('Output validation failed') + + vi.clearAllMocks() + + mid2.mockReturnValueOnce({ output: { val: 9900 } }) + await expect(client({ val: '1234' })).rejects.toThrow('Output validation failed') + }) + + it('middleware can add extra context - single', async () => { + const client = createProcedureClient({ + procedure, + }) + + mid1.mockImplementationOnce((input, context, meta) => { + return meta.next({ + context: { + extra1: '__extra1__', + }, + }) + }) + + mid2.mockImplementationOnce((input, context, meta) => { + return meta.next({ + context: { + extra2: '__extra2__', + }, + }) + }) + + await expect(client({ val: '123' })).resolves.toEqual({ val: 123 }) + + expect(mid1).toBeCalledTimes(1) + expect(mid1).toHaveBeenCalledWith(expect.any(Object), undefined, expect.any(Object)) + + expect(mid2).toBeCalledTimes(1) + expect(mid2).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({ extra1: '__extra1__' }), expect.any(Object)) + + expect(func).toBeCalledTimes(1) + expect(func).toHaveBeenCalledWith(expect.any(Object), { extra1: '__extra1__', extra2: '__extra2__' }, expect.any(Object)) + }) + + it('middleware can override context', async () => { + const client = createProcedureClient({ + procedure, + context: { userId: '123' }, + }) + + mid1.mockImplementationOnce((input, context, meta) => { + return meta.next({ + context: { + userId: '__override1__', + }, + }) + }) + + mid2.mockImplementationOnce((input, context, meta) => { + return meta.next({ + context: { + userId: '__override2__', + }, + }) + }) + + await expect(client({ val: '123' })).resolves.toEqual({ val: 123 }) + + expect(mid1).toBeCalledTimes(1) + expect(mid1).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({ userId: '123' }), expect.any(Object)) + + expect(mid2).toBeCalledTimes(1) + expect(mid2).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({ userId: '__override1__' }), expect.any(Object)) + + expect(func).toBeCalledTimes(1) + expect(func).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({ userId: '__override2__' }), expect.any(Object)) + }) + + const contextCases = [ + ['directly value', { val: '__val__' }], + ['sync function value', () => ({ val: '__val__' })], + ['async function value', async () => ({ val: '__val__' })], + ] as const + + it.each(contextCases)('can accept context: %s', async (_, context) => { + const client = createProcedureClient({ + procedure, + context, + }) + + await client({ val: '123' }) + + expect(mid1).toBeCalledTimes(1) + expect(mid1).toBeCalledWith(expect.any(Object), { val: '__val__' }, expect.any(Object)) + + expect(mid2).toBeCalledTimes(1) + expect(mid2).toBeCalledWith(expect.any(Object), { val: '__val__' }, expect.any(Object)) + + expect(func).toBeCalledTimes(1) + expect(func).toBeCalledWith(expect.any(Object), { val: '__val__' }, expect.any(Object)) + }) + + it.each(contextCases)('can accept hooks - context: %s', async (_, context) => { + const execute = vi.fn((input, context, meta) => meta.next()) + const onStart = vi.fn() + const onSuccess = vi.fn() + const onError = vi.fn() + const onFinish = vi.fn() + + const client = createProcedureClient({ + procedure, + context, + path: ['users'], + execute, + onStart, + onSuccess, + onError, + onFinish, + }) + + await client({ val: '123' }) + + const meta = { + path: ['users'], + procedure: unwrappedProcedure, + } + + const contextValue = { val: '__val__' } + + expect(execute).toBeCalledTimes(1) + expect(execute).toHaveBeenCalledWith({ val: '123' }, contextValue, { + ...meta, + next: expect.any(Function), + }) + + expect(onStart).toBeCalledTimes(1) + expect(onStart).toHaveBeenCalledWith( + { status: 'pending', input: { val: '123' }, output: undefined, error: undefined }, + contextValue, + meta, + ) + + expect(onSuccess).toBeCalledTimes(1) + expect(onSuccess).toHaveBeenCalledWith( + { status: 'success', input: { val: '123' }, output: { val: 123 }, error: undefined }, + contextValue, + meta, + ) + + expect(onError).toBeCalledTimes(0) + }) + + it('accept paths', async () => { + const onSuccess = vi.fn() + const client = createProcedureClient({ + procedure, + path: ['users'], + onSuccess, + }) + + await client({ val: '123' }) + + expect(mid1).toBeCalledTimes(1) + expect(mid1).toHaveBeenCalledWith(expect.any(Object), undefined, expect.objectContaining({ path: ['users'] })) + + expect(mid2).toBeCalledTimes(1) + expect(mid2).toHaveBeenCalledWith(expect.any(Object), undefined, expect.objectContaining({ path: ['users'] })) + + expect(func).toBeCalledTimes(1) + expect(func).toHaveBeenCalledWith(expect.any(Object), undefined, expect.objectContaining({ path: ['users'] })) + + expect(onSuccess).toBeCalledTimes(1) + expect(onSuccess).toHaveBeenCalledWith(expect.any(Object), undefined, expect.objectContaining({ path: ['users'] })) + }) + + it('support signal', async () => { + const controller = new AbortController() + const signal = controller.signal + + const onSuccess = vi.fn() + + const client = createProcedureClient({ + procedure, + onSuccess, + context: { userId: '123' }, + }) + + await client({ val: '123' }, { signal }) + + expect(mid1).toBeCalledTimes(1) + expect(mid1).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.objectContaining({ signal })) + + expect(mid2).toBeCalledTimes(1) + expect(mid2).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.objectContaining({ signal })) + + expect(func).toBeCalledTimes(1) + expect(func).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.objectContaining({ signal })) + + expect(onSuccess).toBeCalledTimes(1) + expect(onSuccess).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.objectContaining({ signal })) + }) +}) + +it('still work without middleware', async () => { + const procedure = new Procedure({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: schema, + }), + func, + }) + + const client = createProcedureClient({ + procedure, + }) + + await expect(client({ val: '123' })).resolves.toEqual({ val: 123 }) + + expect(func).toBeCalledTimes(1) + expect(func).toHaveBeenCalledWith({ val: 123 }, undefined, { path: [], procedure }) +}) + +it('still work without InputSchema', async () => { + const procedure = new Procedure({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: schema, + }), + func, + }) + + const client = createProcedureClient({ + procedure, + }) + + await expect(client('anything')).resolves.toEqual({ val: 123 }) + + expect(func).toBeCalledTimes(1) + expect(func).toHaveBeenCalledWith('anything', undefined, { path: [], procedure }) +}) + +it('still work without OutputSchema', async () => { + const procedure = new Procedure({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: undefined, + }), + func, + }) + + const client = createProcedureClient({ + procedure, + }) + + // @ts-expect-error - without output schema + func.mockReturnValueOnce('anything') + + await expect(client({ val: '123' })).resolves.toEqual('anything') + + expect(func).toBeCalledTimes(1) + expect(func).toHaveBeenCalledWith({ val: 123 }, undefined, { path: [], procedure }) +}) + +it('has helper `output` in meta', async () => { + const client = createProcedureClient({ + procedure, + }) + + mid2.mockImplementationOnce((input, context, meta) => { + return meta.output({ val: '99990' }) + }) + + await expect(client({ val: '123' })).resolves.toEqual({ val: 99990 }) + + expect(mid1).toBeCalledTimes(1) + expect(mid2).toBeCalledTimes(1) + expect(func).toBeCalledTimes(0) + + expect(mid1).toReturnWith(Promise.resolve({ output: { val: '99990' }, context: undefined })) +}) diff --git a/packages/server/src/procedure-client.ts b/packages/server/src/procedure-client.ts new file mode 100644 index 000000000..c1abef5c1 --- /dev/null +++ b/packages/server/src/procedure-client.ts @@ -0,0 +1,151 @@ +import type { Schema, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { Hooks, Value } from '@orpc/shared' +import type { Lazyable } from './lazy' +import type { MiddlewareMeta } from './middleware' +import type { ANY_PROCEDURE, Procedure } from './procedure' +import type { Context, Meta, WELL_CONTEXT, WithSignal } from './types' +import { executeWithHooks, value } from '@orpc/shared' +import { ORPCError } from '@orpc/shared/error' +import { unlazy } from './lazy' +import { mergeContext } from './utils' + +export interface ProcedureClient { + (...opts: [input: TInput, options?: WithSignal] | (undefined extends TInput ? [] : never)): Promise +} + +/** + * Options for creating a procedure caller with comprehensive type safety + */ +export type CreateProcedureClientOptions< + TContext extends Context, + TInputSchema extends Schema, + TOutputSchema extends Schema, + TFuncOutput extends SchemaInput, +> = + & { + procedure: Lazyable> + + /** + * This is helpful for logging and analytics. + * + * @internal + */ + path?: string[] + } + & ({ + /** + * The context used when calling the procedure. + */ + context: Value + } | (undefined extends TContext ? { context?: undefined } : never)) + & Hooks, TContext, Meta> + +export function createProcedureClient< + TContext extends Context = WELL_CONTEXT, + TInputSchema extends Schema = undefined, + TOutputSchema extends Schema = undefined, + TFuncOutput extends SchemaInput = SchemaInput, +>( + options: CreateProcedureClientOptions, +): ProcedureClient, SchemaOutput> { + return async (...[input, callerOptions]) => { + const path = options.path ?? [] + const { default: procedure } = await unlazy(options.procedure) + const context = await value(options.context) as TContext + + const meta: Meta = { + path, + procedure, + signal: callerOptions?.signal, + } + + const executeWithValidation = async () => { + const validInput = await validateInput(procedure, input) + + const output = await executeMiddlewareChain( + procedure, + validInput, + context, + meta, + ) + + return validateOutput(procedure, output) as SchemaOutput + } + + return executeWithHooks({ + hooks: options, + input, + context, + meta, + execute: executeWithValidation, + }) + } +} + +async function validateInput(procedure: ANY_PROCEDURE, input: unknown) { + const schema = procedure['~orpc'].contract['~orpc'].InputSchema + if (!schema) + return input + + const result = await schema['~standard'].validate(input) + if (result.issues) { + throw new ORPCError({ + message: 'Input validation failed', + code: 'BAD_REQUEST', + issues: result.issues, + }) + } + + return result.value +} + +async function validateOutput(procedure: ANY_PROCEDURE, output: unknown) { + const schema = procedure['~orpc'].contract['~orpc'].OutputSchema + if (!schema) + return output + + const result = await schema['~standard'].validate(output) + if (result.issues) { + throw new ORPCError({ + message: 'Output validation failed', + code: 'INTERNAL_SERVER_ERROR', + issues: result.issues, + }) + } + + return result.value +} + +async function executeMiddlewareChain( + procedure: ANY_PROCEDURE, + input: unknown, + context: Context, + meta: Meta, +) { + const middlewares = procedure['~orpc'].middlewares ?? [] + let currentMidIndex = 0 + let currentContext = context + + const next: MiddlewareMeta['next'] = async (nextOptions) => { + const mid = middlewares[currentMidIndex] + currentMidIndex += 1 + currentContext = mergeContext(currentContext, nextOptions.context) + + if (mid) { + return await mid(input, currentContext, { + ...meta, + next, + output: output => ({ output, context: undefined }), + }) + } + + const result = { + output: await procedure['~orpc'].func(input, currentContext, meta), + context: currentContext, + } + + return result as any + } + + return (await next({})).output +} diff --git a/packages/server/src/procedure-decorated.test-d.ts b/packages/server/src/procedure-decorated.test-d.ts new file mode 100644 index 000000000..c6899ab3c --- /dev/null +++ b/packages/server/src/procedure-decorated.test-d.ts @@ -0,0 +1,189 @@ +import type { Middleware, MiddlewareMeta } from './middleware' +import type { Procedure } from './procedure' +import type { DecoratedProcedure } from './procedure-decorated' +import type { WELL_CONTEXT } from './types' +import { z } from 'zod' +import { decorateProcedure } from './procedure-decorated' + +const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) +const procedure = {} as Procedure<{ auth: boolean }, { db: string }, typeof schema, typeof schema, { val: string }> + +const decorated = decorateProcedure(procedure) + +describe('self chainable', () => { + it('prefix', () => { + expectTypeOf(decorated.prefix('/test')).toEqualTypeOf() + + // @ts-expect-error - invalid prefix + decorated.prefix('') + // @ts-expect-error - invalid prefix + decorated.prefix(1) + }) + + it('route', () => { + expectTypeOf(decorated.route({ path: '/test', method: 'GET' })).toEqualTypeOf() + expectTypeOf(decorated.route({ + path: '/test', + method: 'GET', + description: 'description', + summary: 'summary', + deprecated: true, + tags: ['tag1', 'tag2'], + })).toEqualTypeOf() + + // @ts-expect-error - invalid method + decorated.route({ method: 'PUTT' }) + // @ts-expect-error - invalid path + decorated.route({ path: 1 }) + // @ts-expect-error - invalid tags + decorated.route({ tags: [1] }) + }) + + it('use middleware', () => { + const i = decorated + .use((input, context, meta) => { + expectTypeOf(input).toEqualTypeOf<{ val: number }>() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & { db: string }>() + expectTypeOf(meta).toEqualTypeOf>() + + return meta.next({ + context: { + dev: true, + }, + }) + }) + .use((input, context, meta) => { + expectTypeOf(input).toEqualTypeOf<{ val: number }>() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & { db: string } & { dev: boolean }>() + expectTypeOf(meta).toEqualTypeOf>() + + return meta.next({}) + }) + + expectTypeOf(i).toEqualTypeOf< + DecoratedProcedure< + { auth: boolean }, + { db: string } & { dev: boolean }, + typeof schema, + typeof schema, + { val: string } + > + >() + }) + + it('use middleware with map input', () => { + const mid = {} as Middleware + + const i = decorated.use(mid, (input) => { + expectTypeOf(input).toEqualTypeOf<{ val: number }>() + return input.val + }) + + expectTypeOf(i).toEqualTypeOf< + DecoratedProcedure< + { auth: boolean }, + { db: string } & { extra: boolean }, + typeof schema, + typeof schema, + { val: string } + > + >() + + // @ts-expect-error - invalid input + decorated.use(mid) + + // @ts-expect-error - invalid mapped input + decorated.use(mid, input => input) + }) + + it('prevent conflict on context', () => { + decorated.use((input, context, meta) => meta.next({})) + decorated.use((input, context, meta) => meta.next({ context: { id: '1' } })) + decorated.use((input, context, meta) => meta.next({ context: { id: '1', extra: true } })) + decorated.use((input, context, meta) => meta.next({ context: { auth: true } })) + + decorated.use((input, context, meta) => meta.next({}), () => 'anything') + decorated.use((input, context, meta) => meta.next({ context: { id: '1' } }), () => 'anything') + decorated.use((input, context, meta) => meta.next({ context: { id: '1', extra: true } }), () => 'anything') + decorated.use((input, context, meta) => meta.next({ context: { auth: true } }), () => 'anything') + + // @ts-expect-error - conflict with context + decorated.use((input, context, meta) => meta.next({ context: { auth: 1 } })) + + // @ts-expect-error - conflict with context + decorated.use((input, context, meta) => meta.next({ context: { auth: 1, extra: true } })) + + // @ts-expect-error - conflict with context + decorated.use((input, context, meta) => meta.next({ context: { auth: 1 } }), () => 'anything') + + // @ts-expect-error - conflict with context + decorated.use((input, context, meta) => meta.next({ context: { auth: 1, extra: true } }), () => 'anything') + }) + + it('handle middleware with output is typed', () => { + const mid1 = {} as Middleware + const mid2 = {} as Middleware + const mid3 = {} as Middleware + const mid4 = {} as Middleware + + decorated.use(mid1) + decorated.use(mid2) + + // @ts-expect-error - required used any for output + decorated.use(mid3) + // @ts-expect-error - output is not match + decorated.use(mid4) + }) + + it('unshiftTag', () => { + expectTypeOf(decorated.unshiftTag('test')).toEqualTypeOf() + expectTypeOf(decorated.unshiftTag('test', 'test2', 'test3')).toEqualTypeOf() + + // @ts-expect-error - invalid tag + decorated.unshiftTag(1) + // @ts-expect-error - invalid tag + decorated.unshiftTag('123', 2) + }) + + it('unshiftMiddleware', () => { + const mid1 = {} as Middleware + const mid2 = {} as Middleware<{ auth: boolean }, undefined, { val: number }, any> + const mid3 = {} as Middleware<{ auth: boolean }, { dev: boolean }, unknown, { val: string }> + + expectTypeOf(decorated.unshiftMiddleware(mid1)).toEqualTypeOf() + expectTypeOf(decorated.unshiftMiddleware(mid1, mid2)).toEqualTypeOf() + expectTypeOf(decorated.unshiftMiddleware(mid1, mid2, mid3)).toEqualTypeOf() + + const mid4 = {} as Middleware<{ auth: 'invalid' }, undefined, unknown, any> + const mid5 = {} as Middleware<{ auth: boolean }, undefined, { val: string }, any> + const mid6 = {} as Middleware + const mid7 = {} as Middleware<{ db: string }, undefined, unknown, { val: string }> + const mid8 = {} as Middleware + + // @ts-expect-error - context is not match + decorated.unshiftMiddleware(mid4) + // @ts-expect-error - input is not match + decorated.unshiftMiddleware(mid5) + // @ts-expect-error - output is not match + decorated.unshiftMiddleware(mid6) + // @ts-expect-error - context is not match + decorated.unshiftMiddleware(mid7) + // @ts-expect-error - extra context is conflict with context + decorated.unshiftMiddleware(mid8) + // @ts-expect-error - invalid middleware + decorated.unshiftMiddleware(mid4, mid5, mid6, mid7, mid8) + + const mid9 = {} as Middleware + const mid10 = {} as Middleware + + decorated.unshiftMiddleware(mid9) + decorated.unshiftMiddleware(mid10) + // @ts-expect-error - extra context of mid10 is conflict with extra context of mid9 + decorated.unshiftMiddleware(mid9, mid10) + + // @ts-expect-error - invalid middleware + decorated.unshiftMiddleware(1) + // @ts-expect-error - invalid middleware + decorated.unshiftMiddleware(() => { }, 1) + }) +}) diff --git a/packages/server/src/procedure-decorated.test.ts b/packages/server/src/procedure-decorated.test.ts new file mode 100644 index 000000000..d4914eab5 --- /dev/null +++ b/packages/server/src/procedure-decorated.test.ts @@ -0,0 +1,177 @@ +import { ContractProcedure } from '@orpc/contract' +import { z } from 'zod' +import { isProcedure, Procedure } from './procedure' +import { decorateProcedure } from './procedure-decorated' + +beforeEach(() => { + vi.clearAllMocks() +}) + +const func = vi.fn(() => ({ val: '123' })) +const mid = vi.fn((_, __, meta) => meta.next({})) + +const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) +const procedure = new Procedure<{ auth: boolean }, { db: string }, typeof schema, typeof schema, { val: string }>({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: schema, + route: { path: '/test', method: 'GET', deprecated: true, description: 'des', summary: 'sum', tags: ['hi'] }, + inputExample: { val: 123 }, + outputExample: { val: 456 }, + }), + func, + middlewares: [mid], +}) + +const decorated = decorateProcedure(procedure) + +describe('self chainable', () => { + it('prefix', () => { + const prefixed = decorated.prefix('/test') + + expect(prefixed).not.toBe(decorated) + + expect(prefixed).toSatisfy(isProcedure) + expect(prefixed['~orpc'].contract['~orpc'].route?.path).toBe('/test/test') + }) + + it('route', () => { + const route = { path: '/test', method: 'GET', tags: ['hiu'] } as const + const routed = decorated.route(route) + + expect(routed).not.toBe(decorated) + expect(routed).toSatisfy(isProcedure) + expect(routed['~orpc'].contract['~orpc'].route).toBe(route) + }) + + it('use middleware', () => { + const extraMid = vi.fn() + + const applied = decorated.use(extraMid) + + expect(applied).not.toBe(decorated) + expect(applied).toSatisfy(isProcedure) + expect(applied['~orpc'].middlewares).toEqual([mid, extraMid]) + }) + + it('use middleware with map input', () => { + const extraMid = vi.fn() + const map = vi.fn() + + const applied = decorated.use(extraMid, map) + expect(applied).not.toBe(decorated) + expect(applied).toSatisfy(isProcedure) + expect(applied['~orpc'].middlewares).toEqual([mid, expect.any(Function)]) + + extraMid.mockReturnValueOnce('__extra__') + map.mockReturnValueOnce('__map__') + + expect((applied as any)['~orpc'].middlewares[1]('input')).toBe('__extra__') + + expect(map).toBeCalledTimes(1) + expect(map).toBeCalledWith('input') + + expect(extraMid).toBeCalledTimes(1) + expect(extraMid).toBeCalledWith('__map__') + }) + + it('unshiftTag', () => { + const tagged = decorated.unshiftTag('test', 'test2', 'test3') + expect(tagged).not.toBe(decorated) + expect(tagged).toSatisfy(isProcedure) + expect(tagged['~orpc'].contract['~orpc'].route?.tags).toEqual(['test', 'test2', 'test3', 'hi']) + }) + + it('unshiftMiddleware', () => { + const mid1 = vi.fn() + const mid2 = vi.fn() + + const applied = decorated.unshiftMiddleware(mid1, mid2) + expect(applied).not.toBe(decorated) + expect(applied).toSatisfy(isProcedure) + expect(applied['~orpc'].middlewares).toEqual([mid1, mid2, mid]) + }) + + describe('unshiftMiddleware --- prevent duplicate', () => { + const mid1 = vi.fn() + const mid2 = vi.fn() + const mid3 = vi.fn() + const mid4 = vi.fn() + const mid5 = vi.fn() + + it('no duplicate', () => { + expect( + decorated.unshiftMiddleware(mid1, mid2)['~orpc'].middlewares, + ).toEqual([mid1, mid2, mid]) + }) + + it('case 1', () => { + expect( + decorated.unshiftMiddleware(mid1, mid2).unshiftMiddleware(mid1, mid3)['~orpc'].middlewares, + ).toEqual([mid1, mid3, mid2, mid]) + }) + + it('case 2', () => { + expect( + decorated.unshiftMiddleware(mid1, mid2, mid3, mid4).unshiftMiddleware(mid1, mid4, mid2, mid3)['~orpc'].middlewares, + ).toEqual([mid1, mid4, mid2, mid3, mid4, mid]) + }) + + it('case 3', () => { + expect( + decorated.unshiftMiddleware(mid1, mid5, mid2, mid3, mid4).unshiftMiddleware(mid1, mid4, mid2, mid3)['~orpc'].middlewares, + ).toEqual([mid1, mid4, mid2, mid3, mid5, mid2, mid3, mid4, mid]) + }) + + it('case 4', () => { + expect( + decorated + .unshiftMiddleware(mid2, mid2) + .unshiftMiddleware(mid1, mid2)['~orpc'].middlewares, + ).toEqual([mid1, mid2, mid2, mid]) + }) + + it('case 5', () => { + expect( + decorated + .unshiftMiddleware(mid2, mid2) + .unshiftMiddleware(mid1, mid2, mid2)['~orpc'].middlewares, + ).toEqual([mid1, mid2, mid2, mid]) + }) + }) +}) + +it('can use middleware when has no middleware', () => { + const decorated = decorateProcedure(new Procedure({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + }), + func: () => { }, + })) + + const mid = vi.fn() + const applied = decorated.use(mid) + + expect(applied).not.toBe(decorated) + expect(applied).toSatisfy(isProcedure) + expect(applied['~orpc'].middlewares).toEqual([mid]) +}) + +it('can unshift middleware when has no middleware', () => { + const decorated = decorateProcedure(new Procedure({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + }), + func: () => { }, + })) + + const mid1 = vi.fn() + const mid2 = vi.fn() + const applied = decorated.unshiftMiddleware(mid1, mid2) + + expect(applied).not.toBe(decorated) + expect(applied).toSatisfy(isProcedure) + expect(applied['~orpc'].middlewares).toEqual([mid1, mid2]) +}) diff --git a/packages/server/src/procedure-decorated.ts b/packages/server/src/procedure-decorated.ts new file mode 100644 index 000000000..48cc97c79 --- /dev/null +++ b/packages/server/src/procedure-decorated.ts @@ -0,0 +1,151 @@ +import type { HTTPPath, RouteOptions, Schema, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { ANY_MIDDLEWARE, MapInputMiddleware, Middleware } from './middleware' +import type { ProcedureClient } from './procedure-client' +import type { Context, MergeContext } from './types' +import { DecoratedContractProcedure } from '@orpc/contract' +import { decorateMiddleware } from './middleware-decorated' +import { Procedure } from './procedure' +import { createProcedureClient } from './procedure-client' + +export type DecoratedProcedure< + TContext extends Context, + TExtraContext extends Context, + TInputSchema extends Schema, + TOutputSchema extends Schema, + TFuncOutput extends SchemaInput, +> = + & Procedure + & { + prefix: ( + prefix: HTTPPath, + ) => DecoratedProcedure + + route: ( + route: RouteOptions, + ) => DecoratedProcedure + + use: + & ( + > | undefined = undefined>( + middleware: Middleware< + MergeContext, + U, + SchemaOutput, + SchemaInput + >, + ) => DecoratedProcedure< + TContext, + MergeContext, + TInputSchema, + TOutputSchema, + TFuncOutput + > + ) + & ( + < + UExtra extends Context & Partial> | undefined = undefined, + UInput = unknown, + >( + middleware: Middleware< + MergeContext, + UExtra, + UInput, + SchemaInput + >, + mapInput: MapInputMiddleware< + SchemaOutput, + UInput + >, + ) => DecoratedProcedure< + TContext, + MergeContext, + TInputSchema, + TOutputSchema, + TFuncOutput + > + ) + + unshiftTag: (...tags: string[]) => DecoratedProcedure + + unshiftMiddleware: > | undefined = undefined>( + ...middlewares: Middleware, SchemaInput>[] + ) => DecoratedProcedure + + } + & (undefined extends TContext ? ProcedureClient, SchemaOutput> : unknown) + +export function decorateProcedure< + TContext extends Context, + TExtraContext extends Context, + TInputSchema extends Schema, + TOutputSchema extends Schema, + TFuncOutput extends SchemaInput, +>( + procedure: Procedure, +): DecoratedProcedure { + const caller = createProcedureClient({ + procedure, + context: undefined as any, + }) + + const decorated = caller as DecoratedProcedure + + decorated['~type'] = procedure['~type'] + decorated['~orpc'] = procedure['~orpc'] + + decorated.prefix = (prefix) => { + return decorateProcedure(new Procedure({ + ...procedure['~orpc'], + contract: DecoratedContractProcedure.decorate(procedure['~orpc'].contract).prefix(prefix), + })) + } + + decorated.route = (route) => { + return decorateProcedure(new Procedure({ + ...procedure['~orpc'], + contract: DecoratedContractProcedure.decorate(procedure['~orpc'].contract).route(route), + })) + } + + decorated.use = (middleware: Middleware, mapInput?: MapInputMiddleware) => { + const middleware_ = mapInput + ? decorateMiddleware(middleware).mapInput(mapInput) + : middleware + + return decorateProcedure(new Procedure({ + ...procedure['~orpc'], + middlewares: [...(procedure['~orpc'].middlewares ?? []), middleware_], + })) as any + } + + decorated.unshiftTag = (...tags) => { + return decorateProcedure(new Procedure({ + ...procedure['~orpc'], + contract: DecoratedContractProcedure.decorate(procedure['~orpc'].contract).unshiftTag(...tags), + })) + } + + decorated.unshiftMiddleware = (...middlewares: ANY_MIDDLEWARE[]) => { + if (procedure['~orpc'].middlewares?.length) { + let min = 0 + + for (let i = 0; i < procedure['~orpc'].middlewares.length; i++) { + const index = middlewares.indexOf(procedure['~orpc'].middlewares[i]!, min) + + if (index === -1) { + middlewares.push(...procedure['~orpc'].middlewares.slice(i)) + break + } + + min = index + 1 + } + } + + return decorateProcedure(new Procedure({ + ...procedure['~orpc'], + middlewares, + })) + } + + return decorated +} diff --git a/packages/server/src/procedure-implementer.test-d.ts b/packages/server/src/procedure-implementer.test-d.ts new file mode 100644 index 000000000..681050479 --- /dev/null +++ b/packages/server/src/procedure-implementer.test-d.ts @@ -0,0 +1,150 @@ +import type { Middleware, MiddlewareMeta } from './middleware' +import type { DecoratedProcedure } from './procedure-decorated' +import type { Meta, WELL_CONTEXT } from './types' +import { ContractProcedure } from '@orpc/contract' +import { z } from 'zod' +import { ProcedureImplementer } from './procedure-implementer' + +describe('self chainable', () => { + const global_mid = vi.fn() + const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + const implementer = new ProcedureImplementer<{ id?: string }, undefined, typeof schema, typeof schema>({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: schema, + }), + middlewares: [global_mid], + }) + + it('use middleware', () => { + const i = implementer + .use((input, context, meta) => { + expectTypeOf(input).toEqualTypeOf<{ val: number }>() + expectTypeOf(context).toEqualTypeOf<{ id?: string }>() + expectTypeOf(meta).toEqualTypeOf>() + + return meta.next({ + context: { + auth: true, + }, + }) + }) + .use((input, context, meta) => { + expectTypeOf(input).toEqualTypeOf<{ val: number }>() + expectTypeOf(context).toEqualTypeOf< + { id?: string } & { auth: boolean } + >() + expectTypeOf(meta).toEqualTypeOf>() + + return meta.next({}) + }) + + expectTypeOf(i).toEqualTypeOf< + ProcedureImplementer< + { id?: string }, + { auth: boolean }, + typeof schema, + typeof schema + > + >() + }) + + it('use middleware with map input', () => { + const mid: Middleware = (input, context, meta) => { + return meta.next({ + context: { id: 'string', extra: true }, + }) + } + + const i = implementer.use(mid, (input) => { + expectTypeOf(input).toEqualTypeOf<{ val: number }>() + return input.val + }) + + expectTypeOf(i).toEqualTypeOf< + ProcedureImplementer< + { id?: string }, + { id: string, extra: boolean }, + typeof schema, + typeof schema + > + >() + + // @ts-expect-error - invalid input + implementer.use(mid) + + // @ts-expect-error - invalid mapped input + implementer.use(mid, input => input) + }) + + it('prevent conflict on context', () => { + implementer.use((input, context, meta) => meta.next({})) + implementer.use((input, context, meta) => meta.next({ context: { id: '1' } })) + implementer.use((input, context, meta) => meta.next({ context: { id: '1', extra: true } })) + implementer.use((input, context, meta) => meta.next({ context: { auth: true } })) + + implementer.use((input, context, meta) => meta.next({}), () => 'anything') + implementer.use((input, context, meta) => meta.next({ context: { id: '1' } }), () => 'anything') + implementer.use((input, context, meta) => meta.next({ context: { id: '1', extra: true } }), () => 'anything') + implementer.use((input, context, meta) => meta.next({ context: { auth: true } }), () => 'anything') + + // @ts-expect-error - conflict with context + implementer.use((input, context, meta) => meta.next({ context: { id: 1 } })) + + // @ts-expect-error - conflict with context + implementer.use((input, context, meta) => meta.next({ context: { id: 1, extra: true } })) + + // @ts-expect-error - conflict with context + implementer.use((input, context, meta) => meta.next({ context: { id: 1 } }), () => 'anything') + + // @ts-expect-error - conflict with context + implementer.use((input, context, meta) => meta.next({ context: { id: 1, extra: true } }), () => 'anything') + }) + + it('handle middleware with output is typed', () => { + const mid1 = {} as Middleware + const mid2 = {} as Middleware + const mid3 = {} as Middleware + const mid4 = {} as Middleware + + implementer.use(mid1) + implementer.use(mid2) + // @ts-expect-error - required used any for output + implementer.use(mid3) + // @ts-expect-error - output is not match + implementer.use(mid4) + }) +}) + +describe('to DecoratedProcedure', () => { + const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + + const global_mid = vi.fn() + const implementer = new ProcedureImplementer<{ id?: string } | undefined, { db: string }, typeof schema, typeof schema>({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: schema, + }), + middlewares: [global_mid], + }) + + it('func', () => { + const procedure = implementer.func((input, context, meta) => { + expectTypeOf(context).toEqualTypeOf<({ id?: string } & { db: string }) | { db: string }>() + expectTypeOf(input).toEqualTypeOf<{ val: number }>() + expectTypeOf(meta).toEqualTypeOf() + + return { val: '1' } + }) + + expectTypeOf(procedure).toEqualTypeOf< + DecoratedProcedure<{ id?: string } | undefined, { db: string }, typeof schema, typeof schema, { val: string }> + >() + + // @ts-expect-error - invalid output + implementer.func(() => ({ val: 1 })) + + // @ts-expect-error - invalid output + implementer.func(() => {}) + }) +}) diff --git a/packages/server/src/procedure-implementer.test.ts b/packages/server/src/procedure-implementer.test.ts index 6a4c985b7..43bdf86b3 100644 --- a/packages/server/src/procedure-implementer.test.ts +++ b/packages/server/src/procedure-implementer.test.ts @@ -1,224 +1,68 @@ -import type { DecoratedProcedure, Meta, MiddlewareMeta } from '.' -import { DecoratedContractProcedure } from '@orpc/contract' +import { ContractProcedure } from '@orpc/contract' import { z } from 'zod' -import { isProcedure, os } from '.' +import { isProcedure } from './procedure' import { ProcedureImplementer } from './procedure-implementer' -const p1 = new DecoratedContractProcedure({ - InputSchema: undefined, - OutputSchema: undefined, - route: { - method: undefined, - path: undefined, - }, -}) -const implementer1 = new ProcedureImplementer< - { auth: boolean }, - undefined, - undefined, - undefined ->({ contract: p1 }) - -const schema1 = z.object({ id: z.string() }) -const schema2 = z.object({ name: z.string() }) - -const p2 = new DecoratedContractProcedure({ - InputSchema: schema1, - OutputSchema: schema2, - route: { - method: 'GET', - path: '/test', - }, -}) - -const implementer2 = new ProcedureImplementer< - { auth: boolean }, - undefined, - typeof schema1, - typeof schema2 ->({ contract: p2 }) - -describe('use middleware', () => { - it('infer types', () => { - const i = implementer1 - .use((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf>() - - return meta.next({ - context: { - userId: '1', - }, - }) - }) - .use((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf< - { userId: string } & { auth: boolean } - >() - expectTypeOf(meta).toEqualTypeOf>() - - return meta.next({}) - }) - - expectTypeOf(i).toEqualTypeOf< - ProcedureImplementer< - { auth: boolean }, - { userId: string }, - undefined, - undefined - > - >() +describe('self chainable', () => { + const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + const implementer = new ProcedureImplementer<{ id?: string }, undefined, typeof schema, typeof schema>({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: schema, + }), }) - it('map middleware input', () => { - // @ts-expect-error mismatch input - implementer2.use((input: { postId: string }) => { - return { context: { a: 'a' } } - }) - - implementer2.use( - (input: { postId: string }, _, meta) => { - return meta.next({ context: { a: 'a' } }) - }, - // @ts-expect-error mismatch input - input => ({ postId: 12455 }), - ) - - implementer2.use( - (input: { postId: string }, context, meta) => meta.next({}), - input => ({ postId: '12455' }), - ) + it('use middleware', () => { + const mid1 = vi.fn() + const mid2 = vi.fn() + const i = implementer.use(mid1).use(mid2) - const i = implementer2.use( - (input: { id: number }, _, meta) => { - return meta.next({ - context: { - userIdd: '1', - }, - }) - }, - input => ({ id: Number.parseInt(input.id) }), - ) - - expectTypeOf(i).toEqualTypeOf< - ProcedureImplementer< - { auth: boolean }, - { userIdd: string }, - typeof schema1, - typeof schema2 - > - >() + expect(i).not.toBe(implementer) + expect(i).toBeInstanceOf(ProcedureImplementer) + expect(i['~orpc'].middlewares).toEqual([mid1, mid2]) }) -}) -describe('output schema', () => { - it('auto infer output schema if output schema is not specified', async () => { - const sr = os.func(() => ({ a: 1 })) + it('use middleware with map input', () => { + const mid = vi.fn() + const map = vi.fn() - const result = await sr.zz$p.func({}, undefined, { - method: 'GET', - path: '/', - } as any) + const i = implementer.use(mid, map) - expectTypeOf(result).toEqualTypeOf<{ a: number }>() - }) + expect(i).not.toBe(implementer) + expect(i).toBeInstanceOf(ProcedureImplementer) + expect(i['~orpc'].middlewares).toEqual([expect.any(Function)]) - it('not infer output schema if output schema is specified', async () => { - const srb1 = new ProcedureImplementer({ - contract: new DecoratedContractProcedure({ - OutputSchema: z.unknown(), - InputSchema: undefined, - }), - }) + map.mockReturnValueOnce('__input__') + mid.mockReturnValueOnce('__mid__') - const sr = srb1.func(() => ({ b: 1 })) + expect((i as any)['~orpc'].middlewares[0]('input')).toBe('__mid__') - const result = await sr.zz$p.func({}, {}, { - method: 'GET', - path: '/', - } as any) + expect(map).toBeCalledTimes(1) + expect(map).toBeCalledWith('input') - expectTypeOf(result).toEqualTypeOf() + expect(mid).toBeCalledTimes(1) + expect(mid).toBeCalledWith('__input__') }) }) -describe('handler', () => { - it('infer types', () => { - const handler = implementer1.func((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf() - - return { - name: 'unnoq', - } - }) - - expectTypeOf(handler).toEqualTypeOf< - DecoratedProcedure< - { auth: boolean }, - undefined, - undefined, - undefined, - { name: string } - > - >() - expect(isProcedure(handler)).toBe(true) - - implementer2.func((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf<{ id: string }>() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() - expectTypeOf(meta).toEqualTypeOf() +describe('to DecoratedProcedure', () => { + const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) - return { - name: 'unnoq', - } - }) - - // @ts-expect-error mismatch output - implementer2.func(() => {}) + const global_mid = vi.fn() + const implementer = new ProcedureImplementer<{ id?: string } | undefined, { db: string }, typeof schema, typeof schema>({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: schema, + }), + middlewares: [global_mid], }) - it('combine middlewares', () => { - const mid1 = os.middleware((input, context, meta) => { - return meta.next({ - context: { - userId: '1', - }, - }) - }) - - const mid2 = os.middleware((input, context, meta) => { - return meta.next({ }) - }) - - const handler = implementer2 - .use(mid1) - .use(mid2) - .func((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf<{ id: string }>() - expectTypeOf(context).toEqualTypeOf< - { auth: boolean } & { userId: string } - >() - expectTypeOf(meta).toEqualTypeOf() - - return { - name: 'unnoq', - } - }) - - expectTypeOf(handler).toEqualTypeOf< - DecoratedProcedure< - { auth: boolean }, - { userId: string }, - typeof schema1, - typeof schema2, - { name: string } - > - >() + it('func', () => { + const func = vi.fn() + const procedure = implementer.func(func) - expect(handler.zz$p.middlewares).toEqual([mid1, mid2]) + expect(procedure).toSatisfy(isProcedure) + expect(procedure['~orpc'].func).toBe(func) + expect(procedure['~orpc'].middlewares).toEqual([global_mid]) }) }) diff --git a/packages/server/src/procedure-implementer.ts b/packages/server/src/procedure-implementer.ts index 672c8eb88..2322bc846 100644 --- a/packages/server/src/procedure-implementer.ts +++ b/packages/server/src/procedure-implementer.ts @@ -1,14 +1,21 @@ import type { ContractProcedure, Schema, SchemaInput, SchemaOutput } from '@orpc/contract' -import type { DecoratedLazy } from './lazy' -import type { DecoratedProcedure, Procedure, ProcedureFunc } from './procedure' +import type { ANY_MAP_INPUT_MIDDLEWARE, ANY_MIDDLEWARE, MapInputMiddleware, Middleware } from './middleware' +import type { ProcedureFunc } from './procedure' +import type { DecoratedProcedure } from './procedure-decorated' import type { Context, MergeContext } from './types' -import { - decorateMiddleware, - type MapInputMiddleware, - type Middleware, -} from './middleware' -import { decorateProcedure } from './procedure' -import { RouterBuilder } from './router-builder' +import { decorateMiddleware } from './middleware-decorated' +import { Procedure } from './procedure' +import { decorateProcedure } from './procedure-decorated' + +export type ProcedureImplementerDef< + TContext extends Context, + TExtraContext extends Context, + TInputSchema extends Schema, + TOutputSchema extends Schema, +> = { + contract: ContractProcedure + middlewares?: Middleware, Partial | undefined, SchemaOutput, SchemaInput>[] +} export class ProcedureImplementer< TContext extends Context, @@ -16,93 +23,66 @@ export class ProcedureImplementer< TInputSchema extends Schema, TOutputSchema extends Schema, > { - constructor( - public zz$pi: { - contract: ContractProcedure - middlewares?: Middleware[] - }, - ) {} + '~type' = 'ProcedureImplementer' as const + '~orpc': ProcedureImplementerDef - use< - UExtraContext extends - | Partial>> - | undefined = undefined, - >( + constructor(def: ProcedureImplementerDef) { + this['~orpc'] = def + } + + use> | undefined = undefined>( middleware: Middleware< MergeContext, - UExtraContext, + U, SchemaOutput, SchemaInput >, ): ProcedureImplementer< TContext, - MergeContext, + MergeContext, TInputSchema, TOutputSchema > use< - UExtraContext extends - | Partial>> - | undefined = undefined, - UMappedInput = unknown, + UExtra extends Context & Partial> | undefined = undefined, + UInput = unknown, >( middleware: Middleware< MergeContext, - UExtraContext, - UMappedInput, + UExtra, + UInput, SchemaInput >, - mapInput: MapInputMiddleware, UMappedInput>, + mapInput: MapInputMiddleware, UInput>, ): ProcedureImplementer< TContext, - MergeContext, + MergeContext, TInputSchema, TOutputSchema > use( - middleware: Middleware, - mapInput?: MapInputMiddleware, + middleware: ANY_MIDDLEWARE, + mapInput?: ANY_MAP_INPUT_MIDDLEWARE, ): ProcedureImplementer { - const middleware_ = mapInput + const mappedMiddleware = mapInput ? decorateMiddleware(middleware).mapInput(mapInput) : middleware return new ProcedureImplementer({ - ...this.zz$pi, - middlewares: [...(this.zz$pi.middlewares ?? []), middleware_], - }) - } - - func>( - func: ProcedureFunc< - TContext, - TExtraContext, - TInputSchema, - TOutputSchema, - UFuncOutput - >, - ): DecoratedProcedure< - TContext, - TExtraContext, - TInputSchema, - TOutputSchema, - UFuncOutput - > { - return decorateProcedure({ - zz$p: { - middlewares: this.zz$pi.middlewares, - contract: this.zz$pi.contract, - func, - }, + ...this['~orpc'], + middlewares: [...(this['~orpc'].middlewares ?? []), mappedMiddleware], }) } - lazy>>( - loader: () => Promise<{ default: U }>, - ): DecoratedLazy { - // TODO: replace with a more solid solution - return new RouterBuilder(this.zz$pi).lazy(loader as any) as any + func>( + func: ProcedureFunc, + ): DecoratedProcedure { + return decorateProcedure(new Procedure({ + middlewares: this['~orpc'].middlewares, + contract: this['~orpc'].contract, + func, + })) } } diff --git a/packages/server/src/procedure.test-d.ts b/packages/server/src/procedure.test-d.ts new file mode 100644 index 000000000..f1c77303c --- /dev/null +++ b/packages/server/src/procedure.test-d.ts @@ -0,0 +1,12 @@ +import type { ANY_PROCEDURE } from './procedure' +import { isProcedure } from './procedure' + +describe('isProcedure', () => { + it('works', () => { + const item = {} as unknown + + if (isProcedure(item)) { + expectTypeOf(item).toEqualTypeOf() + } + }) +}) diff --git a/packages/server/src/procedure.test.ts b/packages/server/src/procedure.test.ts index bdbd8e491..948e73349 100644 --- a/packages/server/src/procedure.test.ts +++ b/packages/server/src/procedure.test.ts @@ -1,302 +1,22 @@ -import type { MiddlewareMeta } from '.' -import { ContractProcedure, DecoratedContractProcedure } from '@orpc/contract' -import { z } from 'zod' -import { os } from '.' -import { - type DecoratedProcedure, - decorateProcedure, - isProcedure, - Procedure, -} from './procedure' +import { ContractProcedure } from '@orpc/contract' +import { isProcedure, Procedure } from './procedure' -it('isProcedure', () => { - expect( - isProcedure( - decorateProcedure( - new Procedure({ - contract: new ContractProcedure({ - InputSchema: undefined, - OutputSchema: undefined, - }), - func: () => {}, - }), - ), - ), - ).toBe(true) - expect({ - zz$p: { - contract: new DecoratedContractProcedure({ - InputSchema: undefined, - OutputSchema: undefined, - }), - func: () => {}, - }, - }).toSatisfy(isProcedure) - - expect({ - zz$p: { - contract: new DecoratedContractProcedure({ - InputSchema: undefined, - OutputSchema: undefined, - }), - }, - }).not.toSatisfy(isProcedure) - - expect({ - zz$p: { - handler: () => {}, - }, - }).not.toSatisfy(isProcedure) - - expect({}).not.toSatisfy(isProcedure) - expect(12233).not.toSatisfy(isProcedure) - expect('12233').not.toSatisfy(isProcedure) - expect(undefined).not.toSatisfy(isProcedure) - expect(null).not.toSatisfy(isProcedure) -}) - -describe('route method', () => { - it('sets route options correctly', () => { - const p = os.context<{ auth: boolean }>().func(() => { - return 'test' - }) - - const p2 = p.route({ path: '/test', method: 'GET' }) - - expect(p2.zz$p.contract['~orpc'].route?.path).toBe('/test') - expect(p2.zz$p.contract['~orpc'].route?.method).toBe('GET') - }) - - it('preserves existing context and handler', () => { - const handler = () => 'test' - const p = os.context<{ auth: boolean }>().func(handler) - - const p2 = p.route({ path: '/test' }) - - expect(p2.zz$p.func).toBe(handler) - // Context type is preserved through the route method - expectTypeOf(p2).toEqualTypeOf< - DecoratedProcedure< - { auth: boolean }, - undefined, - undefined, - undefined, - string - > - >() - }) - - it('works with prefix method', () => { - const p = os - .context<{ auth: boolean }>() - .route({ path: '/api', method: 'POST' }) - .func(() => 'test') - - const p2 = p.prefix('/v1') - - expect(p2.zz$p.contract['~orpc'].route?.path).toBe('/v1/api') - expect(p2.zz$p.contract['~orpc'].route?.method).toBe('POST') - }) - - it('works with middleware', () => { - const mid = os.middleware((_, __, meta) => meta.next({ context: { userId: '1' } })) - - const p = os - .context<{ auth: boolean }>() - .route({ path: '/test' }) - .use(mid) - .func((input, context) => { - expectTypeOf(context).toEqualTypeOf< - { auth: boolean } & { userId: string } - >() - return 'test' - }) - - expect(p.zz$p.contract['~orpc'].route?.path).toBe('/test') - expect(p.zz$p.middlewares).toEqual([mid]) - }) - - it('overrides existing route options', () => { - const p = os - .context<{ auth: boolean }>() - .route({ path: '/test1', method: 'GET' }) - .func(() => 'test') - - const p2 = p.route({ path: '/test2', method: 'POST' }) - - expect(p2.zz$p.contract['~orpc'].route?.path).toBe('/test2') - expect(p2.zz$p.contract['~orpc'].route?.method).toBe('POST') - }) - - it('preserves input/output schemas', () => { - const inputSchema = z.object({ id: z.number() }) - const outputSchema = z.string() - const p = os - .context<{ auth: boolean }>() - .input(inputSchema) - .output(outputSchema) - .route({ path: '/test' }) - .func((input) => { - expectTypeOf(input).toEqualTypeOf<{ id: number }>() - return 'test' - }) - - const p2 = p.route({ path: '/test2' }) - - // Type checking that schemas are preserved - expectTypeOf(p2).toEqualTypeOf< - DecoratedProcedure< - { auth: boolean }, - undefined, - typeof inputSchema, - typeof outputSchema, - string - > - >() - }) -}) - -it('prefix method', () => { - const p = os.context<{ auth: boolean }>().func(() => { - return 'unnoq' +describe('isProcedure', () => { + const procedure = new Procedure({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + }), + func: () => {}, }) - const p2 = p.prefix('/test') - - expect(p2.zz$p.contract['~orpc'].route?.path).toBe(undefined) - - const p3 = os - .context<{ auth: boolean }>() - .route({ path: '/test1' }) - .func(() => { - return 'unnoq' - }) - - const p4 = p3.prefix('/test') - expect(p4.zz$p.contract['~orpc'].route?.path).toBe('/test/test1') -}) - -describe('use middleware', () => { - it('infer types', () => { - const p1 = os - .context<{ auth: boolean }>() - .use((_, __, meta) => { - return meta.next({ context: { postId: 'string' } }) - }) - .func(() => { - return 'unnoq' - }) - - const p2 = p1 - .use((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf< - { auth: boolean } & { postId: string } - >() - expectTypeOf(meta).toEqualTypeOf>() - - return meta.next({ - context: { - userId: '1', - }, - }) - }) - .use((input, context, meta) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf< - { userId: string } & { postId: string } & { auth: boolean } - >() - expectTypeOf(meta).toEqualTypeOf>() - - return meta.next({}) - }) - - expectTypeOf(p2).toEqualTypeOf< - DecoratedProcedure< - { auth: boolean }, - { postId: string } & { userId: string }, - undefined, - undefined, - string - > - >() + it('works', () => { + expect(procedure).toSatisfy(isProcedure) + expect({}).not.toSatisfy(isProcedure) + expect(true).not.toSatisfy(isProcedure) }) - it('can map input', () => { - const mid = os.middleware((input: { id: number }, __, meta) => { - return meta.next({}) - }) - - os.input(z.object({ postId: z.number() })).use(mid, (input) => { - expectTypeOf(input).toEqualTypeOf<{ postId: number }>() - - return { - id: input.postId, - } - }) - - // @ts-expect-error mismatch input - os.input(z.object({ postId: z.number() })).use(mid) - - // @ts-expect-error mismatch input - os.input(z.object({ postId: z.number() })).use(mid, (input) => { - return { - wrong: input.postId, - } - }) - }) - - it('add middlewares to beginning', () => { - const mid1 = vi.fn() - const mid2 = vi.fn() - const mid3 = vi.fn() - - const p1 = os.use(mid1).func(() => 'unnoq') - const p2 = p1.use(mid2).use(mid3) - - expect(p2.zz$p.middlewares).toEqual([mid3, mid2, mid1]) - }) -}) - -describe('server action', () => { - it('only accept undefined context', () => { - expectTypeOf(os.func(() => {})).toMatchTypeOf<(...args: any[]) => any>() - expectTypeOf( - os.context<{ auth: boolean } | undefined>().func(() => {}), - ).toMatchTypeOf<(...args: any[]) => any>() - expectTypeOf( - os.context<{ auth: boolean }>().func(() => {}), - ).not.toMatchTypeOf<(...args: any[]) => any>() - }) - - it('infer types', () => { - const p = os - .input(z.object({ id: z.number() })) - .output(z.string()) - .func(() => 'string') - - expectTypeOf(p).toMatchTypeOf< - (input: { id: number }) => Promise - >() - - const p2 = os.input(z.object({ id: z.number() })).func(() => 12333) - - expectTypeOf(p2).toMatchTypeOf< - (input: { id: number }) => Promise - >() - }) - - it('works with input', async () => { - const p = os - .input(z.object({ id: z.number(), date: z.date() })) - .func(async (input, context) => { - expect(context).toBe(undefined) - return input - }) - - expect(await p({ id: 123, date: new Date('2022-01-01') })).toEqual({ - id: 123, - date: new Date('2022-01-01'), - }) + it('works with raw object', () => { + expect(Object.assign({}, procedure)).toSatisfy(isProcedure) }) }) diff --git a/packages/server/src/procedure.ts b/packages/server/src/procedure.ts index 12061b0af..00aa7b6b4 100644 --- a/packages/server/src/procedure.ts +++ b/packages/server/src/procedure.ts @@ -1,234 +1,70 @@ import type { Promisable } from '@orpc/shared' import type { Lazy } from './lazy' -import type { ProcedureCaller } from './procedure-caller' +import type { Middleware } from './middleware' import type { Context, MergeContext, Meta } from './types' -import { - type ContractProcedure, - DecoratedContractProcedure, - type HTTPPath, - isContractProcedure, - type RouteOptions, - type Schema, - type SchemaInput, - type SchemaOutput, -} from '@orpc/contract' -import { - decorateMiddleware, - type MapInputMiddleware, - type Middleware, -} from './middleware' -import { createProcedureCaller } from './procedure-caller' +import { type ContractProcedure, isContractProcedure, type Schema, type SchemaInput, type SchemaOutput } from '@orpc/contract' -export class Procedure< +export interface ProcedureFunc< TContext extends Context, TExtraContext extends Context, TInputSchema extends Schema, TOutputSchema extends Schema, - TFuncOutput extends SchemaOutput, + TFuncOutput extends SchemaInput, > { - constructor( - public zz$p: { - middlewares?: Middleware[] - contract: ContractProcedure - func: ProcedureFunc< - TContext, - TExtraContext, - TInputSchema, - TOutputSchema, - TFuncOutput - > - }, - ) {} + ( + input: SchemaOutput, + context: MergeContext, + meta: Meta, + ): Promisable> } -export type ANY_PROCEDURE = Procedure -export type WELL_DEFINED_PROCEDURE = Procedure -export type ANY_LAZY_PROCEDURE = Lazy - -export type DecoratedProcedure< - TContext extends Context, - TExtraContext extends Context, - TInputSchema extends Schema, - TOutputSchema extends Schema, - TFuncOutput extends SchemaOutput, -> = Procedure< - TContext, - TExtraContext, - TInputSchema, - TOutputSchema, - TFuncOutput -> & { - prefix: ( - prefix: HTTPPath, - ) => DecoratedProcedure< - TContext, - TExtraContext, - TInputSchema, - TOutputSchema, - TFuncOutput - > - - route: ( - opts: RouteOptions, - ) => DecoratedProcedure< - TContext, - TExtraContext, - TInputSchema, - TOutputSchema, - TFuncOutput - > - - use: (< - UExtraContext extends - | Partial>> - | undefined = undefined, - >( - middleware: Middleware< - MergeContext, - UExtraContext, - SchemaOutput, - SchemaInput - >, - ) => DecoratedProcedure< - TContext, - MergeContext, - TInputSchema, - TOutputSchema, - TFuncOutput - >) & (< - UExtraContext extends - | Partial>> - | undefined = undefined, - UMappedInput = unknown, - >( - middleware: Middleware< - MergeContext, - UExtraContext, - UMappedInput, - SchemaInput - >, - mapInput: MapInputMiddleware< - SchemaOutput, - UMappedInput - >, - ) => DecoratedProcedure< - TContext, - MergeContext, - TInputSchema, - TOutputSchema, - TFuncOutput - >) -} & (undefined extends TContext - ? ProcedureCaller> - : unknown) - -export interface ProcedureFunc< +export interface ProcedureDef< TContext extends Context, TExtraContext extends Context, TInputSchema extends Schema, TOutputSchema extends Schema, - TOutput extends SchemaOutput, + TFuncOutput extends SchemaInput, > { - ( - input: SchemaOutput, - context: MergeContext, - meta: Meta, - ): Promisable> + middlewares?: Middleware, Partial | undefined, SchemaOutput, any>[] + contract: ContractProcedure + func: ProcedureFunc } -const DECORATED_PROCEDURE_SYMBOL = Symbol('DECORATED_PROCEDURE') - -export function decorateProcedure< +export class Procedure< TContext extends Context, TExtraContext extends Context, TInputSchema extends Schema, TOutputSchema extends Schema, - TFuncOutput extends SchemaOutput, ->( - procedure: Procedure< - TContext, - TExtraContext, - TInputSchema, - TOutputSchema, - TFuncOutput - >, -): DecoratedProcedure< - TContext, - TExtraContext, - TInputSchema, - TOutputSchema, - TFuncOutput - > { - if (DECORATED_PROCEDURE_SYMBOL in procedure) { - return procedure as any - } - - return Object.assign(createProcedureCaller({ - procedure: procedure as any, - context: undefined as any, - }), { - [DECORATED_PROCEDURE_SYMBOL]: true, - zz$p: procedure.zz$p, - - prefix(prefix: HTTPPath) { - return decorateProcedure({ - zz$p: { - ...procedure.zz$p, - contract: DecoratedContractProcedure.decorate( - procedure.zz$p.contract, - ).prefix(prefix), - }, - }) - }, - - route(opts: RouteOptions) { - return decorateProcedure({ - zz$p: { - ...procedure.zz$p, - contract: DecoratedContractProcedure.decorate( - procedure.zz$p.contract, - ).route(opts), - }, - }) - }, - - use( - middleware: Middleware, - mapInput?: MapInputMiddleware, - ) { - const middleware_ = mapInput - ? decorateMiddleware(middleware).mapInput(mapInput) - : middleware + TFuncOutput extends SchemaInput, +> { + '~type' = 'Procedure' as const + '~orpc': ProcedureDef - return decorateProcedure({ - zz$p: { - ...procedure.zz$p, - middlewares: [middleware_, ...(procedure.zz$p.middlewares ?? [])], - }, - }) - }, - }) as any + constructor(def: ProcedureDef) { + this['~orpc'] = def + } } +export type ANY_PROCEDURE = Procedure +export type WELL_PROCEDURE = Procedure +export type ANY_LAZY_PROCEDURE = Lazy + export function isProcedure(item: unknown): item is ANY_PROCEDURE { - if (item instanceof Procedure) + if (item instanceof Procedure) { return true + } return ( (typeof item === 'object' || typeof item === 'function') && item !== null - && 'zz$p' in item - && typeof item.zz$p === 'object' - && item.zz$p !== null - && 'contract' in item.zz$p - && isContractProcedure(item.zz$p.contract) - && 'func' in item.zz$p - && typeof item.zz$p.func === 'function' + && '~type' in item + && item['~type'] === 'Procedure' + && '~orpc' in item + && typeof item['~orpc'] === 'object' + && item['~orpc'] !== null + && 'contract' in item['~orpc'] + && isContractProcedure(item['~orpc'].contract) + && 'func' in item['~orpc'] + && typeof item['~orpc'].func === 'function' ) } diff --git a/packages/server/src/router-builder.test-d.ts b/packages/server/src/router-builder.test-d.ts new file mode 100644 index 000000000..5d73d8aef --- /dev/null +++ b/packages/server/src/router-builder.test-d.ts @@ -0,0 +1,244 @@ +import type { Lazy } from './lazy' +import type { DecoratedLazy } from './lazy-decorated' +import type { Middleware } from './middleware' +import type { Procedure } from './procedure' +import type { DecoratedProcedure } from './procedure-decorated' +import type { AdaptedRouter, RouterBuilder } from './router-builder' +import type { WELL_CONTEXT } from './types' +import { z } from 'zod' +import { lazy } from './lazy' + +const builder = {} as RouterBuilder<{ auth: boolean }, { db: string }> + +describe('AdaptedRouter', () => { + const ping = {} as Procedure<{ auth: boolean }, { db: string }, undefined, undefined, unknown> + const pong = {} as Procedure + + it('without lazy', () => { + const router = { + ping, + pong, + nested: { + ping, + pong, + }, + } + const adapted = {} as AdaptedRouter<{ log: true, auth: boolean }, typeof router> + + expectTypeOf(adapted.ping).toEqualTypeOf< + DecoratedProcedure<{ log: true, auth: boolean }, { db: string }, undefined, undefined, unknown> + >() + expectTypeOf(adapted.pong).toEqualTypeOf< + DecoratedProcedure<{ log: true, auth: boolean }, undefined, undefined, undefined, unknown> + >() + expectTypeOf(adapted.nested.ping).toEqualTypeOf< + DecoratedProcedure<{ log: true, auth: boolean }, { db: string }, undefined, undefined, unknown> + >() + expectTypeOf(adapted.nested.pong).toEqualTypeOf< + DecoratedProcedure<{ log: true, auth: boolean }, undefined, undefined, undefined, unknown> + >() + }) + + it('with lazy', () => { + const router = { + ping: lazy(() => Promise.resolve({ default: ping })), + pong, + nested: lazy(() => Promise.resolve({ + default: { + ping, + pong: lazy(() => Promise.resolve({ default: pong })), + }, + })), + } + + const adapted = {} as AdaptedRouter<{ log: true } | undefined, typeof router> + + expectTypeOf(adapted.ping).toEqualTypeOf + >>() + expectTypeOf(adapted.pong).toEqualTypeOf< + DecoratedProcedure<{ log: true } | undefined, undefined, undefined, undefined, unknown> + >() + expectTypeOf(adapted.nested.ping).toEqualTypeOf + >>() + expectTypeOf(adapted.nested.pong).toEqualTypeOf + >>() + }) + + it('with procedure', () => { + expectTypeOf>().toEqualTypeOf< + DecoratedProcedure<{ log: boolean }, { db: string }, undefined, undefined, unknown> + >() + + expectTypeOf < AdaptedRouter<{ log: boolean }, Lazy>>().toEqualTypeOf< + DecoratedLazy> + >() + }) +}) + +describe('self chainable', () => { + it('prefix', () => { + expectTypeOf(builder.prefix('/test')).toEqualTypeOf() + + // @ts-expect-error - invalid prefix + builder.prefix('') + // @ts-expect-error - invalid prefix + builder.prefix(1) + }) + + it('tag', () => { + expectTypeOf(builder.tag('test')).toEqualTypeOf() + expectTypeOf(builder.tag('test', 'test2', 'test3')).toEqualTypeOf() + + // @ts-expect-error - invalid tag + builder.tag(1) + // @ts-expect-error - invalid tag + builder.tag('123', 2) + }) + + it('use middleware', () => { + const mid1 = {} as Middleware<{ auth: boolean }, undefined, unknown, unknown> + const mid2 = {} as Middleware<{ auth: boolean }, { dev: string }, unknown, unknown> + const mid3 = {} as Middleware<{ auth: boolean, db: string }, { dev: string }, unknown, unknown> + + expectTypeOf(builder.use(mid1)).toEqualTypeOf() + expectTypeOf(builder.use(mid2)).toEqualTypeOf< + RouterBuilder<{ auth: boolean }, { db: string } & { dev: string }> + >() + expectTypeOf(builder.use(mid3)).toEqualTypeOf< + RouterBuilder<{ auth: boolean }, { db: string } & { dev: string }> + >() + + const mid4 = {} as Middleware<{ auth: boolean }, { dev: string }, unknown, { val: string }> + const mid5 = {} as Middleware<{ auth: boolean }, { dev: string }, unknown, { val: number }> + const mid6 = {} as Middleware<{ auth: 'invalid' }, undefined, any, unknown> + + // @ts-expect-error - invalid middleware + builder.use(mid4) + // @ts-expect-error - invalid middleware + builder.use(mid5) + // @ts-expect-error - invalid middleware + builder.use(mid6) + // @ts-expect-error - invalid middleware + builder.use(true) + // @ts-expect-error - invalid middleware + builder.use(() => {}) + }) +}) + +describe('to AdaptedRouter', () => { + const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + const ping = {} as Procedure<{ auth: boolean }, { db: string }, typeof schema, typeof schema, { val: string }> + const pong = {} as Procedure + + const wrongPing = {} as Procedure<{ auth: 'invalid' }, undefined, undefined, undefined, unknown> + + it('router without lazy', () => { + expectTypeOf(builder.router({ ping, pong, nested: { ping, pong } })).toEqualTypeOf< + AdaptedRouter< + { auth: boolean }, + { + ping: typeof ping + pong: typeof pong + nested: { ping: typeof ping, pong: typeof pong } + } + > + >() + + builder.router({ ping }) + // @ts-expect-error - context is not match + builder.router({ wrongPing }) + }) + + it('router with lazy', () => { + expectTypeOf(builder.router({ + ping: lazy(() => Promise.resolve({ default: ping })), + pong, + nested: lazy(() => Promise.resolve({ + default: { + ping, + pong: lazy(() => Promise.resolve({ default: pong })), + }, + })), + })).toEqualTypeOf< + AdaptedRouter< + { auth: boolean }, + { + ping: Lazy + pong: typeof pong + nested: Lazy<{ ping: typeof ping, pong: Lazy }> + } + > + >() + + builder.router({ ping: lazy(() => Promise.resolve({ default: ping })) }) + // @ts-expect-error - context is not match + builder.router({ wrongPing: lazy(() => Promise.resolve({ default: wrongPing })) }) + }) + + it('procedure as a router', () => { + expectTypeOf(builder.router(ping)).toEqualTypeOf< + AdaptedRouter< + { auth: boolean }, + typeof ping + > + >() + + expectTypeOf(builder.router(lazy(() => Promise.resolve({ default: ping })))).toEqualTypeOf< + AdaptedRouter< + { auth: boolean }, + Lazy + > + >() + }) +}) + +describe('to Decorated Adapted Lazy', () => { + const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + const ping = {} as Procedure<{ auth: boolean }, { db: string }, typeof schema, typeof schema, { val: string }> + const pong = {} as Procedure + + const wrongPing = {} as Procedure<{ auth: 'invalid' }, undefined, undefined, undefined, unknown> + + it('router without lazy', () => { + const router = { + ping, + pong, + nested: { + ping, + pong, + }, + } + + expectTypeOf(builder.lazy(() => Promise.resolve({ default: router }))).toEqualTypeOf< + DecoratedLazy> + >() + + builder.lazy(() => Promise.resolve({ default: { ping } })) + // @ts-expect-error - context is not match + builder.lazy(() => Promise.resolve({ default: { wrongPing } })) + }) + + it('router with lazy', () => { + const router = { + ping: lazy(() => Promise.resolve({ default: ping })), + pong, + nested: lazy(() => Promise.resolve({ + default: { + ping, + pong: lazy(() => Promise.resolve({ default: pong })), + }, + })), + } + + expectTypeOf(builder.lazy(() => Promise.resolve({ default: router }))).toEqualTypeOf< + DecoratedLazy> + >() + + builder.lazy(() => Promise.resolve({ default: { ping: lazy(() => Promise.resolve({ default: ping })) } })) + // @ts-expect-error - context is not match + builder.lazy(() => Promise.resolve({ default: { wrongPing: lazy(() => Promise.resolve({ default: wrongPing })) } })) + }) +}) diff --git a/packages/server/src/router-builder.test.ts b/packages/server/src/router-builder.test.ts index 13c89417c..9aa117154 100644 --- a/packages/server/src/router-builder.test.ts +++ b/packages/server/src/router-builder.test.ts @@ -1,154 +1,298 @@ -import type { DecoratedLazy } from './lazy' import { ContractProcedure } from '@orpc/contract' import { z } from 'zod' -import { decorateProcedure, isProcedure, os, Procedure } from '.' -import { createLazy, isLazy, LAZY_LOADER_SYMBOL } from './lazy' -import { LAZY_ROUTER_PREFIX_SYMBOL, RouterBuilder } from './router-builder' - -const builder = new RouterBuilder({}) -const ping = os - .route({ method: 'GET', path: '/ping', tags: ['ping'] }) - .func(() => 'ping') -const pong = os - .output(z.object({ id: z.string() })) - .func(() => ({ id: '123' })) - -const lazy = os.lazy(() => Promise.resolve({ - default: os.route({ - method: 'GET', - path: '/lazy', - tags: ['lazy'], - }).func(() => 'lazy'), -})) - -const lazyRouter = os.lazy(() => Promise.resolve({ - default: { - lazy, - lazyRouter: os.lazy(() => Promise.resolve({ default: { lazy } })), - }, -})) - -describe('prefix', () => { - it('chainable prefix', () => { - expect(builder.prefix('/1').prefix('/2').prefix('/3').zz$rb.prefix).toEqual( - '/1/2/3', - ) - }) - - it('router', async () => { - const router = builder - .prefix('/api') - .prefix('/users') - .router({ ping, pong, lazy: builder.lazy(() => Promise.resolve({ default: { - ping, - lazy: os.route({ method: 'GET', path: '/lazy' }).func(() => 'lazy'), - } })) }) - - expect(router.ping.zz$p.contract['~orpc'].route?.path).toEqual('/api/users/ping') - expect(router.pong.zz$p.contract['~orpc'].route?.path).toEqual(undefined) - expect((await router.lazy.lazy[LAZY_LOADER_SYMBOL]()).default.zz$p.contract['~orpc'].route?.path).toEqual('/api/users/lazy') - expect((await (router.lazy as any)[LAZY_LOADER_SYMBOL]()).default.lazy.zz$p.contract['~orpc'].route?.path).toEqual('/api/users/lazy') - expect((router.lazy as any)[LAZY_ROUTER_PREFIX_SYMBOL]).toEqual('/api/users') - expect((router.lazy.lazy as any)[LAZY_ROUTER_PREFIX_SYMBOL]).toEqual('/api/users') - }) +import { getLazyRouterPrefix } from './hidden' +import { isLazy, lazy, unlazy } from './lazy' +import { isProcedure, Procedure } from './procedure' +import { RouterBuilder } from './router-builder' + +const mid1 = vi.fn() +const mid2 = vi.fn() + +const builder = new RouterBuilder<{ auth: boolean }, { db: string }>({ + middlewares: [mid1, mid2], + prefix: '/prefix', + tags: ['tag1', 'tag2'], }) -describe('tags', () => { - it('chainable tags', () => { - expect(builder.tags('1', '2').tags('3').tags('4').zz$rb.tags).toEqual([ - '1', - '2', - '3', - '4', - ]) - }) - - it('router', async () => { - const router = builder - .tags('api') - .tags('users') - .router({ ping, pong, lazy, lazyRouter }) - - expect(router.ping.zz$p.contract['~orpc'].route?.tags).toEqual([ - 'ping', - 'api', - 'users', - ]) - expect(router.pong.zz$p.contract['~orpc'].route?.tags).toEqual(['api', 'users']) - - expect((await (router.lazy[LAZY_LOADER_SYMBOL]())).default.zz$p.contract['~orpc'].route?.tags).toEqual([ - 'lazy', - 'api', - 'users', - ]) - expect((await (router.lazyRouter.lazy[LAZY_LOADER_SYMBOL]())).default.zz$p.contract['~orpc'].route?.tags).toEqual([ - 'lazy', - 'api', - 'users', - ]) - expect((await (router.lazyRouter.lazyRouter.lazy[LAZY_LOADER_SYMBOL]())).default.zz$p.contract['~orpc'].route?.tags).toEqual([ - 'lazy', - 'api', - 'users', - ]) +it('prevent dynamic params on prefix', () => { + expect(() => builder.prefix('/{id}')).toThrowError() + expect(() => new RouterBuilder({ prefix: '/{id}' })).toThrowError() +}) + +describe('self chainable', () => { + it('prefix', () => { + const prefixed = builder.prefix('/test') + expect(prefixed).not.toBe(builder) + expect(prefixed).toBeInstanceOf(RouterBuilder) + expect(prefixed['~orpc'].prefix).toBe('/prefix/test') + }) + + it('prefix --- still work without pre prefix', () => { + const builder = new RouterBuilder({}) + + const prefixed = builder.prefix('/test') + expect(prefixed).not.toBe(builder) + expect(prefixed).toBeInstanceOf(RouterBuilder) + expect(prefixed['~orpc'].prefix).toBe('/test') + }) + + it('tag', () => { + const tagged = builder.tag('test1', 'test2') + expect(tagged).not.toBe(builder) + expect(tagged).toBeInstanceOf(RouterBuilder) + expect(tagged['~orpc'].tags).toEqual(['tag1', 'tag2', 'test1', 'test2']) + }) + + it('tag --- still work without pre tag', () => { + const builder = new RouterBuilder({}) + + const tagged = builder.tag('test1', 'test2') + expect(tagged).not.toBe(builder) + expect(tagged).toBeInstanceOf(RouterBuilder) + expect(tagged['~orpc'].tags).toEqual(['test1', 'test2']) + }) + + it('use middleware', () => { + const mid3 = vi.fn() + const mid4 = vi.fn() + + const applied = builder.use(mid3).use(mid4) + expect(applied).not.toBe(builder) + expect(applied).toBeInstanceOf(RouterBuilder) + expect(applied['~orpc'].middlewares).toEqual([mid1, mid2, mid3, mid4]) + }) + + it('use middleware --- still work without pre middleware', () => { + const builder = new RouterBuilder({}) + + const applied = builder.use(mid1).use(mid2) + expect(applied).not.toBe(builder) + expect(applied).toBeInstanceOf(RouterBuilder) + expect(applied['~orpc'].middlewares).toEqual([mid1, mid2]) }) }) -describe('middleware', () => { - const mid1 = vi.fn() - const mid2 = vi.fn() - const mid3 = vi.fn() - - it('chainable middleware', () => { - expect(builder.use(mid1).use(mid2).use(mid3).zz$rb.middlewares).toEqual([ - mid1, - mid2, - mid3, - ]) - }) - - it('router', async () => { - const router = builder.use(mid1).use(mid2).router({ ping, pong, lazy, lazyRouter }) - - expect(router.ping.zz$p.middlewares).toEqual([mid1, mid2]) - expect(router.pong.zz$p.middlewares).toEqual([mid1, mid2]) - - expect((await (router.lazy[LAZY_LOADER_SYMBOL]())).default.zz$p.middlewares).toEqual([mid1, mid2]) - expect((await (router.lazyRouter.lazy[LAZY_LOADER_SYMBOL]())).default.zz$p.middlewares).toEqual([mid1, mid2]) - expect((await (router.lazyRouter.lazyRouter.lazy[LAZY_LOADER_SYMBOL]())).default.zz$p.middlewares).toEqual([mid1, mid2]) - }) - - it('decorate items', () => { - const ping = new Procedure({ - contract: new ContractProcedure({ - InputSchema: undefined, - OutputSchema: undefined, - }), - func: () => { }, - }) - - const decorated = decorateProcedure({ - zz$p: { - contract: new ContractProcedure({ - InputSchema: undefined, - OutputSchema: undefined, - }), - func: () => { }, +describe('adapt router', () => { + const pMid1 = vi.fn() + const pMid2 = vi.fn() + + const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + const ping = new Procedure({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: undefined, + route: { + tags: ['tag3', 'tag4'], + }, + }), + func: vi.fn(), + middlewares: [mid1, pMid1, pMid2], + }) + const pong = new Procedure({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: schema, + route: { + method: 'GET', + path: '/pong', + description: 'desc', }, - }) + }), + func: vi.fn(), + }) + + const router = { + ping, + pong, + nested: { + ping, + pong, + }, + } + + const routerWithLazy = { + ping: lazy(() => Promise.resolve({ default: ping })), + pong, + nested: lazy(() => Promise.resolve({ default: { + ping, + pong: lazy(() => Promise.resolve({ default: pong })), + } })), + } + + it('router without lazy', () => { + const adapted = builder.router(router) + + expect(adapted.ping).toSatisfy(isProcedure) + expect(typeof adapted.ping).toBe('function') + expect(adapted.ping['~orpc'].func).toBe(ping['~orpc'].func) + expect(adapted.ping['~orpc'].middlewares).toEqual([mid1, mid2, pMid1, pMid2]) + expect(adapted.ping['~orpc'].contract['~orpc'].route?.path).toBe(undefined) + expect(adapted.ping['~orpc'].contract['~orpc'].route?.method).toBe(undefined) + expect(adapted.ping['~orpc'].contract['~orpc'].route?.tags).toEqual(['tag1', 'tag2', 'tag3', 'tag4']) + + expect(adapted.pong).toSatisfy(isProcedure) + expect(typeof adapted.pong).toBe('function') + expect(adapted.pong['~orpc'].func).toBe(pong['~orpc'].func) + expect(adapted.pong['~orpc'].middlewares).toEqual([mid1, mid2]) + expect(adapted.pong['~orpc'].contract['~orpc'].route?.path).toBe('/prefix/pong') + expect(adapted.pong['~orpc'].contract['~orpc'].route?.method).toBe('GET') + expect(adapted.pong['~orpc'].contract['~orpc'].route?.tags).toEqual(['tag1', 'tag2']) - const lazy = createLazy(() => Promise.resolve({ default: ping })) + expect(adapted.nested.ping).toSatisfy(isProcedure) + expect(typeof adapted.nested.ping).toBe('function') + expect(adapted.nested.ping['~orpc'].func).toBe(ping['~orpc'].func) + expect(adapted.nested.ping['~orpc'].middlewares).toEqual([mid1, mid2, pMid1, pMid2]) + expect(adapted.nested.ping['~orpc'].contract['~orpc'].route?.path).toBe(undefined) + expect(adapted.nested.ping['~orpc'].contract['~orpc'].route?.method).toBe(undefined) + expect(adapted.nested.ping['~orpc'].contract['~orpc'].route?.tags).toEqual(['tag1', 'tag2', 'tag3', 'tag4']) - const router = builder.router({ ping, nested: { ping }, lazy }) + expect(adapted.nested.pong).toSatisfy(isProcedure) + expect(typeof adapted.nested.pong).toBe('function') + expect(adapted.nested.pong['~orpc'].func).toBe(pong['~orpc'].func) + expect(adapted.nested.pong['~orpc'].middlewares).toEqual([mid1, mid2]) + expect(adapted.nested.pong['~orpc'].contract['~orpc'].route?.path).toBe('/prefix/pong') + expect(adapted.nested.pong['~orpc'].contract['~orpc'].route?.method).toBe('GET') + expect(adapted.nested.pong['~orpc'].contract['~orpc'].route?.tags).toEqual(['tag1', 'tag2']) + }) + + it('router with lazy', async () => { + const adapted = builder.router(routerWithLazy) as any + + expect(getLazyRouterPrefix(adapted.ping)).toBe('/prefix') + expect(getLazyRouterPrefix(adapted.pong)).toBe(undefined) + expect(getLazyRouterPrefix(adapted.nested)).toBe('/prefix') + expect(getLazyRouterPrefix(adapted.nested.ping)).toBe('/prefix') + expect(getLazyRouterPrefix(adapted.nested.pong)).toBe('/prefix') + + expect(adapted.ping).toSatisfy(isLazy) + expect(typeof adapted.ping).toBe('function') + expect((await unlazy(adapted.ping) as any).default).toSatisfy(isProcedure) + expect((await unlazy(adapted.ping) as any).default['~orpc'].func).toBe(ping['~orpc'].func) + expect((await unlazy(adapted.ping) as any).default['~orpc'].middlewares).toEqual([mid1, mid2, pMid1, pMid2]) + expect((await unlazy(adapted.ping) as any).default['~orpc'].contract['~orpc'].route?.path).toBe(undefined) + expect((await unlazy(adapted.ping) as any).default['~orpc'].contract['~orpc'].route?.method).toBe(undefined) + expect((await unlazy(adapted.ping) as any).default['~orpc'].contract['~orpc'].route?.tags).toEqual(['tag1', 'tag2', 'tag3', 'tag4']) + + expect(adapted.pong).toSatisfy(isProcedure) + expect(typeof adapted.pong).toBe('function') + expect(adapted.pong['~orpc'].func).toBe(pong['~orpc'].func) + expect(adapted.pong['~orpc'].middlewares).toEqual([mid1, mid2]) + expect(adapted.pong['~orpc'].contract['~orpc'].route?.path).toBe('/prefix/pong') + expect(adapted.pong['~orpc'].contract['~orpc'].route?.method).toBe('GET') + expect(adapted.pong['~orpc'].contract['~orpc'].route?.tags).toEqual(['tag1', 'tag2']) + + expect(adapted.nested.ping).toSatisfy(isLazy) + expect(typeof adapted.nested.ping).toBe('function') + expect((await unlazy(adapted.nested.ping) as any).default).toSatisfy(isProcedure) + expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].func).toBe(ping['~orpc'].func) + expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].middlewares).toEqual([mid1, mid2, pMid1, pMid2]) + expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].contract['~orpc'].route?.path).toBe(undefined) + expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].contract['~orpc'].route?.method).toBe(undefined) + expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].contract['~orpc'].route?.tags).toEqual(['tag1', 'tag2', 'tag3', 'tag4']) + + expect(adapted.nested.pong).toSatisfy(isLazy) + expect(typeof adapted.nested.pong).toBe('function') + expect((await unlazy(adapted.nested.pong) as any).default).toSatisfy(isProcedure) + expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].func).toBe(pong['~orpc'].func) + expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].middlewares).toEqual([mid1, mid2]) + expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].contract['~orpc'].route?.path).toBe('/prefix/pong') + expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].contract['~orpc'].route?.method).toBe('GET') + expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].contract['~orpc'].route?.tags).toEqual(['tag1', 'tag2']) + }) + + it('router lazy with nested lazy', async () => { + const adapted = builder.lazy(() => Promise.resolve({ default: routerWithLazy })) as any + + expect(getLazyRouterPrefix(adapted.ping)).toBe('/prefix') + expect(getLazyRouterPrefix(adapted.pong)).toBe('/prefix') + expect(getLazyRouterPrefix(adapted.nested)).toBe('/prefix') + expect(getLazyRouterPrefix(adapted.nested.ping)).toBe('/prefix') + expect(getLazyRouterPrefix(adapted.nested.pong)).toBe('/prefix') + + expect(adapted.ping).toSatisfy(isLazy) + expect(typeof adapted.ping).toBe('function') + expect((await unlazy(adapted.ping) as any).default).toSatisfy(isProcedure) + expect((await unlazy(adapted.ping) as any).default['~orpc'].func).toBe(ping['~orpc'].func) + expect((await unlazy(adapted.ping) as any).default['~orpc'].middlewares).toEqual([mid1, mid2, pMid1, pMid2]) + expect((await unlazy(adapted.ping) as any).default['~orpc'].contract['~orpc'].route?.path).toBe(undefined) + expect((await unlazy(adapted.ping) as any).default['~orpc'].contract['~orpc'].route?.method).toBe(undefined) + expect((await unlazy(adapted.ping) as any).default['~orpc'].contract['~orpc'].route?.tags).toEqual(['tag1', 'tag2', 'tag3', 'tag4']) + + expect(adapted.pong).toSatisfy(isLazy) + expect(typeof adapted.pong).toBe('function') + expect((await unlazy(adapted.pong) as any).default).toSatisfy(isProcedure) + expect((await unlazy(adapted.pong) as any).default['~orpc'].func).toBe(pong['~orpc'].func) + expect((await unlazy(adapted.pong) as any).default['~orpc'].middlewares).toEqual([mid1, mid2]) + expect((await unlazy(adapted.pong) as any).default['~orpc'].contract['~orpc'].route?.path).toBe('/prefix/pong') + expect((await unlazy(adapted.pong) as any).default['~orpc'].contract['~orpc'].route?.method).toBe('GET') + expect((await unlazy(adapted.pong) as any).default['~orpc'].contract['~orpc'].route?.tags).toEqual(['tag1', 'tag2']) + + expect(adapted.nested.ping).toSatisfy(isLazy) + expect(typeof adapted.nested.ping).toBe('function') + expect((await unlazy(adapted.nested.ping) as any).default).toSatisfy(isProcedure) + expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].func).toBe(ping['~orpc'].func) + expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].middlewares).toEqual([mid1, mid2, pMid1, pMid2]) + expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].contract['~orpc'].route?.path).toBe(undefined) + expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].contract['~orpc'].route?.method).toBe(undefined) + expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].contract['~orpc'].route?.tags).toEqual(['tag1', 'tag2', 'tag3', 'tag4']) + + expect(adapted.nested.pong).toSatisfy(isLazy) + expect(typeof adapted.nested.pong).toBe('function') + expect((await unlazy(adapted.nested.pong) as any).default).toSatisfy(isProcedure) + expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].func).toBe(pong['~orpc'].func) + expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].middlewares).toEqual([mid1, mid2]) + expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].contract['~orpc'].route?.path).toBe('/prefix/pong') + expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].contract['~orpc'].route?.method).toBe('GET') + expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].contract['~orpc'].route?.tags).toEqual(['tag1', 'tag2']) + }) + + it('support procedure as a router', async () => { + const adapted = builder.router(ping) + + expect(adapted).toSatisfy(isProcedure) + expect(typeof adapted).toBe('function') + expect(adapted['~orpc'].func).toBe(ping['~orpc'].func) + expect(adapted['~orpc'].middlewares).toEqual([mid1, mid2, pMid1, pMid2]) + expect(adapted['~orpc'].contract['~orpc'].route?.path).toBe(undefined) + expect(adapted['~orpc'].contract['~orpc'].route?.method).toBe(undefined) + expect(adapted['~orpc'].contract['~orpc'].route?.tags).toEqual(['tag1', 'tag2', 'tag3', 'tag4']) + + const adaptedLazy = builder.router(lazy(() => Promise.resolve({ default: ping }))) + + expect(adaptedLazy).toSatisfy(isLazy) + expect(typeof adaptedLazy).toBe('function') + expect((await unlazy(adaptedLazy) as any).default).toSatisfy(isProcedure) + expect((await unlazy(adaptedLazy) as any).default['~orpc'].func).toBe(ping['~orpc'].func) + expect((await unlazy(adaptedLazy) as any).default['~orpc'].middlewares).toEqual([mid1, mid2, pMid1, pMid2]) + expect((await unlazy(adaptedLazy) as any).default['~orpc'].contract['~orpc'].route?.path).toBe(undefined) + expect((await unlazy(adaptedLazy) as any).default['~orpc'].contract['~orpc'].route?.method).toBe(undefined) + }) + + it('can concat LAZY_ROUTER_PREFIX_SYMBOL', () => { + const adapted = builder.prefix('/hi').router(builder.router(routerWithLazy)) as any + expect(getLazyRouterPrefix(adapted.ping)).toBe('/prefix/hi/prefix') + }) + + it('works with LAZY_ROUTER_PREFIX_SYMBOL when prefix is not set', () => { + const builderWithoutPrefix = new RouterBuilder({}) + const adapted = builderWithoutPrefix.router(routerWithLazy) as any + expect(getLazyRouterPrefix(adapted.ping)).toBe(undefined) + expect(getLazyRouterPrefix(adapted.pong)).toBe(undefined) + + const adapted2 = builderWithoutPrefix.router(builder.router(routerWithLazy) as any) as any + expect(getLazyRouterPrefix(adapted2.ping)).toBe('/prefix') + expect(getLazyRouterPrefix(adapted2.pong)).toBe(undefined) + }) + + it('getLazyRouterPrefix works', () => { + expect(getLazyRouterPrefix({})).toBe(undefined) + expect(getLazyRouterPrefix(builder.router(routerWithLazy).ping)).toBe('/prefix') + expect(getLazyRouterPrefix(builder.router(routerWithLazy).pong)).toBe(undefined) + }) - expectTypeOf(router).toEqualTypeOf<{ - ping: typeof decorated - nested: { ping: typeof decorated } - lazy: DecoratedLazy - }>() + it('deepSetLazyRouterPrefix not recursive on Symbol', () => { + const adapted = builder.router(routerWithLazy) as any - expect(router.ping).satisfies(isProcedure) - expect(router.nested.ping).satisfies(isProcedure) - expect(router.lazy).satisfies(isLazy) + expect(adapted.nested[Symbol('anything')]).toBe(undefined) }) }) diff --git a/packages/server/src/router-builder.ts b/packages/server/src/router-builder.ts index d5f9f25cf..5f6cfead0 100644 --- a/packages/server/src/router-builder.ts +++ b/packages/server/src/router-builder.ts @@ -1,227 +1,140 @@ -import type { DecoratedLazy, Lazy } from './lazy' -import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE, DecoratedProcedure } from './procedure' -import type { HandledRouter, Router } from './router' +import type { HTTPPath } from '@orpc/contract' +import type { FlattenLazy, Lazy } from './lazy' +import type { ANY_MIDDLEWARE, Middleware } from './middleware' +import type { ANY_PROCEDURE, Procedure } from './procedure' +import type { ANY_ROUTER, Router } from './router' import type { Context, MergeContext } from './types' -import { DecoratedContractProcedure, type HTTPPath } from '@orpc/contract' -import { createLazy, decorateLazy, isLazy, loadLazy } from './lazy' -import { - decorateMiddleware, - type MapInputMiddleware, - type Middleware, -} from './middleware' -import { decorateProcedure, isProcedure } from './procedure' +import { deepSetLazyRouterPrefix, getLazyRouterPrefix } from './hidden' +import { flatLazy, isLazy, lazy, unlazy } from './lazy' +import { type DecoratedLazy, decorateLazy } from './lazy-decorated' +import { isProcedure } from './procedure' +import { type DecoratedProcedure, decorateProcedure } from './procedure-decorated' -export const LAZY_ROUTER_PREFIX_SYMBOL = Symbol('ORPC_LAZY_ROUTER_PREFIX') +export type AdaptedRouter< + TContext extends Context, + TRouter extends ANY_ROUTER, +> = TRouter extends Lazy + ? DecoratedLazy> + : TRouter extends Procedure + ? DecoratedProcedure + : { + [K in keyof TRouter]: TRouter[K] extends ANY_ROUTER ? AdaptedRouter : never + } + +export type RouterBuilderDef = { + prefix?: HTTPPath + tags?: readonly string[] + middlewares?: Middleware, Partial | undefined, unknown, any>[] +} export class RouterBuilder< TContext extends Context, TExtraContext extends Context, > { - constructor( - public zz$rb: { - prefix?: HTTPPath - tags?: string[] - middlewares?: Middleware[] - }, - ) { - if (zz$rb.prefix && zz$rb.prefix.includes('{')) { - throw new Error('Prefix cannot contain "{" for dynamic routing') + '~type' = 'RouterBuilder' as const + '~orpc': RouterBuilderDef + + constructor(def: RouterBuilderDef) { + this['~orpc'] = def + + if (def.prefix && def.prefix.includes('{')) { + throw new Error(` + Dynamic routing in prefix not supported yet. + Please remove "{" from "${def.prefix}". + `) } } prefix(prefix: HTTPPath): RouterBuilder { return new RouterBuilder({ - ...this.zz$rb, - prefix: `${this.zz$rb.prefix ?? ''}${prefix}`, + ...this['~orpc'], + prefix: `${this['~orpc'].prefix ?? ''}${prefix}`, }) } - tags(...tags: string[]): RouterBuilder { - if (!tags.length) - return this - + tag(...tags: string[]): RouterBuilder { return new RouterBuilder({ - ...this.zz$rb, - tags: [...(this.zz$rb.tags ?? []), ...tags], + ...this['~orpc'], + tags: [...(this['~orpc'].tags ?? []), ...tags], }) } - use< - UExtraContext extends - | Partial>> - | undefined = undefined, - >( + use> | undefined = undefined>( middleware: Middleware< MergeContext, - UExtraContext, + U, unknown, unknown >, - ): RouterBuilder> - - use< - UExtraContext extends - | Partial>> - | undefined = undefined, - UMappedInput = unknown, - >( - middleware: Middleware< - MergeContext, - UExtraContext, - UMappedInput, - unknown - >, - mapInput: MapInputMiddleware, - ): RouterBuilder> - - use( - middleware: Middleware, - mapInput?: MapInputMiddleware, - ): RouterBuilder { - const middleware_ = mapInput - ? decorateMiddleware(middleware).mapInput(mapInput) - : middleware - + ): RouterBuilder> { return new RouterBuilder({ - ...this.zz$rb, - middlewares: [...(this.zz$rb.middlewares || []), middleware_], + ...this['~orpc'], + middlewares: [...(this['~orpc'].middlewares ?? []), middleware as any], }) } - router>( - router: URouter, - ): HandledRouter { - const handled = adaptRouter({ - routerOrChild: router, - middlewares: this.zz$rb.middlewares, - tags: this.zz$rb.tags, - prefix: this.zz$rb.prefix, - }) - - return handled as any + router, any>>( + router: U, + ): AdaptedRouter { + const adapted = adapt(router, this['~orpc']) + return adapted as any } - lazy>( + lazy, any>>( loader: () => Promise<{ default: U }>, - ): DecoratedLazy { - const lazy = adaptLazyRouter({ - current: createLazy(loader), - middlewares: this.zz$rb.middlewares, - tags: this.zz$rb.tags, - prefix: this.zz$rb.prefix, - }) - - return lazy as any + ): AdaptedRouter> { + const adapted = adapt(flatLazy(lazy(loader)), this['~orpc']) + return adapted as any } } -function adaptRouter(options: { - routerOrChild: Router | Router[keyof Router] - middlewares?: Middleware[] - tags?: string[] - prefix?: HTTPPath -}) { - if (isProcedure(options.routerOrChild)) { - return adaptProcedure({ - ...options, - procedure: options.routerOrChild, - }) - } +function adapt( + item: ANY_ROUTER, + options: { + middlewares?: ANY_MIDDLEWARE[] + tags?: readonly string[] + prefix?: HTTPPath + }, +): ANY_ROUTER { + if (isLazy(item)) { + const adaptedLazy = decorateLazy(lazy(async () => { + const routerOrProcedure = (await unlazy(item)).default as ANY_ROUTER | ANY_PROCEDURE + const adapted = adapt(routerOrProcedure, options) + + return { default: adapted } + })) + + const lazyPrefix = getLazyRouterPrefix(item) + if (options.prefix || lazyPrefix) { + const prefixed = deepSetLazyRouterPrefix(adaptedLazy, `${options.prefix ?? ''}${lazyPrefix ?? ''}` as any) + return prefixed + } - if (isLazy(options.routerOrChild)) { - return adaptLazyRouter({ - ...options, - current: options.routerOrChild, - }) + return adaptedLazy } - const handled: Record = {} + if (isProcedure(item)) { + let decorated = decorateProcedure(item) - for (const key in options.routerOrChild) { - handled[key] = adaptRouter({ - ...options, - routerOrChild: options.routerOrChild[key]!, - }) - } - - return handled as any -} + if (options.tags?.length) { + decorated = decorated.unshiftTag(...options.tags) + } -function adaptLazyRouter(options: { - current: ANY_LAZY_PROCEDURE | Lazy> - middlewares?: Middleware[] - tags?: string[] - prefix?: HTTPPath -}): DecoratedLazy>> { - const loader = async (): Promise<{ default: unknown }> => { - const current = (await loadLazy(options.current)).default - - return { - default: adaptRouter({ - ...options, - routerOrChild: current, - }), + if (options.prefix) { + decorated = decorated.prefix(options.prefix) } - } - let lazyRouterPrefix = options.prefix + if (options.middlewares?.length) { + decorated = decorated.unshiftMiddleware(...options.middlewares) + } - if (LAZY_ROUTER_PREFIX_SYMBOL in options.current && typeof options.current[LAZY_ROUTER_PREFIX_SYMBOL] === 'string') { - lazyRouterPrefix = `${options.current[LAZY_ROUTER_PREFIX_SYMBOL]}${lazyRouterPrefix ?? ''}` as HTTPPath + return decorated } - const decoratedLazy = Object.assign(decorateLazy(createLazy(loader)), { - [LAZY_ROUTER_PREFIX_SYMBOL]: lazyRouterPrefix, - }) - - const recursive = new Proxy(decoratedLazy, { - get(target, key) { - if (typeof key !== 'string') { - return Reflect.get(target, key) - } - - return adaptLazyRouter({ - ...options, - current: createLazy(async () => { - const current = (await loadLazy(options.current)).default - return { default: current[key] } - }), - }) - }, - }) - - return recursive as any -} - -function adaptProcedure(options: { - procedure: ANY_PROCEDURE - middlewares?: Middleware[] - tags?: string[] - prefix?: HTTPPath -}): DecoratedProcedure { - const builderMiddlewares = options.middlewares ?? [] - const procedureMiddlewares = options.procedure.zz$p.middlewares ?? [] - - const middlewares = [ - ...builderMiddlewares, - ...procedureMiddlewares.filter( - item => !builderMiddlewares.includes(item), - ), - ] - - let contract = DecoratedContractProcedure.decorate( - options.procedure.zz$p.contract, - ).pushTag(...(options.tags ?? [])) - - if (options.prefix) { - contract = contract.prefix(options.prefix) + const adapted = {} as Record + for (const key in item) { + adapted[key] = adapt(item[key]!, options) } - return decorateProcedure({ - zz$p: { - ...options.procedure.zz$p, - contract, - middlewares, - }, - }) + return adapted } diff --git a/packages/server/src/router-caller.test.ts b/packages/server/src/router-caller.test.ts deleted file mode 100644 index 46c4cf36f..000000000 --- a/packages/server/src/router-caller.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { z } from 'zod' -import { createRouterCaller, ORPCError, os } from '.' - -describe('createRouterCaller', () => { - const internal = false - const context = { auth: true } - - const osw = os.context<{ auth?: boolean }>() - - const ping = osw - .input(z.object({ value: z.string().transform(v => Number(v)) })) - .output(z.object({ value: z.number().transform(v => v.toString()) })) - .func((input, context, meta) => { - expect(context).toEqual(context) - - return input - }) - - const pong = osw.func((_, context, meta) => { - expect(context).toEqual(context) - - return { value: true } - }) - - const lazyRouter = osw.lazy(() => Promise.resolve({ - default: { - ping: osw.lazy(() => Promise.resolve({ default: ping })), - pong, - lazyRouter: osw.lazy(() => Promise.resolve({ default: { ping, pong } })), - }, - })) - - const router = osw.router({ - ping, - pong, - nested: { - ping, - pong, - }, - lazyRouter, - }) - - it('infer context', () => { - createRouterCaller({ - router, - // @ts-expect-error invalid context - context: { auth: 123 }, - }) - - createRouterCaller({ - router, - context, - }) - }) - - it('with validate', () => { - const caller = createRouterCaller({ - router, - context, - }) - - expectTypeOf(caller.ping).toMatchTypeOf< - (input: { value: string }) => Promise<{ - value: string - }> - >() - - expectTypeOf(caller.pong).toMatchTypeOf< - (input: unknown) => Promise<{ - value: boolean - }> - >() - - expectTypeOf(caller.nested.ping).toMatchTypeOf< - (input: { value: string }) => Promise<{ - value: string - }> - >() - - expectTypeOf(caller.nested.pong).toMatchTypeOf< - (input: unknown) => Promise<{ - value: boolean - }> - >() - - expectTypeOf(caller.lazyRouter.ping).toMatchTypeOf< - (input: { value: string }) => Promise<{ - value: string - }> - >() - - expectTypeOf(caller.lazyRouter.pong).toMatchTypeOf< - (input: unknown) => Promise<{ - value: boolean - }> - >() - - expectTypeOf(caller.lazyRouter.lazyRouter.ping).toMatchTypeOf< - (input: { value: string }) => Promise<{ - value: string - }> - >() - - expectTypeOf(caller.lazyRouter.lazyRouter.pong).toMatchTypeOf< - (input: unknown) => Promise<{ - value: boolean - }> - >() - - expect(caller.ping({ value: '123' })).resolves.toEqual({ value: '123' }) - expect(caller.pong({ value: '123' })).resolves.toEqual({ value: true }) - - expect(caller.nested.ping({ value: '123' })).resolves.toEqual({ - value: '123', - }) - expect(caller.nested.pong({ value: '123' })).resolves.toEqual({ - value: true, - }) - - expect(caller.lazyRouter.ping({ value: '123' })).resolves.toEqual({ - value: '123', - }) - expect(caller.lazyRouter.pong({ value: '123' })).resolves.toEqual({ - value: true, - }) - - expect(caller.lazyRouter.lazyRouter.ping({ value: '123' })).resolves.toEqual({ - value: '123', - }) - expect(caller.lazyRouter.lazyRouter.pong({ value: '123' })).resolves.toEqual({ - value: true, - }) - - // @ts-expect-error - invalid input - expect(caller.ping({ value: new Date('2023-01-01') })).rejects.toThrowError( - 'Validation input failed', - ) - - // @ts-expect-error - invalid input - expect(caller.nested.ping({ value: true })).rejects.toThrowError( - 'Validation input failed', - ) - - // @ts-expect-error - invalid input - expect(caller.lazyRouter.ping({ value: true })).rejects.toThrowError( - 'Validation input failed', - ) - - // @ts-expect-error - invalid input - expect(caller.lazyRouter.lazyRouter.ping({ value: true })).rejects.toThrowError( - 'Validation input failed', - ) - }) - - it('path', () => { - const ping = osw.func((_, __, { path }) => { - return path - }) - - const lazyRouter = osw.lazy(() => Promise.resolve({ - default: { - ping: osw.lazy(() => Promise.resolve({ default: ping })), - lazyRouter: osw.lazy(() => Promise.resolve({ default: { ping } })), - }, - })) - - const router = osw.router({ - ping, - nested: { - ping, - child: { - ping, - }, - }, - lazyRouter, - }) - - const caller = createRouterCaller({ - router, - context, - }) - - expect(caller.ping('')).resolves.toEqual(['ping']) - expect(caller.nested.ping('')).resolves.toEqual(['nested', 'ping']) - expect(caller.nested.child.ping('')).resolves.toEqual([ - 'nested', - 'child', - 'ping', - ]) - expect(caller.lazyRouter.ping()).resolves.toEqual(['lazyRouter', 'ping']) - expect(caller.lazyRouter.lazyRouter.ping('')).resolves.toEqual([ - 'lazyRouter', - 'lazyRouter', - 'ping', - ]) - }) - - it('hooks', async () => { - const onStart = vi.fn() - const onSuccess = vi.fn() - const onError = vi.fn() - const onFinish = vi.fn() - const onExecute = vi.fn() - - const procedure = os.input(z.string()).func(() => 'output') - - const context = { val: 'context' } - const caller = createRouterCaller({ - router: { procedure, nested: { procedure } }, - context, - execute: async (input, context, meta) => { - onStart(input, context, meta) - onExecute(input, context, meta) - try { - const output = await meta.next() - onSuccess(output, context, meta) - return output - } - catch (e) { - onError(e, context, meta) - throw e - } - }, - onStart, - onSuccess, - onError, - onFinish, - }) - - const meta = { - path: ['procedure'], - procedure, - } - - await caller.procedure('input') - expect(onStart).toBeCalledTimes(2) - expect(onStart).toHaveBeenNthCalledWith(1, 'input', context, { ...meta, next: expect.any(Function) }) - expect(onStart).toHaveBeenNthCalledWith(2, { input: 'input', status: 'pending' }, context, meta) - expect(onExecute).toBeCalledTimes(1) - expect(onExecute).toHaveBeenCalledWith('input', context, { ...meta, next: expect.any(Function) }) - expect(onSuccess).toBeCalledTimes(2) - expect(onSuccess).toHaveBeenNthCalledWith(1, { output: 'output', input: 'input', status: 'success' }, context, meta) - expect(onSuccess).toHaveBeenNthCalledWith(2, 'output', context, { ...meta, next: expect.any(Function) }) - expect(onError).not.toBeCalled() - expect(onFinish).toBeCalledTimes(1) - expect(onFinish).toBeCalledWith({ output: 'output', input: 'input', status: 'success' }, context, meta) - - onSuccess.mockClear() - onError.mockClear() - onFinish.mockClear() - onExecute.mockClear() - - // @ts-expect-error - invalid input - await expect(caller.nested.procedure(123)).rejects.toThrowError( - 'Validation input failed', - ) - - const meta2 = { - path: ['nested', 'procedure'], - procedure, - } - - const error2 = new ORPCError({ - message: 'Validation input failed', - code: 'BAD_REQUEST', - cause: expect.any(Error), - }) - - expect(onExecute).toBeCalledTimes(1) - expect(onExecute).toHaveBeenCalledWith(123, context, { ...meta2, next: expect.any(Function) }) - expect(onError).toBeCalledTimes(2) - expect(onError).toHaveBeenNthCalledWith(1, { input: 123, error: error2, status: 'error' }, context, meta2) - expect(onError).toHaveBeenNthCalledWith(2, error2, context, { ...meta2, next: expect.any(Function) }) - expect(onSuccess).not.toBeCalled() - expect(onFinish).toBeCalledTimes(1) - expect(onFinish).toBeCalledWith({ input: 123, error: error2, status: 'error' }, context, meta2) - }) -}) diff --git a/packages/server/src/router-caller.ts b/packages/server/src/router-caller.ts deleted file mode 100644 index b2cccadcf..000000000 --- a/packages/server/src/router-caller.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { Hooks, Merge, Value } from '@orpc/shared' -import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE } from './procedure' -import type { Router } from './router' -import { isLazy } from './lazy' -import { isProcedure } from './procedure' -import { createProcedureCaller, type ProcedureCaller } from './procedure-caller' - -export interface CreateRouterCallerOptions< - TRouter extends Router, -> extends Hooks< - unknown, - unknown, - TRouter extends Router ? UContext : never, - { path: string[], procedure: ANY_PROCEDURE } - > { - router: TRouter - - /** - * The context used when calling the procedure. - */ - context: Value< - TRouter extends Router ? UContext : never - > - - /** - * This is helpful for logging and analytics. - * - * @internal - */ - basePath?: string[] -} - -export type RouterCaller< - TRouter extends Router, -> = { - [K in keyof TRouter]: TRouter[K] extends ANY_PROCEDURE | ANY_LAZY_PROCEDURE - ? ProcedureCaller - : TRouter[K] extends Router - ? RouterCaller - : never -} - -export function createRouterCaller< - TRouter extends Router, ->( - options: CreateRouterCallerOptions, -): RouterCaller { - return createRouterCallerInternal(options) as any -} - -function createRouterCallerInternal( - options: Merge>, { - router: Router | Router[keyof Router] - }>, -) { - const procedureCaller = isLazy(options.router) || isProcedure(options.router) - ? createProcedureCaller({ - ...options, - procedure: options.router as any, - context: options.context, - path: options.basePath, - }) - : {} - - const recursive = new Proxy(procedureCaller, { - get(target, key) { - if (typeof key !== 'string') { - return Reflect.get(target, key) - } - - const next = (options.router as any)[key] - - return createRouterCallerInternal({ - ...options, - router: next, - basePath: [...(options.basePath ?? []), key], - }) - }, - }) - - return recursive -} diff --git a/packages/server/src/router-client.test-d.ts b/packages/server/src/router-client.test-d.ts new file mode 100644 index 000000000..056f5da09 --- /dev/null +++ b/packages/server/src/router-client.test-d.ts @@ -0,0 +1,134 @@ +import type { Procedure } from './procedure' +import type { ProcedureClient } from './procedure-client' +import type { Meta, WELL_CONTEXT } from './types' +import { z } from 'zod' +import { lazy } from './lazy' +import { createRouterClient, type RouterClient } from './router-client' + +const schema = z.object({ val: z.string().transform(val => Number(val)) }) +const ping = {} as Procedure +const pong = {} as Procedure<{ auth: boolean }, undefined, undefined, undefined, unknown> + +const router = { + ping, + pong, + nested: { + ping, + pong, + }, +} + +const routerWithLazy = { + ping: lazy(() => Promise.resolve({ default: ping })), + pong, + nested: lazy(() => Promise.resolve({ default: { + ping, + pong: lazy(() => Promise.resolve({ default: pong })), + } })), +} + +describe('RouterClient', () => { + it('router without lazy', () => { + const client = {} as RouterClient + + expectTypeOf(client.ping).toEqualTypeOf< + ProcedureClient<{ val: string }, { val: number }> + >() + expectTypeOf(client.pong).toEqualTypeOf< + ProcedureClient + >() + + expectTypeOf(client.nested.ping).toEqualTypeOf< + ProcedureClient<{ val: string }, { val: number }> + >() + expectTypeOf(client.nested.pong).toEqualTypeOf< + ProcedureClient + >() + }) + + it('support lazy', () => { + expectTypeOf>().toEqualTypeOf>() + }) + + it('support procedure as router', () => { + expectTypeOf>().toEqualTypeOf>() + }) +}) + +describe('createRouterClient', () => { + it('return RouterClient', () => { + const client = createRouterClient({ + router, + context: { auth: true }, + }) + + expectTypeOf(client).toMatchTypeOf>() + + const client2 = createRouterClient({ + router: routerWithLazy, + context: { auth: true }, + }) + expectTypeOf(client2).toMatchTypeOf>() + }) + + it('required context when needed', () => { + createRouterClient({ + router: { ping }, + }) + + createRouterClient({ + router: { pong }, + context: { auth: true }, + }) + + createRouterClient({ + router: { pong }, + context: () => ({ auth: true }), + }) + + createRouterClient({ + router: { pong }, + context: async () => ({ auth: true }), + }) + + createRouterClient({ + router: { pong }, + // @ts-expect-error --- invalid context + context: { auth: 'invalid' }, + }) + + // @ts-expect-error --- missing context + createRouterClient({ + router: { pong }, + }) + }) + + it('support hooks', () => { + createRouterClient({ + router, + context: { auth: true }, + onSuccess: async ({ output }, context, meta) => { + expectTypeOf(output).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf & { + auth: boolean + }>() + expectTypeOf(meta).toEqualTypeOf() + }, + }) + }) + + it('support base path', () => { + createRouterClient({ + router: { ping }, + context: { auth: true }, + path: ['users'], + }) + + createRouterClient({ + router: { ping }, + context: { auth: true }, + // @ts-expect-error --- invalid path + path: [123], + }) + }) +}) diff --git a/packages/server/src/router-client.test.ts b/packages/server/src/router-client.test.ts new file mode 100644 index 000000000..5eab7437e --- /dev/null +++ b/packages/server/src/router-client.test.ts @@ -0,0 +1,172 @@ +import { ContractProcedure } from '@orpc/contract' +import { z } from 'zod' +import { lazy, unlazy } from './lazy' +import { Procedure } from './procedure' +import { createProcedureClient } from './procedure-client' +import { createRouterClient } from './router-client' + +vi.mock('./procedure-client', () => ({ + createProcedureClient: vi.fn(() => vi.fn(() => '__mocked__')), +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('createRouterClient', () => { + const schema = z.object({ val: z.string().transform(v => Number(v)) }) + const ping = new Procedure({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: schema, + }), + func: vi.fn(() => ({ val: '123' })), + }) + const pong = new Procedure({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + }), + func: vi.fn(() => ('output')), + }) + + const router = { + ping: lazy(() => Promise.resolve({ default: ping })), + pong, + nested: lazy(() => Promise.resolve({ default: { + ping, + pong: lazy(() => Promise.resolve({ default: pong })), + } })), + } + + const client = createRouterClient({ + router, + context: { auth: true }, + path: ['users'], + }) + + it('works', () => { + expect(client.pong({ val: '123' })).toEqual('__mocked__') + + expect(createProcedureClient).toBeCalledTimes(1) + expect(createProcedureClient).toBeCalledWith(expect.objectContaining({ + procedure: pong, + context: { auth: true }, + path: ['users', 'pong'], + })) + + expect(vi.mocked(createProcedureClient).mock.results[0]?.value).toBeCalledTimes(1) + expect(vi.mocked(createProcedureClient).mock.results[0]?.value).toBeCalledWith({ val: '123' }) + }) + + it('work with lazy', async () => { + expect(client.ping({ val: '123' })).toEqual('__mocked__') + + expect(createProcedureClient).toBeCalledTimes(1) + expect(createProcedureClient).toHaveBeenNthCalledWith(1, expect.objectContaining({ + procedure: expect.any(Object), + context: { auth: true }, + path: ['users', 'ping'], + })) + + expect((await unlazy(vi.mocked(createProcedureClient as any).mock.calls[0]![0].procedure)).default).toBe(ping) + + expect(vi.mocked(createProcedureClient).mock.results[0]?.value).toBeCalledTimes(1) + expect(vi.mocked(createProcedureClient).mock.results[0]?.value).toBeCalledWith({ val: '123' }) + }) + + it('work with nested lazy', async () => { + expect(client.nested.ping({ val: '123' })).toEqual('__mocked__') + + expect(createProcedureClient).toBeCalledTimes(2) + expect(createProcedureClient).toHaveBeenNthCalledWith(2, expect.objectContaining({ + procedure: expect.any(Object), + context: { auth: true }, + path: ['users', 'nested', 'ping'], + })) + + const lazied = vi.mocked(createProcedureClient as any).mock.calls[1]![0].procedure + expect(await unlazy(lazied)).toEqual({ default: ping }) + + expect(vi.mocked(createProcedureClient).mock.results[1]?.value).toBeCalledTimes(1) + expect(vi.mocked(createProcedureClient).mock.results[1]?.value).toBeCalledWith({ val: '123' }) + }) + + it('work with procedure as router', () => { + const client = createRouterClient({ + router: ping, + context: { auth: true }, + path: ['users'], + }) + + expect(client({ val: '123' })).toEqual('__mocked__') + + expect(createProcedureClient).toBeCalledTimes(1) + expect(createProcedureClient).toHaveBeenCalledWith(expect.objectContaining({ + procedure: ping, + context: { auth: true }, + path: ['users'], + })) + + expect(vi.mocked(createProcedureClient).mock.results[0]?.value).toBeCalledTimes(1) + expect(vi.mocked(createProcedureClient).mock.results[0]?.value).toBeCalledWith({ val: '123' }) + }) + + it('hooks', async () => { + const onStart = vi.fn() + const onSuccess = vi.fn() + const onError = vi.fn() + const onFinish = vi.fn() + const execute = vi.fn() + + const client = createRouterClient({ + router, + context: { auth: true }, + onStart, + onSuccess, + onError, + onFinish, + execute, + }) + + expect(client.pong({ val: '123' })).toEqual('__mocked__') + + expect(createProcedureClient).toBeCalledTimes(1) + expect(createProcedureClient).toHaveBeenCalledWith(expect.objectContaining({ + procedure: pong, + context: { auth: true }, + path: ['pong'], + onStart, + onSuccess, + onError, + onFinish, + execute, + })) + }) + + it('not recursive on symbol', () => { + expect((client as any)[Symbol('something')]).toBeUndefined() + }) + + it('return undefined if access the undefined key', async () => { + const client = createRouterClient({ + router: { + ping, + }, + }) + + // @ts-expect-error --- invalid access + expect(client.router).toBeUndefined() + }) + + it('works without base path', async () => { + const client = createRouterClient({ + router: { + ping, + }, + }) + + expect(client.ping({ val: '123' })).toEqual('__mocked__') + expect(vi.mocked(createProcedureClient).mock.calls[0]![0].path).toEqual(['ping']) + }) +}) diff --git a/packages/server/src/router-client.ts b/packages/server/src/router-client.ts new file mode 100644 index 000000000..0df11cb78 --- /dev/null +++ b/packages/server/src/router-client.ts @@ -0,0 +1,88 @@ +import type { ContractProcedure, ContractRouter, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { Hooks, Value } from '@orpc/shared' +import type { Lazy } from './lazy' +import type { Procedure } from './procedure' +import type { ProcedureClient } from './procedure-client' +import type { Meta } from './types' +import { isLazy } from './lazy' +import { createLazyProcedureFormAnyLazy } from './lazy-utils' +import { isProcedure } from './procedure' +import { createProcedureClient } from './procedure-client' +import { type ANY_ROUTER, getRouterChild, type Router } from './router' + +export type RouterClient = +T extends Lazy + ? RouterClient + : T extends + | ContractProcedure + | Procedure + ? ProcedureClient, SchemaOutput> + : { + [K in keyof T]: T[K] extends ANY_ROUTER | ContractRouter ? RouterClient : never + } + +export type CreateRouterClientOptions< + TRouter extends ANY_ROUTER, +> = + & { + router: TRouter | Lazy + + /** + * This is helpful for logging and analytics. + * + * @internal + */ + path?: string[] + } + & (TRouter extends Router + ? undefined extends UContext ? { context?: Value } : { context: Value } + : never) + & Hooks ? UContext : never, Meta> + +export function createRouterClient< + TRouter extends ANY_ROUTER, +>( + options: CreateRouterClientOptions, +): RouterClient { + if (isProcedure(options.router)) { + const caller = createProcedureClient({ + ...options, + procedure: options.router, + context: options.context, + path: options.path, + }) + + return caller as any + } + + const procedureCaller = isLazy(options.router) + ? createProcedureClient({ + ...options, + procedure: createLazyProcedureFormAnyLazy(options.router), + context: options.context, + path: options.path, + }) + : {} + + const recursive = new Proxy(procedureCaller, { + get(target, key) { + if (typeof key !== 'string') { + return Reflect.get(target, key) + } + + const next = getRouterChild(options.router, key) + + if (!next) { + return Reflect.get(target, key) + } + + return createRouterClient({ + ...options, + router: next, + path: [...(options.path ?? []), key], + }) + }, + }) + + return recursive as any +} diff --git a/packages/server/src/router-implementer.test-d.ts b/packages/server/src/router-implementer.test-d.ts new file mode 100644 index 000000000..96b08080d --- /dev/null +++ b/packages/server/src/router-implementer.test-d.ts @@ -0,0 +1,105 @@ +import type { DecoratedLazy } from './lazy-decorated' +import type { Middleware } from './middleware' +import type { AdaptedRouter } from './router-builder' +import type { RouterImplementer } from './router-implementer' +import { oc } from '@orpc/contract' +import { z } from 'zod' +import { lazy } from './lazy' +import { Procedure } from './procedure' + +const schema = z.object({ val: z.string().transform(val => Number(val)) }) + +const ping = oc.input(schema).output(schema) +const pong = oc.route({ method: 'GET', path: '/ping' }) + +const contract = oc.router({ + ping, + pong, + nested: { + ping, + pong, + }, +}) + +const pingImpl = new Procedure({ + contract: ping, + func: vi.fn(), +}) + +const pongImpl = new Procedure({ + contract: pong, + func: vi.fn(), +}) + +const router = { + ping: pingImpl, + pong: pongImpl, + nested: { + ping: pingImpl, + pong: pongImpl, + }, +} + +const routerWithLazy = { + ping: lazy(() => Promise.resolve({ default: pingImpl })), + pong: pongImpl, + nested: lazy(() => Promise.resolve({ + default: { + ping: pingImpl, + pong: lazy(() => Promise.resolve({ default: pongImpl })), + }, + })), +} + +const implementer = {} as RouterImplementer<{ auth: boolean }, { db: string }, typeof contract> + +describe('self chainable', () => { + it('use middleware', () => { + const mid1 = {} as Middleware<{ auth: boolean }, undefined, unknown, unknown> + const mid2 = {} as Middleware<{ auth: boolean }, { dev: string }, unknown, unknown> + const mid3 = {} as Middleware<{ auth: boolean, db: string }, { dev: string }, unknown, unknown> + + expectTypeOf(implementer.use(mid1)).toEqualTypeOf() + expectTypeOf(implementer.use(mid2)).toEqualTypeOf< + RouterImplementer<{ auth: boolean }, { db: string } & { dev: string }, typeof contract> + >() + expectTypeOf(implementer.use(mid3)).toEqualTypeOf< + RouterImplementer<{ auth: boolean }, { db: string } & { dev: string }, typeof contract> + >() + + const mid4 = {} as Middleware<{ auth: boolean }, { dev: string }, unknown, { val: string }> + const mid5 = {} as Middleware<{ auth: boolean }, { dev: string }, unknown, { val: number }> + const mid6 = {} as Middleware<{ auth: 'invalid' }, undefined, any, any> + + // @ts-expect-error - invalid middleware + implementer.use(mid4) + // @ts-expect-error - invalid middleware + implementer.use(mid5) + // @ts-expect-error - invalid middleware + implementer.use(mid6) + // @ts-expect-error - invalid middleware + implementer.use(true) + // @ts-expect-error - invalid middleware + implementer.use(() => {}) + }) +}) + +it('to AdaptedRouter', () => { + expectTypeOf(implementer.router(router)).toMatchTypeOf< + AdaptedRouter<{ auth: boolean }, typeof router> + >() + + expectTypeOf(implementer.router(routerWithLazy)).toMatchTypeOf< + AdaptedRouter<{ auth: boolean }, typeof routerWithLazy> + >() +}) + +it('to AdaptedLazy', () => { + expectTypeOf(implementer.lazy(() => Promise.resolve({ default: router }))).toMatchTypeOf< + DecoratedLazy> + >() + + expectTypeOf(implementer.lazy(() => Promise.resolve({ default: routerWithLazy }))).toMatchTypeOf< + DecoratedLazy> + >() +}) diff --git a/packages/server/src/router-implementer.test.ts b/packages/server/src/router-implementer.test.ts index c9182491d..47c776f0b 100644 --- a/packages/server/src/router-implementer.test.ts +++ b/packages/server/src/router-implementer.test.ts @@ -1,138 +1,117 @@ import { oc } from '@orpc/contract' import { z } from 'zod' -import { os, RouterImplementer } from '.' - -const cp1 = oc.input(z.string()).output(z.string()) -const cp2 = oc.output(z.string()) -const cp3 = oc.route({ method: 'GET', path: '/test' }) -const cr = oc.router({ - p1: cp1, - nested: oc.router({ - p2: cp2, - }), - nested2: { - p3: cp3, - }, +import { getRouterContract } from './hidden' +import { Procedure } from './procedure' +import { RouterBuilder } from './router-builder' +import { RouterImplementer } from './router-implementer' + +vi.mock('./router-builder', () => ({ + RouterBuilder: vi.fn(() => ({ + router: vi.fn(() => ({ mocked: true })), + lazy: vi.fn(() => ({ mocked: true })), + })), +})) + +beforeEach(() => { + vi.clearAllMocks() }) -const osw = os.context<{ auth: boolean }>().contract(cr) +const schema = z.object({ val: z.string().transform(val => Number(val)) }) + +const ping = oc.input(schema).output(schema) +const pong = oc.route({ method: 'GET', path: '/ping' }) -const p1 = osw.p1.func(() => { - return 'unnoq' +const contract = oc.router({ + ping, + pong, + nested: { + ping, + pong, + }, }) -const p2 = osw.nested.p2.func(() => { - return 'unnoq' +const pingImpl = new Procedure({ + contract: ping, + func: vi.fn(), }) -const p3 = osw.nested2.p3.func(() => { - return 'unnoq' +const pongImpl = new Procedure({ + contract: pong, + func: vi.fn(), }) -it('required all procedure match', () => { - const implementer = new RouterImplementer<{ auth: boolean }, typeof cr>({ - contract: cr, - }) +const router = { + ping: pingImpl, + pong: pongImpl, + nested: { + ping: pingImpl, + pong: pongImpl, + }, +} - implementer.router({ - p1, - nested: { - p2: os.contract(cp2).func(() => ''), - }, - nested2: { - p3, - }, - }) +const mid = vi.fn() +const implementer = new RouterImplementer({ + contract, + middlewares: [mid], +}) - implementer.router({ - p1, - nested: { - p2: os.contract(cp2).func(() => ''), - }, - nested2: { - p3: os.context<{ auth: boolean }>().lazy(() => Promise.resolve({ default: p3 })), - }, - }) +describe('self chainable', () => { + it('use middleware', () => { + const mid1 = vi.fn() + const mid2 = vi.fn() + const mid3 = vi.fn() - implementer.router({ - p1, - nested: { - p2: os.contract(cp2).func(() => ''), - }, - nested2: osw.nested2.lazy(() => Promise.resolve({ default: { - p3, - } })), - }) + const implementer = new RouterImplementer({ + contract, + }) + + const applied1 = implementer.use(mid1) + expect(applied1).not.toBe(implementer) + expect(applied1).toBeInstanceOf(RouterImplementer) + expect(applied1['~orpc'].middlewares).toEqual([mid1]) - implementer.router({ - p1, - nested: { - p2: os.contract(cp2).lazy(() => Promise.resolve({ default: os.output(z.string()).func(() => { - return '' - }) })), - }, - nested2: osw.nested2.lazy(() => Promise.resolve({ - default: { - p3: osw.nested2.p3.lazy(() => Promise.resolve({ default: p3 })), - }, - })), + const applied2 = applied1.use(mid2).use(mid3) + expect(applied2['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) }) +}) + +describe('to AdaptedRouter', () => { + it('works', () => { + expect(implementer.router(router)).toEqual({ mocked: true }) - implementer.lazy(() => Promise.resolve({ - default: { - p1, - nested: { - p2: os.contract(cp2).func(() => ''), - }, - nested2: osw.nested2.lazy(() => Promise.resolve({ - default: { - p3, - }, - })), - }, - })) - - implementer.router({ - // @ts-expect-error p1 is mismatch - p1: os.func(() => { }), - nested: { - p2, - }, - nested2: { - p3, - }, + expect(RouterBuilder).toBeCalledTimes(1) + expect(RouterBuilder).toBeCalledWith(expect.objectContaining({ + middlewares: [mid], + })) + + const builder = vi.mocked(RouterBuilder).mock.results[0]?.value + expect(vi.mocked(builder.router)).toBeCalledTimes(1) + expect(vi.mocked(builder.router)).toBeCalledWith(router) }) - implementer.router({ - // @ts-expect-error p1 is mismatch - p1: p2, - nested: { - p2, - }, - nested2: { - p3, - }, + it('attach contract', () => { + const adapted = implementer.router(router) as any + expect(getRouterContract(adapted)).toBe(contract) }) +}) + +describe('to AdaptedLazy', () => { + it('works', () => { + const loader = () => Promise.resolve({ default: router }) + expect(implementer.lazy(loader)).toEqual({ mocked: true }) - // @ts-expect-error required all procedure match - implementer.router({}) + expect(RouterBuilder).toBeCalledTimes(1) + expect(RouterBuilder).toBeCalledWith(expect.objectContaining({ + middlewares: [mid], + })) - implementer.router({ - p1, - nested: { - p2, - }, - // @ts-expect-error missing p3 - nested2: {}, + const builder = vi.mocked(RouterBuilder).mock.results[0]?.value + expect(vi.mocked(builder.lazy)).toBeCalledTimes(1) + expect(vi.mocked(builder.lazy)).toBeCalledWith(loader) }) - implementer.router({ - p1, - nested: { - p2, - }, - nested2: { - p3: p3.prefix('/test'), - }, + it('attach contract', () => { + const adapted = implementer.lazy(() => Promise.resolve({ default: router })) as any + expect(getRouterContract(adapted)).toBe(contract) }) }) diff --git a/packages/server/src/router-implementer.ts b/packages/server/src/router-implementer.ts index 93d509ebb..ea9a18690 100644 --- a/packages/server/src/router-implementer.ts +++ b/packages/server/src/router-implementer.ts @@ -1,84 +1,64 @@ -import type { DecoratedLazy } from './lazy' +import type { ContractRouter } from '@orpc/contract' +import type { FlattenLazy } from './lazy' import type { Middleware } from './middleware' -import type { HandledRouter, RouterWithContract } from './router' -import type { Context } from './types' -import { type ContractProcedure, type ContractRouter, isContractProcedure } from '@orpc/contract' -import { createLazy, decorateLazy } from './lazy' -import { ProcedureImplementer } from './procedure-implementer' +import type { Router } from './router' +import type { AdaptedRouter } from './router-builder' +import type { Context, MergeContext } from './types' +import { setRouterContract } from './hidden' import { RouterBuilder } from './router-builder' -export const ROUTER_CONTRACT_SYMBOL = Symbol('ORPC_ROUTER_CONTRACT') +export interface RouterImplementerDef< + TContext extends Context, + TExtraContext extends Context, + TContract extends ContractRouter, +> { + middlewares?: Middleware, Partial | undefined, unknown, any>[] + contract: TContract +} export class RouterImplementer< TContext extends Context, + TExtraContext extends Context, TContract extends ContractRouter, > { - constructor( - public zz$ri: { - contract: TContract - }, - ) {} + '~type' = 'RouterImplementer' as const + '~orpc': RouterImplementerDef - router>( - router: U, - ): HandledRouter { - return Object.assign(new RouterBuilder({}).router(router), { - [ROUTER_CONTRACT_SYMBOL]: this.zz$ri.contract, - }) + constructor(def: RouterImplementerDef) { + this['~orpc'] = def } - lazy>( - loader: () => Promise<{ default: U }>, - ): DecoratedLazy { - const lazy = createLazy(loader) - const decorated = decorateLazy(lazy) - - return Object.assign(decorated, { - [ROUTER_CONTRACT_SYMBOL]: this.zz$ri.contract, + use> | undefined = undefined>( + middleware: Middleware< + MergeContext, + U, + unknown, + unknown + >, + ): RouterImplementer, TContract> { + return new RouterImplementer({ + ...this['~orpc'], + middlewares: [...(this['~orpc'].middlewares ?? []), middleware as any], }) } -} -export type ChainedRouterImplementer< - TContext extends Context, - TContract extends ContractRouter, - TExtraContext extends Context, -> = { - [K in keyof TContract]: TContract[K] extends ContractProcedure< - infer UInputSchema, - infer UOutputSchema - > - ? ProcedureImplementer - : TContract[K] extends ContractRouter - ? ChainedRouterImplementer - : never -} & RouterImplementer - -export function chainRouterImplementer< - TContext extends Context, - TContract extends ContractRouter, - TExtraContext extends Context, ->( - contract: TContract, - middlewares?: Middleware[], -): ChainedRouterImplementer { - const result: Record = {} + router, TContract>>( + router: U, + ): AdaptedRouter { + const adapted = new RouterBuilder(this['~orpc']).router(router) - for (const key in contract) { - const item = contract[key] + const contracted = setRouterContract(adapted, this['~orpc'].contract) - if (isContractProcedure(item)) { - result[key] = new ProcedureImplementer({ - contract: item, - middlewares, - }) - } - else { - result[key] = chainRouterImplementer(item as ContractRouter, middlewares) - } + return contracted } - const implementer = new RouterImplementer({ contract }) + lazy, TContract>>( + loader: () => Promise<{ default: U }>, + ): AdaptedRouter> { + const adapted = new RouterBuilder(this['~orpc']).lazy(loader) - return Object.assign(implementer, result) as any + const contracted = setRouterContract(adapted, this['~orpc'].contract) + + return contracted + } } diff --git a/packages/server/src/router.test-d.ts b/packages/server/src/router.test-d.ts index edb4fd517..f5dd7f760 100644 --- a/packages/server/src/router.test-d.ts +++ b/packages/server/src/router.test-d.ts @@ -1,48 +1,241 @@ -import type { InferRouterInputs, InferRouterOutputs } from '.' +import type { ANY_LAZY, Lazy } from './lazy' +import type { Procedure } from './procedure' +import type { ANY_ROUTER, InferRouterInputs, InferRouterOutputs, Router } from './router' +import type { WELL_CONTEXT } from './types' +import { oc } from '@orpc/contract' import { z } from 'zod' -import { os } from '.' - -const router = os.router({ - ping: os - .input(z.object({ ping: z.string().transform(() => 1) })) - .output(z.object({ pong: z.number().transform(() => '1') })) - .func(() => ({ pong: 1 })), - user: { - find: os - .input(z.object({ find: z.number().transform(() => '1') })) - .func(() => ({ user: { id: 1 } })) - , +import { lazy } from './lazy' +import { getRouterChild } from './router' + +const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + +const ping = {} as Procedure<{ auth: boolean }, { db: string }, typeof schema, typeof schema, { val: string }> +const pong = {} as Procedure + +const router = { + ping: lazy(() => Promise.resolve({ default: ping })), + pong, + nested: { + ping, + pong, }, -}) + lazy: lazy(() => Promise.resolve({ default: { + ping: lazy(() => Promise.resolve({ default: ping })), + pong, + nested: lazy(() => Promise.resolve({ default: { + ping: lazy(() => Promise.resolve({ default: ping })), + pong, + } })), + } })), +} it('InferRouterInputs', () => { type Inputs = InferRouterInputs - expectTypeOf().toEqualTypeOf<{ - ping: { - ping: string - } - user: { - find: { - find: number - } - } - }>() + expectTypeOf().toEqualTypeOf<{ val: string }>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<{ val: string }>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<{ val: string }>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<{ val: string }>() + expectTypeOf().toEqualTypeOf() }) it('InferRouterOutputs', () => { type Outputs = InferRouterOutputs - expectTypeOf().toEqualTypeOf<{ - ping: { - pong: string - } - user: { - find: { - user: { - id: number - } - } - } - }>() + expectTypeOf().toEqualTypeOf<{ val: number }>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<{ val: number }>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<{ val: number }>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<{ val: number }>() + expectTypeOf().toEqualTypeOf() +}) + +describe('Router', () => { + it('require match context', () => { + const ping = {} as Procedure<{ auth: boolean }, { db: string }, undefined, undefined, unknown> + const pong = {} as Procedure<{ auth: string }, undefined, undefined, undefined, unknown> + + const router: Router<{ auth: boolean, userId: string }, any> = { + ping, + // @ts-expect-error auth is not match + pong, + nested: { + ping, + // @ts-expect-error auth is not match + pong, + }, + + pingLazy: lazy(() => Promise.resolve({ default: ping })), + // @ts-expect-error auth is not match + pongLazy: lazy(() => Promise.resolve({ default: pong })), + + nestedLazy1: lazy(() => Promise.resolve({ + default: { + ping, + }, + })), + + nestedLazy2: lazy(() => Promise.resolve({ + default: { + ping: lazy(() => Promise.resolve({ default: ping })), + }, + })), + + // @ts-expect-error auth is not match + nestedLazy3: lazy(() => Promise.resolve({ + default: { + pong, + }, + })), + + // @ts-expect-error auth is not match + nestedLazy4: lazy(() => Promise.resolve({ + default: { + nested: { + pong: lazy(() => Promise.resolve({ default: pong })), + }, + }, + })), + + nestedLazy6: lazy(() => Promise.resolve({ + default: { + nested: lazy(() => Promise.resolve({ + default: { + pingLazy: lazy(() => Promise.resolve({ default: ping })), + }, + })), + }, + })), + + // @ts-expect-error auth is not match + nestedLazy5: lazy(() => Promise.resolve({ + default: { + nested: lazy(() => Promise.resolve({ + default: { + pongLazy: lazy(() => Promise.resolve({ default: pong })), + }, + })), + }, + })), + } + }) + + it('require match contract', () => { + const contract = oc.router({ + ping: oc.input(schema), + pong: oc.output(schema), + + nested: oc.router({ + ping: oc.input(schema), + pong: oc.output(schema), + }), + }) + + const ping = {} as Procedure<{ auth: boolean }, { db: string }, typeof schema, undefined, unknown> + const pong = {} as Procedure + + const router1: Router<{ auth: boolean, userId: string }, typeof contract> = { + ping, + pong, + nested: { + ping, + pong, + }, + } + + const router2: Router<{ auth: boolean, userId: string }, typeof contract> = { + ping, + pong: lazy(() => Promise.resolve({ default: pong })), + nested: { + ping: lazy(() => Promise.resolve({ default: ping })), + pong, + }, + } + + const router3: Router<{ auth: boolean, userId: string }, typeof contract> = { + ping: lazy(() => Promise.resolve({ default: ping })), + pong, + nested: lazy(() => Promise.resolve({ + default: { + ping: lazy(() => Promise.resolve({ default: ping })), + pong, + }, + })), + } + + // @ts-expect-error missing + const router4: Router<{ auth: boolean, userId: string }, typeof contract> = {} + + const router39: Router<{ auth: boolean, userId: string }, typeof contract> = { + // @ts-expect-error wrong ping + ping: pong, + pong, + nested: { + ping, + // @ts-expect-error wrong pong + pong: ping, + }, + } + + const router565: Router<{ auth: boolean, userId: string }, typeof contract> = { + // @ts-expect-error wrong ping + ping: lazy(() => Promise.resolve({ default: pong })), + pong, + nested: { + ping, + // @ts-expect-error wrong pong + pong: lazy(() => Promise.resolve({ default: ping })), + }, + } + + const router343: Router<{ auth: boolean, userId: string }, typeof contract> = { + // @ts-expect-error wrong ping + ping: lazy(() => Promise.resolve({ default: pong })), + pong, + // @ts-expect-error wrong nested + nested: lazy(() => Promise.resolve({ + default: { + ping: lazy(() => Promise.resolve({ default: ping })), + pong: lazy(() => Promise.resolve({ default: ping })), + }, + })), + } + }) + + it('support procedure as a router', () => { + const router1: Router<{ auth: boolean, userId: string }, any> = {} as Procedure<{ auth: boolean }, { db: string }, typeof schema, undefined, unknown> + // @ts-expect-error - invalid context + const router2: Router<{ auth: boolean, userId: string }, any> = {} as Procedure<{ auth: boolean, dev: boolean }, { db: string }, typeof schema, undefined, unknown> + + const pingContract = oc.input(schema) + const router3: Router<{ auth: boolean, userId: string }, typeof pingContract> = {} as Procedure<{ auth: boolean }, { db: string }, typeof schema, undefined, unknown> + // @ts-expect-error - mismatch contract + const router4: Router<{ auth: boolean, userId: string }, typeof pingContract> = {} as Procedure<{ auth: boolean }, { db: string }, undefined, undefined, unknown> + }) +}) + +describe('getRouterChild', () => { + it('works', () => { + getRouterChild({}) + getRouterChild(router) + getRouterChild(lazy(() => Promise.resolve({ default: router }))) + getRouterChild(lazy(() => Promise.resolve({ default: undefined }))) + + // @ts-expect-error --- invalid router + getRouterChild(1) + + expectTypeOf(getRouterChild({})).toEqualTypeOf | undefined>() + }) + + it('return lazy if router is lazy', () => { + expectTypeOf( + getRouterChild(lazy(() => Promise.resolve({ default: router })), 'a', 'b'), + ) + .toMatchTypeOf() + }) }) diff --git a/packages/server/src/router.test.ts b/packages/server/src/router.test.ts index aa76e57d5..fc73fdad0 100644 --- a/packages/server/src/router.test.ts +++ b/packages/server/src/router.test.ts @@ -1,101 +1,98 @@ -import { oc } from '@orpc/contract' +import { ContractProcedure } from '@orpc/contract' import { z } from 'zod' -import { os, type RouterWithContract } from '.' +import { isLazy, lazy, unlazy } from './lazy' +import { Procedure } from './procedure' +import { getRouterChild } from './router' -it('require procedure match context', () => { - const osw = os.context<{ auth: boolean, userId: string }>() +describe('getRouterChild', () => { + const schema = z.object({ val: z.string().transform(val => Number(val)) }) - osw.router({ - ping: osw.context<{ auth: boolean }>().func(() => { - return { pong: 'ping' } + const ping = new Procedure({ + contract: new ContractProcedure({ + InputSchema: schema, + OutputSchema: schema, }), - - // @ts-expect-error userId is not match - ping2: osw.context<{ userId: number }>().func(() => { - return { name: 'unnoq' } - }), - - nested: { - ping: osw.context<{ auth: boolean }>().func(() => { - return { pong: 'ping' } - }), - - // @ts-expect-error userId is not match - ping2: osw.context<{ userId: number }>().func(() => { - return { name: 'unnoq' } - }), - }, + func: vi.fn(() => ({ val: '123' })), }) -}) - -it('require match contract', () => { - const pingContract = oc.route({ method: 'GET', path: '/ping' }) - const pongContract = oc.input(z.string()).output(z.string()) - const ping = os.contract(pingContract).func(() => { - return 'ping' - }) - const pong = os.contract(pongContract).func(() => { - return 'pong' - }) - - const contract = oc.router({ - ping: pingContract, - pong: pongContract, - - nested: oc.router({ - ping: pingContract, - pong: pongContract, + const pong = new Procedure({ + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, }), + func: vi.fn(() => ('output')), }) - const _1: RouterWithContract = { - ping, - pong, + it('with procedure as router', () => { + expect(getRouterChild(ping)).toBe(ping) + expect(getRouterChild(ping, '~orpc')).toBe(undefined) + expect(getRouterChild(ping, '~type')).toBe(undefined) + }) - nested: { + it('with router', () => { + const router = { ping, pong, - }, - } - - const _2: RouterWithContract = { - ping, - pong, + nested: { + ping, + pong, + }, + } + + expect(getRouterChild(router, 'ping')).toBe(ping) + expect(getRouterChild(router, 'pong')).toBe(pong) + expect(getRouterChild(router, 'nested')).toBe(router.nested) + expect(getRouterChild(router, 'nested', 'ping')).toBe(ping) + expect(getRouterChild(router, 'nested', 'pong')).toBe(pong) + expect(getRouterChild(router, 'nested', '~orpc')).toBe(undefined) + expect(getRouterChild(router, 'nested', 'ping', '~orpc')).toBe(undefined) + expect(getRouterChild(router, 'nested', 'pue', '~orpc', 'peng', 'pue')).toBe(undefined) + }) - nested: os.contract(contract.nested).router({ - ping, + it('with lazy router', async () => { + const lazyPing = lazy(() => Promise.resolve({ default: ping })) + const lazyPong = lazy(() => Promise.resolve({ default: pong })) + + const lazyNested = lazy(() => Promise.resolve({ + default: { + ping, + pong: lazyPong, + nested2: lazy(() => Promise.resolve({ + default: { + ping, + pong: lazyPong, + }, + })), + }, + })) + + const router = { + ping: lazyPing, pong, - }), - } + nested: lazyNested, + } - const _3: RouterWithContract = { - ping, - pong, + expect(await unlazy(getRouterChild(router, 'ping'))).toEqual({ default: ping }) + expect(getRouterChild(router, 'pong')).toBe(pong) - // @ts-expect-error missing nested.ping - nested: { - pong, - }, - } + expect(getRouterChild(router, 'nested')).toSatisfy(isLazy) + expect(getRouterChild(router, 'nested', 'ping')).toSatisfy(isLazy) + expect(getRouterChild(router, 'nested', 'pong')).toSatisfy(isLazy) - const _4: RouterWithContract = { - ping, - pong, + expect(getRouterChild(router, 'nested')).toBe(lazyNested) + expect(await unlazy(getRouterChild(router, 'nested', 'ping'))).toEqual({ default: ping }) + expect(await unlazy(getRouterChild(router, 'nested', 'pong'))).toEqual({ default: pong }) - nested: { - ping, - // @ts-expect-error nested.pong is mismatch - pong: os.func(() => 'ping'), - }, - } + expect(getRouterChild(router, 'nested', '~orpc')).toSatisfy(isLazy) + expect(await unlazy(getRouterChild(router, 'nested', '~orpc'))).toEqual({ default: undefined }) - // @ts-expect-error missing pong - const _5: RouterWithContract = { - ping, + expect(await unlazy(getRouterChild(router, 'nested', 'nested2', 'pong'))).toEqual({ default: pong }) + expect(await unlazy(getRouterChild(router, 'nested', 'nested2', 'peo', 'pue', 'cu', 'la'))).toEqual({ default: undefined }) + }) - nested: { - ping, - pong, - }, - } + it('support Lazy', async () => { + const lazied = lazy(() => Promise.resolve({ default: undefined })) + + expect(await unlazy(getRouterChild(lazied, 'ping'))).toEqual({ default: undefined }) + expect(await unlazy(getRouterChild(lazied, 'ping', '~orpc'))).toEqual({ default: undefined }) + }) }) diff --git a/packages/server/src/router.ts b/packages/server/src/router.ts index 244571dae..3a4bc8fa1 100644 --- a/packages/server/src/router.ts +++ b/packages/server/src/router.ts @@ -1,77 +1,80 @@ -import type { - ContractProcedure, - ContractRouter, - SchemaInput, - SchemaOutput, -} from '@orpc/contract' -import type { ANY_LAZY, DecoratedLazy, Lazy } from './lazy' -import type { - DecoratedProcedure, - Procedure, -} from './procedure' - +import type { ContractProcedure, ContractRouter, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { ANY_LAZY, Lazy, Lazyable } from './lazy' +import type { ANY_PROCEDURE, Procedure } from './procedure' import type { Context } from './types' +import { flatLazy, isLazy, lazy, unlazy } from './lazy' +import { isProcedure } from './procedure' -export interface Router { - [k: string]: - | Procedure - | Lazy> - | Router - | Lazy> -} - -export type HandledRouter> = { - [K in keyof TRouter]: TRouter[K] extends Procedure< - infer UContext, - infer UExtraContext, - infer UInputSchema, - infer UOutputSchema, - infer UFuncOutput - > - ? DecoratedProcedure< - UContext, - UExtraContext, - UInputSchema, - UOutputSchema, - UFuncOutput - > - : TRouter[K] extends ANY_LAZY - ? DecoratedLazy - : TRouter[K] extends Router - ? HandledRouter - : never -} - -export type RouterWithContract< +export type Router< TContext extends Context, TContract extends ContractRouter, -> = { - [K in keyof TContract]: TContract[K] extends ContractProcedure< - infer UInputSchema, - infer UOutputSchema - > - ? Procedure | Lazy> - : TContract[K] extends ContractRouter - ? RouterWithContract - : never -} +> = Lazyable< + TContract extends ContractProcedure + ? Procedure + : { + [K in keyof TContract]: TContract[K] extends ContractRouter ? Router : never + } +> -export type InferRouterInputs> = { - [K in keyof T]: T[K] extends - | Procedure - | Lazy> - ? SchemaInput - : T[K] extends Router - ? InferRouterInputs - : never -} +export type ANY_ROUTER = Router + +export type InferRouterInputs = + T extends Lazy ? InferRouterInputs + : T extends Procedure + ? SchemaInput + : { + [K in keyof T]: T[K] extends ANY_ROUTER ? InferRouterInputs : never + } + +export type InferRouterOutputs = + T extends Lazy ? InferRouterOutputs + : T extends Procedure + ? SchemaOutput + : { + [K in keyof T]: T[K] extends ANY_ROUTER ? InferRouterOutputs : never + } + +export function getRouterChild< + T extends ANY_ROUTER | Lazy, +>(router: T, ...path: string[]): T extends ANY_LAZY + ? Lazy | undefined> + : ANY_ROUTER | Lazy | undefined { + let current: ANY_ROUTER | Lazy | undefined = router + + for (let i = 0; i < path.length; i++) { + const segment = path[i]! + + if (!current) { + return undefined as any + } + + if (isProcedure(current)) { + return undefined as any + } + + if (!isLazy(current)) { + current = current[segment] + + continue + } + + const lazied = current + const rest = path.slice(i) + + const newLazy = lazy(async () => { + const unwrapped = await unlazy(lazied) + + if (!unwrapped.default) { + return unwrapped + } + + const next = getRouterChild(unwrapped.default, ...rest) + + return { default: next } + }) + + return flatLazy(newLazy) + } -export type InferRouterOutputs> = { - [K in keyof T]: T[K] extends - | Procedure - | Lazy> - ? SchemaOutput - : T[K] extends Router - ? InferRouterOutputs - : never + return current as any } diff --git a/packages/server/src/types.test-d.ts b/packages/server/src/types.test-d.ts index a7f5aab0c..d14c1d0ec 100644 --- a/packages/server/src/types.test-d.ts +++ b/packages/server/src/types.test-d.ts @@ -1,4 +1,4 @@ -import type { Caller, CallerOptions, MergeContext } from './types' +import type { MergeContext } from './types' it('mergeContext', () => { expectTypeOf>().toEqualTypeOf() @@ -16,55 +16,3 @@ it('mergeContext', () => { bar: string }>() }) - -describe('Caller', () => { - const fn: Caller = async (input, options) => { - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(options).toEqualTypeOf() - return 123 - } - - const fnWithOptionalInput: Caller = async (...args) => { - const [input, options] = args - - expectTypeOf(input).toEqualTypeOf() - expectTypeOf(options).toEqualTypeOf() - return 123 - } - - it('just a function', () => { - expectTypeOf(fn).toEqualTypeOf<(input: string, options?: CallerOptions) => Promise>() - expectTypeOf(fnWithOptionalInput).toMatchTypeOf<(input: string | undefined, options?: CallerOptions) => Promise>() - }) - - it('infer correct input', () => { - fn('123') - fnWithOptionalInput('123') - - // @ts-expect-error - invalid input - fn(123) - // @ts-expect-error - invalid input - fnWithOptionalInput(123) - - // @ts-expect-error - invalid input - fn({}) - // @ts-expect-error - invalid input - fnWithOptionalInput({}) - }) - - it('accept signal', () => { - fn('123', { signal: new AbortSignal() }) - fnWithOptionalInput('123', { signal: new AbortSignal() }) - - // @ts-expect-error - invalid signal - fn('123', { signal: 1234 }) - // @ts-expect-error - invalid signal - fnWithOptionalInput('123', { signal: 1234 }) - }) - - it('can accept call without args', () => { - expectTypeOf(fnWithOptionalInput()).toEqualTypeOf>() - // @ts-expect-error - input is required - expectTypeOf(fn()).toEqualTypeOf>() - }) -}) diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 3f9a5b9ea..471b9621f 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1,21 +1,18 @@ -import type { WELL_DEFINED_PROCEDURE } from './procedure' +import type { ANY_PROCEDURE } from './procedure' -export type Context = Record | undefined +export type Context = Record | undefined +export type WELL_CONTEXT = Record | undefined export type MergeContext< TA extends Context, TB extends Context, > = TA extends undefined ? TB : TB extends undefined ? TA : TA & TB -export interface CallerOptions { +export interface WithSignal { signal?: AbortSignal } -export interface Caller { - (...opts: [input: TInput, options?: CallerOptions] | (undefined extends TInput ? [] : never)): Promise -} - -export interface Meta extends CallerOptions { +export interface Meta extends WithSignal { path: string[] - procedure: WELL_DEFINED_PROCEDURE + procedure: ANY_PROCEDURE } diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 99061213a..e6322353d 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.lib.json", "compilerOptions": { + "lib": ["ES2022", "DOM"], "types": [] }, "references": [ diff --git a/packages/shared/src/hook.ts b/packages/shared/src/hook.ts index 70ce31faf..906db8d10 100644 --- a/packages/shared/src/hook.ts +++ b/packages/shared/src/hook.ts @@ -17,7 +17,7 @@ export interface Hooks | OnErrorState, context: TContext, meta: TMeta) => Promisable> } -export async function executeWithHooks & { next?: never }) | undefined>( +export async function executeWithHooks & { next?: never }) | undefined>( options: { hooks?: Hooks input: TInput diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cebe4c6d9..5fd079d1c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,6 +3,7 @@ export * from './function' export * from './hook' export * from './json' export * from './object' +export * from './proxy' export * from './value' export { isPlainObject } from 'is-what' diff --git a/packages/shared/src/proxy.ts b/packages/shared/src/proxy.ts new file mode 100644 index 000000000..df4ea9bec --- /dev/null +++ b/packages/shared/src/proxy.ts @@ -0,0 +1,30 @@ +import type { AnyFunction } from './function' + +export function createCallableObject(obj: TObject, func: TFunc): TObject & TFunc { + const proxy = new Proxy(func, { + has(target, key) { + return Reflect.has(obj, key) || Reflect.has(target, key) + }, + ownKeys(target) { + return Array.from(new Set(Reflect.ownKeys(obj).concat(...Reflect.ownKeys(target)))) + }, + get(target, key) { + if (!Reflect.has(target, key) || Reflect.has(obj, key)) { + return Reflect.get(obj, key) + } + + return Reflect.get(target, key) + }, + defineProperty(_, key, descriptor) { + return Reflect.defineProperty(obj, key, descriptor) + }, + set(_, key, value) { + return Reflect.set(obj, key, value) + }, + deleteProperty(target, key) { + return Reflect.deleteProperty(target, key) && Reflect.deleteProperty(obj, key) + }, + }) + + return proxy as any +} diff --git a/packages/shared/src/value.ts b/packages/shared/src/value.ts index 9b6ccf2fb..bfd096f64 100644 --- a/packages/shared/src/value.ts +++ b/packages/shared/src/value.ts @@ -2,10 +2,9 @@ import type { Promisable } from 'type-fest' export type Value = T | (() => Promisable) -export function value>(value: T): -Promise ? U : never> { +export function value>(value: T): Promise ? U : never> { if (typeof value === 'function') { - return value() + return (value as any)() } return value as any diff --git a/packages/vue-query/src/utils-procedure.test-d.ts b/packages/vue-query/src/utils-procedure.test-d.ts index 0a0fff057..7e84f5494 100644 --- a/packages/vue-query/src/utils-procedure.test-d.ts +++ b/packages/vue-query/src/utils-procedure.test-d.ts @@ -1,11 +1,11 @@ -import type { Caller } from '@orpc/server' +import type { ProcedureClient } from '@orpc/server' import type { InfiniteData, QueryKey } from '@tanstack/vue-query' import { useInfiniteQuery, useQuery } from '@tanstack/vue-query' import { ref } from 'vue' import { createProcedureUtils } from './utils-procedure' describe('queryOptions', () => { - const client = vi.fn >( + const client = vi.fn >( (...[input]) => Promise.resolve(input?.toString()), ) const utils = createProcedureUtils(client, []) @@ -179,7 +179,7 @@ describe('infiniteOptions', () => { }) it('input can be optional', () => { - const utils = createProcedureUtils({} as Caller<{ limit?: number, cursor?: number } | undefined, string>, []) + const utils = createProcedureUtils({} as ProcedureClient<{ limit?: number, cursor?: number } | undefined, string>, []) utils.infiniteOptions({ getNextPageParam, diff --git a/packages/vue-query/src/utils-procedure.test.ts b/packages/vue-query/src/utils-procedure.test.ts index 962a21257..f316a2294 100644 --- a/packages/vue-query/src/utils-procedure.test.ts +++ b/packages/vue-query/src/utils-procedure.test.ts @@ -1,4 +1,4 @@ -import type { Caller } from '@orpc/server' +import type { ProcedureClient } from '@orpc/server' import { ref } from 'vue' import * as keyModule from './key' import { createProcedureUtils } from './utils-procedure' @@ -13,7 +13,7 @@ beforeEach(() => { }) describe('queryOptions', () => { - const client = vi.fn>( + const client = vi.fn>( (...[input]) => Promise.resolve(input?.toString()), ) const utils = createProcedureUtils(client, ['ping']) @@ -117,7 +117,7 @@ describe('infiniteOptions', () => { }) describe('mutationOptions', () => { - const client = vi.fn>( + const client = vi.fn>( (...[input]) => Promise.resolve(input?.toString()), ) const utils = createProcedureUtils(client, ['ping']) diff --git a/packages/vue-query/src/utils-procedure.ts b/packages/vue-query/src/utils-procedure.ts index 4d897884f..381c951f2 100644 --- a/packages/vue-query/src/utils-procedure.ts +++ b/packages/vue-query/src/utils-procedure.ts @@ -1,4 +1,4 @@ -import type { Caller } from '@orpc/server' +import type { ProcedureClient } from '@orpc/server' import type { IsEqual } from '@orpc/shared' import type { QueryKey } from '@tanstack/vue-query' import type { ComputedRef } from 'vue' @@ -29,7 +29,7 @@ export interface ProcedureUtils { } export function createProcedureUtils( - client: Caller, + client: ProcedureClient, path: string[], ): ProcedureUtils { return { diff --git a/packages/vue-query/src/utils-router.test-d.ts b/packages/vue-query/src/utils-router.test-d.ts index 41d89c735..488ec9b75 100644 --- a/packages/vue-query/src/utils-router.test-d.ts +++ b/packages/vue-query/src/utils-router.test-d.ts @@ -1,3 +1,4 @@ +import type { RouterClient } from '@orpc/server' import { oc } from '@orpc/contract' import { os } from '@orpc/server' import { z } from 'zod' @@ -22,7 +23,7 @@ const router = os.contract(contractRouter).router({ describe('with contract router', () => { it('build correct types', () => { - const utils = createRouterUtils({} as any) + const utils = createRouterUtils({} as RouterClient) const generalUtils = createGeneralUtils([]) const pingUtils = createProcedureUtils(ping, []) @@ -40,7 +41,7 @@ describe('with contract router', () => { describe('with router', () => { it('build correct types', () => { - const utils = createRouterUtils({} as any) + const utils = createRouterUtils({} as RouterClient) const generalUtils = createGeneralUtils([]) const pingUtils = createProcedureUtils(ping, []) diff --git a/packages/vue-query/src/utils-router.ts b/packages/vue-query/src/utils-router.ts index 1a34a8151..1df8d268c 100644 --- a/packages/vue-query/src/utils-router.ts +++ b/packages/vue-query/src/utils-router.ts @@ -1,28 +1,20 @@ -import type { RouterClient } from '@orpc/client' -import type { ContractProcedure, ContractRouter, SchemaInput, SchemaOutput } from '@orpc/contract' -import type { Lazy, Procedure, Router } from '@orpc/server' +import type { ProcedureClient, RouterClient } from '@orpc/server' import { createGeneralUtils, type GeneralUtils } from './utils-general' import { createProcedureUtils, type ProcedureUtils } from './utils-procedure' -export type RouterUtils | ContractRouter> = { - [K in keyof T]: T[K] extends - | ContractProcedure - | Procedure - | Lazy> - ? - & ProcedureUtils, SchemaOutput> - & GeneralUtils> - : T[K] extends Router | ContractRouter - ? RouterUtils - : never -} & GeneralUtils +export type RouterUtils> = + T extends ProcedureClient + ? ProcedureUtils & GeneralUtils + : { + [K in keyof T]: T[K] extends RouterClient ? RouterUtils : never + } & GeneralUtils /** * @param client - The client create form `@orpc/client` * @param path - The base path for query key */ -export function createRouterUtils | ContractRouter>( - client: RouterClient, +export function createRouterUtils>( + client: T, path: string[] = [], ): RouterUtils { const generalUtils = createGeneralUtils(path) diff --git a/packages/vue-query/tests/e2e.test.ts b/packages/vue-query/tests/e2e.test.ts index 3b4c45ee7..105cfaa03 100644 --- a/packages/vue-query/tests/e2e.test.ts +++ b/packages/vue-query/tests/e2e.test.ts @@ -37,7 +37,7 @@ describe('useQuery', () => { // @ts-expect-error -- invalid input const query = useQuery(orpc.user.create.queryOptions({ input: {} }), queryClient) - await vi.waitFor(() => expect(query.error.value).toEqual(new Error('Validation input failed'))) + await vi.waitFor(() => expect(query.error.value).toEqual(new Error('Input validation failed'))) expect(queryClient.getQueryData(orpc.ping.key({ type: 'query' }))).toEqual(undefined) }) diff --git a/packages/vue-query/tests/helpers.ts b/packages/vue-query/tests/helpers.ts index 4aab98505..8ad44cc1a 100644 --- a/packages/vue-query/tests/helpers.ts +++ b/packages/vue-query/tests/helpers.ts @@ -1,4 +1,4 @@ -import { createORPCClient } from '@orpc/client' +import { createORPCFetchClient } from '@orpc/client' import { os } from '@orpc/server' import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' import { QueryClient } from '@tanstack/vue-query' @@ -85,7 +85,7 @@ export const appRouter = orpcServer.router({ }, }) -export const orpcClient = createORPCClient({ +export const orpcClient = createORPCFetchClient({ baseURL: 'http://localhost:3000', async fetch(...args) { diff --git a/playgrounds/contract-openapi/src/contract/index.ts b/playgrounds/contract-openapi/src/contract/index.ts index a6a11d03c..fc4e6f4e7 100644 --- a/playgrounds/contract-openapi/src/contract/index.ts +++ b/playgrounds/contract-openapi/src/contract/index.ts @@ -10,7 +10,7 @@ import { } from './planet' export const contract = oc.router({ - auth: oc.tags('Authentication').prefix('/auth').router({ + auth: oc.tag('Authentication').prefix('/auth').router({ signup, signin, refresh, @@ -18,7 +18,7 @@ export const contract = oc.router({ me, }), - planet: oc.tags('Planets').prefix('/planets').router({ + planet: oc.tag('Planets').prefix('/planets').router({ list: listPlanets, create: createPlanet, find: findPlanet, diff --git a/playgrounds/contract-openapi/src/orpc.ts b/playgrounds/contract-openapi/src/orpc.ts index 11a2d2fef..26b802015 100644 --- a/playgrounds/contract-openapi/src/orpc.ts +++ b/playgrounds/contract-openapi/src/orpc.ts @@ -3,7 +3,10 @@ import type { UserSchema } from './schemas/user' import { ORPCError, os } from '@orpc/server' import { contract } from './contract' -export type ORPCContext = { user?: z.infer, db: any } +export interface ORPCContext { + user?: z.infer + db?: any +} const base = os.context().use(async (input, context, meta) => { const start = Date.now() diff --git a/playgrounds/contract-openapi/src/playground-client.ts b/playgrounds/contract-openapi/src/playground-client.ts index 89f64da41..783b94bfe 100644 --- a/playgrounds/contract-openapi/src/playground-client.ts +++ b/playgrounds/contract-openapi/src/playground-client.ts @@ -3,9 +3,9 @@ */ import type { contract } from './contract' -import { createORPCClient } from '@orpc/client' +import { createORPCFetchClient } from '@orpc/client' -const orpc = createORPCClient({ +const orpc = createORPCFetchClient({ baseURL: 'http://localhost:3000/api', }) diff --git a/playgrounds/contract-openapi/src/playground-react.ts b/playgrounds/contract-openapi/src/playground-react.ts index 28f7ee0d0..695bb07a8 100644 --- a/playgrounds/contract-openapi/src/playground-react.ts +++ b/playgrounds/contract-openapi/src/playground-react.ts @@ -2,10 +2,11 @@ * This file is where you can play with type of oRPC React. */ +import type { RouterClient } from '@orpc/server' import type { contract } from './contract' import { createORPCReact } from '@orpc/react' -const { orpc } = createORPCReact() +const { orpc } = createORPCReact>() const listQuery = orpc.planet.list.useInfiniteQuery({ input: {}, diff --git a/playgrounds/expressjs/src/orpc.ts b/playgrounds/expressjs/src/orpc.ts index d9631dcc7..6f03d3a64 100644 --- a/playgrounds/expressjs/src/orpc.ts +++ b/playgrounds/expressjs/src/orpc.ts @@ -2,7 +2,10 @@ import type { z } from 'zod' import type { UserSchema } from './schemas/user' import { ORPCError, os } from '@orpc/server' -export type ORPCContext = { user?: z.infer, db: any } +export interface ORPCContext { + user?: z.infer + db?: any +} export const pub = os.context().use(async (input, context, meta) => { const start = Date.now() diff --git a/playgrounds/expressjs/src/playground-client.ts b/playgrounds/expressjs/src/playground-client.ts index fe7af2ba5..b53f16cee 100644 --- a/playgrounds/expressjs/src/playground-client.ts +++ b/playgrounds/expressjs/src/playground-client.ts @@ -3,9 +3,9 @@ */ import type { router } from './router' -import { createORPCClient } from '@orpc/client' +import { createORPCFetchClient } from '@orpc/client' -const orpc = createORPCClient({ +const orpc = createORPCFetchClient({ baseURL: 'http://localhost:3000/api', }) diff --git a/playgrounds/expressjs/src/playground-react.ts b/playgrounds/expressjs/src/playground-react.ts index 33e0cb7b5..708f65b83 100644 --- a/playgrounds/expressjs/src/playground-react.ts +++ b/playgrounds/expressjs/src/playground-react.ts @@ -2,10 +2,11 @@ * This file is where you can play with type of oRPC React. */ +import type { RouterClient } from '@orpc/server' import type { router } from './router' import { createORPCReact } from '@orpc/react' -const { orpc } = createORPCReact() +const { orpc } = createORPCReact >() const listQuery = orpc.planet.list.useInfiniteQuery({ input: {}, diff --git a/playgrounds/expressjs/src/router/index.ts b/playgrounds/expressjs/src/router/index.ts index 48dd6d0c2..802e2bd20 100644 --- a/playgrounds/expressjs/src/router/index.ts +++ b/playgrounds/expressjs/src/router/index.ts @@ -10,7 +10,7 @@ import { } from './planet' export const router = pub.router({ - auth: pub.tags('Authentication').prefix('/auth').router({ + auth: pub.tag('Authentication').prefix('/auth').router({ signup, signin, refresh, @@ -18,7 +18,7 @@ export const router = pub.router({ me, }), - planet: pub.tags('Planets').prefix('/planets').router({ + planet: pub.tag('Planets').prefix('/planets').router({ list: listPlanets, create: createPlanet, find: findPlanet, diff --git a/playgrounds/nextjs/src/app/api/[...rest]/router.ts b/playgrounds/nextjs/src/app/api/[...rest]/router.ts index b7aa350af..dc4f9dac6 100644 --- a/playgrounds/nextjs/src/app/api/[...rest]/router.ts +++ b/playgrounds/nextjs/src/app/api/[...rest]/router.ts @@ -10,7 +10,7 @@ import { import { pub } from '@/orpc' export const router = pub.router({ - auth: pub.tags('Authentication').router({ + auth: pub.tag('Authentication').router({ signup, signin, refresh, @@ -18,7 +18,7 @@ export const router = pub.router({ me, }), - planet: pub.tags('Planets').router({ + planet: pub.tag('Planets').router({ list: listPlanets, create: createPlanet, find: findPlanet, diff --git a/playgrounds/nextjs/src/lib/orpc.ts b/playgrounds/nextjs/src/lib/orpc.ts index 4f24f06c4..52a058efd 100644 --- a/playgrounds/nextjs/src/lib/orpc.ts +++ b/playgrounds/nextjs/src/lib/orpc.ts @@ -1,13 +1,12 @@ import type { router } from '@/app/api/[...rest]/router' -import { createORPCClient } from '@orpc/client' - +import { createORPCFetchClient } from '@orpc/client' import { createORPCReact } from '@orpc/react' -export const orpcClient = createORPCClient({ +export const orpcClient = createORPCFetchClient({ baseURL: 'http://localhost:3000/api', headers: () => ({ Authorization: 'Bearer default-token', }), }) -export const { orpc, ORPCContext } = createORPCReact() +export const { orpc, ORPCContext } = createORPCReact() diff --git a/playgrounds/nextjs/src/orpc.ts b/playgrounds/nextjs/src/orpc.ts index efccd81ee..8a855608b 100644 --- a/playgrounds/nextjs/src/orpc.ts +++ b/playgrounds/nextjs/src/orpc.ts @@ -20,13 +20,13 @@ const base = os } }) .use(async (input, context, meta) => { - // You can use headers or cookies here to create the user object: - // import { cookies, headers } from 'next/headers' - // const headerList = await headers(); - // const cookieList = await cookies(); - // - // These lines are commented out because Stackblitz has issues with Next.js headers and cookies. - // However, this works fine in a local environment. + // You can use headers or cookies here to create the user object: + // import { cookies, headers } from 'next/headers' + // const headerList = await headers(); + // const cookieList = await cookies(); + // + // These lines are commented out because Stackblitz has issues with Next.js headers and cookies. + // However, this works fine in a local environment. const user = { id: 'test', name: 'John Doe', email: 'john@doe.com' } satisfies z.infer diff --git a/playgrounds/nuxt/lib/orpc.ts b/playgrounds/nuxt/lib/orpc.ts index 6641907fc..274dd6993 100644 --- a/playgrounds/nuxt/lib/orpc.ts +++ b/playgrounds/nuxt/lib/orpc.ts @@ -1,8 +1,8 @@ -import { createORPCClient } from '@orpc/client' +import { createORPCFetchClient } from '@orpc/client' import { createORPCVueQueryUtils } from '@orpc/vue-query' import type { router } from '~/server/router' -export const client = createORPCClient({ +export const client = createORPCFetchClient({ baseURL: 'http://localhost:3000/api', headers: () => ({ Authorization: 'Bearer default-token', diff --git a/playgrounds/nuxt/server/orpc.ts b/playgrounds/nuxt/server/orpc.ts index d9631dcc7..6f03d3a64 100644 --- a/playgrounds/nuxt/server/orpc.ts +++ b/playgrounds/nuxt/server/orpc.ts @@ -2,7 +2,10 @@ import type { z } from 'zod' import type { UserSchema } from './schemas/user' import { ORPCError, os } from '@orpc/server' -export type ORPCContext = { user?: z.infer, db: any } +export interface ORPCContext { + user?: z.infer + db?: any +} export const pub = os.context().use(async (input, context, meta) => { const start = Date.now() diff --git a/playgrounds/nuxt/server/router/index.ts b/playgrounds/nuxt/server/router/index.ts index 831594ffb..46198268b 100644 --- a/playgrounds/nuxt/server/router/index.ts +++ b/playgrounds/nuxt/server/router/index.ts @@ -10,7 +10,7 @@ import { } from './planet' export const router = { - auth: pub.tags('Authentication').prefix('/auth').router({ + auth: pub.tag('Authentication').prefix('/auth').router({ signup, signin, refresh, @@ -18,7 +18,7 @@ export const router = { me, }), - planet: pub.tags('Planets').prefix('/planets').router({ + planet: pub.tag('Planets').prefix('/planets').router({ list: listPlanets, create: createPlanet, find: findPlanet, diff --git a/playgrounds/openapi/src/orpc.ts b/playgrounds/openapi/src/orpc.ts index d9631dcc7..6f03d3a64 100644 --- a/playgrounds/openapi/src/orpc.ts +++ b/playgrounds/openapi/src/orpc.ts @@ -2,7 +2,10 @@ import type { z } from 'zod' import type { UserSchema } from './schemas/user' import { ORPCError, os } from '@orpc/server' -export type ORPCContext = { user?: z.infer, db: any } +export interface ORPCContext { + user?: z.infer + db?: any +} export const pub = os.context().use(async (input, context, meta) => { const start = Date.now() diff --git a/playgrounds/openapi/src/playground-client.ts b/playgrounds/openapi/src/playground-client.ts index fe7af2ba5..b53f16cee 100644 --- a/playgrounds/openapi/src/playground-client.ts +++ b/playgrounds/openapi/src/playground-client.ts @@ -3,9 +3,9 @@ */ import type { router } from './router' -import { createORPCClient } from '@orpc/client' +import { createORPCFetchClient } from '@orpc/client' -const orpc = createORPCClient({ +const orpc = createORPCFetchClient({ baseURL: 'http://localhost:3000/api', }) diff --git a/playgrounds/openapi/src/playground-react.ts b/playgrounds/openapi/src/playground-react.ts index 33e0cb7b5..708f65b83 100644 --- a/playgrounds/openapi/src/playground-react.ts +++ b/playgrounds/openapi/src/playground-react.ts @@ -2,10 +2,11 @@ * This file is where you can play with type of oRPC React. */ +import type { RouterClient } from '@orpc/server' import type { router } from './router' import { createORPCReact } from '@orpc/react' -const { orpc } = createORPCReact() +const { orpc } = createORPCReact >() const listQuery = orpc.planet.list.useInfiniteQuery({ input: {}, diff --git a/playgrounds/openapi/src/router/index.ts b/playgrounds/openapi/src/router/index.ts index 48dd6d0c2..802e2bd20 100644 --- a/playgrounds/openapi/src/router/index.ts +++ b/playgrounds/openapi/src/router/index.ts @@ -10,7 +10,7 @@ import { } from './planet' export const router = pub.router({ - auth: pub.tags('Authentication').prefix('/auth').router({ + auth: pub.tag('Authentication').prefix('/auth').router({ signup, signin, refresh, @@ -18,7 +18,7 @@ export const router = pub.router({ me, }), - planet: pub.tags('Planets').prefix('/planets').router({ + planet: pub.tag('Planets').prefix('/planets').router({ list: listPlanets, create: createPlanet, find: findPlanet, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a82e7c32f..4b9a0353e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -325,9 +325,9 @@ importers: specifier: workspace:* version: link:../zod devDependencies: - '@orpc/openapi': - specifier: workspace:* - version: link:../openapi + zod: + specifier: ^3.24.1 + version: 3.24.1 packages/shared: dependencies: