diff --git a/apps/content/content/docs/server/context.mdx b/apps/content/content/docs/server/context.mdx index ada68b183..0660f8548 100644 --- a/apps/content/content/docs/server/context.mdx +++ b/apps/content/content/docs/server/context.mdx @@ -45,53 +45,53 @@ If your procedure only depends on `Middleware Context`, you can [call it](/docs/server/client) or use it as a [Server Action](/docs/server/server-action) directly. ```ts twoslash -import { os, ORPCError, call } from '@orpc/server' +import { call, ORPCError, os } from '@orpc/server' +import { RPCHandler } from '@orpc/server/fetch' import { headers } from 'next/headers' const base = os.use(async ({ context, path, next }, input) => { - return next({ - context: { - db: 'fake-db', - } - }) + return next({ + context: { + db: 'fake-db', + }, + }) }) -const authMid = base.middleware(async ({ context, next, path }, input) => { +const authMid = os + .context<{ db: string }>() // this middleware depends on some context + .middleware(async ({ context, next, path }, input) => { const headersList = await headers() const user = headersList.get('Authorization') ? { id: 'example' } : undefined if (!user) { - throw new ORPCError({ code: 'UNAUTHORIZED' }) + throw new ORPCError({ code: 'UNAUTHORIZED' }) } return next({ - context: { - user, - } + context: { + user, + }, }) -}) + }) export const router = base.router({ - getUser: base - .use(authMid) - .handler(({ input, context }) => { - // ^ context is fully typed - }), + getUser: base + .use(authMid) + .handler(({ input, context }) => { + // ^ context is fully typed + }), }) // You can call this procedure directly without manually providing context const output = await call(router.getUser, null) -import { RPCHandler } from '@orpc/server/fetch' -import { OpenAPIServerlessHandler, OpenAPIServerHandler } from '@orpc/openapi/fetch' - const rpcHandler = new RPCHandler(router) export async function fetch(request: Request) { - // No need to pass context; middleware handles it - const { response } = await rpcHandler.handle(request) + // No need to pass context; middleware handles it + const { response } = await rpcHandler.handle(request) - return response ?? new Response('Not found', { status: 404 }) + return response ?? new Response('Not found', { status: 404 }) } ``` diff --git a/apps/content/examples/middleware.ts b/apps/content/examples/middleware.ts index e6691327a..1273f62a1 100644 --- a/apps/content/examples/middleware.ts +++ b/apps/content/examples/middleware.ts @@ -5,20 +5,7 @@ import { z } from 'zod' export type Context = { user?: { id: string } } -export const pub = os - .context() - .use(async ({ context, path, next }, input) => { - // This middleware will apply to everything create from pub - const start = Date.now() - - try { - return await next({}) - } - finally { - // eslint-disable-next-line no-console - console.log(`middleware cost ${Date.now() - start}ms`) - } - }) +export const pub = os.context() export const authMiddleware = pub.middleware(async ({ context, next, path, procedure }, input) => { if (!context.user) { diff --git a/packages/contract/src/builder.test-d.ts b/packages/contract/src/builder.test-d.ts index 8e43dddb6..11f57b281 100644 --- a/packages/contract/src/builder.test-d.ts +++ b/packages/contract/src/builder.test-d.ts @@ -1,16 +1,15 @@ -import type { DecoratedContractProcedure } from './procedure-decorated' +import type { ContractProcedure } from './procedure' +import type { ContractProcedureBuilder } from './procedure-builder' +import type { ContractProcedureBuilderWithInput } from './procedure-builder-with-input' +import type { ContractProcedureBuilderWithOutput } from './procedure-builder-with-output' import type { AdaptedContractRouter, ContractRouterBuilder } from './router-builder' import { z } from 'zod' import { ContractBuilder } from './builder' -import { ContractProcedure } from './procedure' -const schema = z.object({ - value: z.string(), -}) +const schema = z.object({ value: z.string() }) const baseErrorMap = { BASE: { - status: 500, data: z.object({ message: z.string(), }), @@ -19,142 +18,72 @@ const baseErrorMap = { const builder = new ContractBuilder({ errorMap: baseErrorMap, OutputSchema: undefined, InputSchema: undefined }) -it('also is a contract procedure', () => { - expectTypeOf(builder).toMatchTypeOf>() -}) - -describe('self chainable', () => { - describe('errors', () => { - const errors = { - BAD: { - status: 500, - data: schema, - }, - ERROR2: { - status: 401, - data: schema, - }, - } as const - - it('should merge and strict with old one', () => { - expectTypeOf(builder.errors(errors)).toEqualTypeOf< - ContractBuilder - >() - }) - - it('should prevent redefine errorMap', () => { - // @ts-expect-error - not allow redefine errorMap - builder.errors({ BASE: baseErrorMap.BASE }) - // @ts-expect-error - not allow redefine errorMap - even with undefined - builder.errors({ BASE: undefined }) - }) +describe('ContractBuilder', () => { + it('is a contract procedure', () => { + expectTypeOf(builder).toMatchTypeOf>() }) -}) - -describe('to ContractRouterBuilder', () => { - it('prefix', () => { - expectTypeOf(builder.prefix('/prefix')).toEqualTypeOf< - ContractRouterBuilder - >() - // @ts-expect-error - invalid prefix - builder.prefix(1) - // @ts-expect-error - invalid prefix - builder.prefix('') - }) + it('.errors', () => { + const errors = { BAD_GATEWAY: { data: schema } } as const - it('tags', () => { - expectTypeOf(builder.tag('tag1', 'tag2')).toEqualTypeOf< - ContractRouterBuilder - >() + expectTypeOf(builder.errors(errors)) + .toEqualTypeOf>() - // @ts-expect-error - invalid tag - builder.tag(1) - // @ts-expect-error - invalid tag - builder.tag({}) + // @ts-expect-error - not allow redefine error map + builder.errors({ BASE: baseErrorMap.BASE }) }) -}) -describe('to DecoratedContractProcedure', () => { - it('route', () => { - expectTypeOf(builder.route({ method: 'GET', path: '/path' })).toEqualTypeOf< - DecoratedContractProcedure - >() - - expectTypeOf(builder.route({ })).toEqualTypeOf< - DecoratedContractProcedure - >() + it('.route', () => { + expectTypeOf(builder.route({ method: 'GET' })).toEqualTypeOf>() // @ts-expect-error - invalid method builder.route({ method: 'HE' }) - // @ts-expect-error - invalid path - builder.route({ method: 'GET', path: '' }) }) - it('input', () => { + it('.input', () => { expectTypeOf(builder.input(schema)).toEqualTypeOf< - DecoratedContractProcedure - >() - - expectTypeOf(builder.input(schema, { value: 'example' })).toEqualTypeOf< - DecoratedContractProcedure + ContractProcedureBuilderWithInput >() - - // @ts-expect-error - invalid schema - builder.input({}) - - // @ts-expect-error - invalid example - builder.input(schema, { }) }) - it('output', () => { + it('.output', () => { expectTypeOf(builder.output(schema)).toEqualTypeOf< - DecoratedContractProcedure - >() - - expectTypeOf(builder.output(schema, { value: 'example' })).toEqualTypeOf< - DecoratedContractProcedure + ContractProcedureBuilderWithOutput >() + }) - // @ts-expect-error - invalid schema - builder.output({}) + it('.prefix', () => { + expectTypeOf(builder.prefix('/api')).toEqualTypeOf>() - // @ts-expect-error - invalid example - builder.output(schema, {}) + // @ts-expect-error - invalid prefix + builder.prefix(1) }) -}) -describe('to router', () => { - const errors = { - CONFLICT: { - status: 400, - data: z.object({ - message: z.string(), - }), - }, - } - - const router = { a: { b: { - c: new ContractProcedure({ InputSchema: undefined, OutputSchema: undefined, errorMap: errors }), - } } } - - it('adapt all procedures', () => { - expectTypeOf(builder.router(router)).toEqualTypeOf>() - expectTypeOf(builder.router({})).toEqualTypeOf>() + it('.tag', () => { + expectTypeOf(builder.tag('tag1', 'tag2')).toEqualTypeOf>() - // @ts-expect-error - invalid router - builder.router({ a: 1 }) + // @ts-expect-error - invalid tag + builder.tag(1) }) - it('throw on conflict error map', () => { - builder.router({ ping: {} as ContractProcedure }) - // @ts-expect-error conflict - builder.router({ ping: {} as ContractProcedure }) - }) + it('.router', () => { + const router = { + ping: {} as ContractProcedure, + pong: {} as ContractProcedure>, + } - it('only required partial match error map', () => { - expectTypeOf(builder.router({ ping: {} as ContractProcedure })).toEqualTypeOf<{ - ping: DecoratedContractProcedure - }>() + expectTypeOf(builder.router(router)).toEqualTypeOf>() + + const invalidErrorMap = { + BASE: { + ...baseErrorMap.BASE, + status: 400, + }, + } + + builder.router({ + // @ts-expect-error - error map is not match + ping: {} as ContractProcedure, + }) }) }) diff --git a/packages/contract/src/builder.test.ts b/packages/contract/src/builder.test.ts index be7310dc2..c3915afd3 100644 --- a/packages/contract/src/builder.test.ts +++ b/packages/contract/src/builder.test.ts @@ -1,24 +1,26 @@ import { z } from 'zod' import { ContractBuilder } from './builder' -import { ContractProcedure, isContractProcedure } from './procedure' -import { DecoratedContractProcedure } from './procedure-decorated' +import { ContractProcedure } from './procedure' +import { ContractProcedureBuilder } from './procedure-builder' +import { ContractProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ContractProcedureBuilderWithOutput } from './procedure-builder-with-output' import { ContractRouterBuilder } from './router-builder' -vi.mock('./procedure-decorated', () => ({ - DecoratedContractProcedure: vi.fn(), -})) +vi.mock('./router-builder', async (origin) => { + const ContractRouterBuilder = vi.fn() + ContractRouterBuilder.prototype.router = vi.fn(() => '__router__') -vi.mock('./router-builder', () => ({ - ContractRouterBuilder: vi.fn(), -})) - -beforeEach(() => { - vi.clearAllMocks() + return { + ContractRouterBuilder, + } }) +const ContractRouterBuilderRouterSpy = vi.spyOn(ContractRouterBuilder.prototype, 'router') + +const schema = z.object({ value: z.string() }) + const baseErrorMap = { BASE: { - status: 500, data: z.object({ message: z.string(), }), @@ -27,107 +29,81 @@ const baseErrorMap = { const builder = new ContractBuilder({ errorMap: baseErrorMap, OutputSchema: undefined, InputSchema: undefined }) -const schema = z.object({ val: z.string().transform(val => Number(val)) }) - -it('also is a contract procedure', () => { - expect(builder).toSatisfy(isContractProcedure) +beforeEach(() => { + vi.clearAllMocks() }) -describe('self chainable', () => { - it('errors', () => { - const errors = { - BAD: { - status: 500, - data: schema, - }, - } +describe('contractBuilder', () => { + it('is a contract procedure', () => { + expect(builder).toBeInstanceOf(ContractProcedure) + }) - const applied = builder.errors(errors) + it('.errors', () => { + const errors = { BAD_GATEWAY: { data: schema } } as const - expect(applied).not.toBe(builder) + const applied = builder.errors(errors) expect(applied).toBeInstanceOf(ContractBuilder) - expect(applied['~orpc']).toEqual({ - errorMap: { - ...baseErrorMap, - ...errors, - }, + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual({ + ...baseErrorMap, + ...errors, }) }) -}) -describe('to ContractRouterBuilder', () => { - it('prefix', () => { - expect(builder.prefix('/prefix')).toBeInstanceOf(ContractRouterBuilder) + it('.route', () => { + const route = { method: 'GET', path: '/path' } as const + const applied = builder.route(route) + expect(applied).toBeInstanceOf(ContractProcedureBuilder) + expect(applied['~orpc'].route).toEqual(route) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + }) + it('.input', () => { + const applied = builder.input(schema) + expect(applied).toBeInstanceOf(ContractProcedureBuilderWithInput) + expect(applied['~orpc'].InputSchema).toEqual(schema) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + }) + + it('.output', () => { + const applied = builder.output(schema) + expect(applied).toBeInstanceOf(ContractProcedureBuilderWithOutput) + expect(applied['~orpc'].OutputSchema).toEqual(schema) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + }) + + it('.prefix', () => { + const applied = builder.prefix('/api') + expect(applied).toBeInstanceOf(ContractRouterBuilder) + expect(applied).toBe(vi.mocked(ContractRouterBuilder).mock.results[0]!.value) expect(ContractRouterBuilder).toHaveBeenCalledWith({ - prefix: '/prefix', + prefix: '/api', errorMap: baseErrorMap, }) }) - it('tag', () => { - expect(builder.tag('tag1', 'tag2')).toBeInstanceOf(ContractRouterBuilder) - + it('.tag', () => { + const applied = builder.tag('tag1', 'tag2') + expect(applied).toBeInstanceOf(ContractRouterBuilder) + expect(applied).toBe(vi.mocked(ContractRouterBuilder).mock.results[0]!.value) expect(ContractRouterBuilder).toHaveBeenCalledWith({ tags: ['tag1', 'tag2'], errorMap: baseErrorMap, }) }) -}) - -describe('to DecoratedContractProcedure', () => { - it('route', () => { - const route = { method: 'GET', path: '/path' } as const - const procedure = builder.route(route) - - expect(procedure).toBeInstanceOf(DecoratedContractProcedure) - expect(DecoratedContractProcedure).toHaveBeenCalledWith({ route, errorMap: baseErrorMap }) - }) - - const schema = z.object({ - value: z.string(), - }) - const example = { value: 'example' } - - it('input', () => { - const procedure = builder.input(schema, example) - - expect(procedure).toBeInstanceOf(DecoratedContractProcedure) - expect(DecoratedContractProcedure).toHaveBeenCalledWith({ InputSchema: schema, inputExample: example, errorMap: baseErrorMap }) - }) - - it('output', () => { - const procedure = builder.output(schema, example) - - expect(procedure).toBeInstanceOf(DecoratedContractProcedure) - expect(DecoratedContractProcedure).toHaveBeenCalledWith({ OutputSchema: schema, outputExample: example, errorMap: baseErrorMap }) - }) -}) - -describe('to router', () => { - const router = { - a: { - b: { - c: new ContractProcedure({ InputSchema: undefined, OutputSchema: undefined, errorMap: baseErrorMap }), - }, - }, - } - it('adapt all routers', () => { - const routerFn = vi.fn() - vi.mocked(ContractRouterBuilder).mockReturnValue({ - router: routerFn, - } as any) + it('.router', () => { + const router = { + ping: {} as any, + pong: {} as any, + } - const mockedValue = { __mocked__: true } - routerFn.mockReturnValue(mockedValue) + const applied = builder.router(router) - expect(builder.router(router)).toBe(mockedValue) - expect(ContractRouterBuilder).toBeCalledTimes(1) - expect(ContractRouterBuilder).toBeCalledWith({ + expect(applied).toBe(ContractRouterBuilderRouterSpy.mock.results[0]!.value) + expect(ContractRouterBuilderRouterSpy).toHaveBeenCalledWith(router) + expect(ContractRouterBuilder).toHaveBeenCalledWith({ errorMap: baseErrorMap, }) - expect(routerFn).toBeCalledTimes(1) - expect(routerFn).toBeCalledWith(router) }) }) diff --git a/packages/contract/src/builder.ts b/packages/contract/src/builder.ts index bd49c3a0d..2f54f39e4 100644 --- a/packages/contract/src/builder.ts +++ b/packages/contract/src/builder.ts @@ -2,9 +2,10 @@ import type { ErrorMap, ErrorMapGuard, ErrorMapSuggestions, StrictErrorMap } fro import type { ContractRouter } from './router' import type { AdaptedContractRouter } from './router-builder' import type { HTTPPath, Schema, SchemaInput, SchemaOutput } from './types' - import { ContractProcedure, type RouteOptions } from './procedure' -import { DecoratedContractProcedure } from './procedure-decorated' +import { ContractProcedureBuilder } from './procedure-builder' +import { ContractProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ContractProcedureBuilderWithOutput } from './procedure-builder-with-output' import { ContractRouterBuilder } from './router-builder' export type ContractBuilderDef = { @@ -22,22 +23,8 @@ export class ContractBuilder extends ContractProcedu }) } - prefix(prefix: HTTPPath): ContractRouterBuilder { - return new ContractRouterBuilder({ - prefix, - errorMap: this['~orpc'].errorMap, - }) - } - - tag(...tags: string[]): ContractRouterBuilder { - return new ContractRouterBuilder({ - tags, - errorMap: this['~orpc'].errorMap, - }) - } - - route(route: RouteOptions): DecoratedContractProcedure { - return new DecoratedContractProcedure({ + route(route: RouteOptions): ContractProcedureBuilder { + return new ContractProcedureBuilder({ route, InputSchema: undefined, OutputSchema: undefined, @@ -45,8 +32,8 @@ export class ContractBuilder extends ContractProcedu }) } - input(schema: U, example?: SchemaInput): DecoratedContractProcedure { - return new DecoratedContractProcedure({ + input(schema: U, example?: SchemaInput): ContractProcedureBuilderWithInput { + return new ContractProcedureBuilderWithInput({ InputSchema: schema, inputExample: example, OutputSchema: undefined, @@ -54,8 +41,8 @@ export class ContractBuilder extends ContractProcedu }) } - output(schema: U, example?: SchemaOutput): DecoratedContractProcedure { - return new DecoratedContractProcedure({ + output(schema: U, example?: SchemaOutput): ContractProcedureBuilderWithOutput { + return new ContractProcedureBuilderWithOutput({ OutputSchema: schema, outputExample: example, InputSchema: undefined, @@ -63,6 +50,20 @@ export class ContractBuilder extends ContractProcedu }) } + prefix(prefix: HTTPPath): ContractRouterBuilder { + return new ContractRouterBuilder({ + prefix, + errorMap: this['~orpc'].errorMap, + }) + } + + tag(...tags: string[]): ContractRouterBuilder { + return new ContractRouterBuilder({ + tags, + errorMap: this['~orpc'].errorMap, + }) + } + router>>>(router: T): AdaptedContractRouter { return new ContractRouterBuilder({ errorMap: this['~orpc'].errorMap, diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index 626d6d100..9543029ba 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -10,6 +10,9 @@ export * from './error' export * from './error-map' export * from './error-orpc' export * from './procedure' +export * from './procedure-builder' +export * from './procedure-builder-with-input' +export * from './procedure-builder-with-output' export * from './procedure-client' export * from './procedure-decorated' export * from './router' diff --git a/packages/contract/src/procedure-builder-with-input.test-d.ts b/packages/contract/src/procedure-builder-with-input.test-d.ts new file mode 100644 index 000000000..44c78892f --- /dev/null +++ b/packages/contract/src/procedure-builder-with-input.test-d.ts @@ -0,0 +1,62 @@ +import type { ContractProcedure } from './procedure' +import type { ContractProcedureBuilderWithInput } from './procedure-builder-with-input' +import type { DecoratedContractProcedure } from './procedure-decorated' +import { z } from 'zod' + +const baseErrorMap = { + BASE: { + status: 500, + data: z.object({ + message: z.string(), + }), + }, +} + +const inputSchema = z.object({ input: z.string().transform(v => Number.parseInt(v)) }) + +const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + +const builder = {} as ContractProcedureBuilderWithInput + +describe('DecoratedContractProcedure', () => { + it('is a contract procedure', () => { + expectTypeOf(builder).toMatchTypeOf>() + }) + + it('.errors', () => { + const errors = { BAD_GATEWAY: { data: z.object({ message: z.string() }) } } as const + + expectTypeOf(builder.errors(errors)) + .toEqualTypeOf>() + + // @ts-expect-error - not allow redefine error map + builder.errors({ BASE: baseErrorMap.BASE }) + }) + + it('.route', () => { + expectTypeOf(builder.route({ method: 'GET' })).toEqualTypeOf>() + + // @ts-expect-error - invalid method + builder.route({ method: 'HE' }) + }) + + it('.prefix', () => { + expectTypeOf(builder.prefix('/api')).toEqualTypeOf>() + + // @ts-expect-error - invalid prefix + builder.prefix(1) + }) + + it('.unshiftTag', () => { + expectTypeOf(builder.unshiftTag('tag', 'tag2')).toEqualTypeOf>() + + // @ts-expect-error - invalid tag + builder.unshiftTag(1) + }) + + it('.output', () => { + expectTypeOf(builder.output(schema)).toEqualTypeOf< + DecoratedContractProcedure + >() + }) +}) diff --git a/packages/contract/src/procedure-builder-with-input.test.ts b/packages/contract/src/procedure-builder-with-input.test.ts new file mode 100644 index 000000000..f18fe0b58 --- /dev/null +++ b/packages/contract/src/procedure-builder-with-input.test.ts @@ -0,0 +1,98 @@ +import { z } from 'zod' +import { ContractProcedure } from './procedure' +import { ContractProcedureBuilderWithInput } from './procedure-builder-with-input' +import { DecoratedContractProcedure } from './procedure-decorated' + +const baseErrorMap = { + BASE: { + status: 500, + data: z.object({ + message: z.string(), + }), + }, +} + +const baseRoute = { + method: 'GET', + path: '/v1/users', + tags: ['tag'], +} as const + +const inputSchema = z.object({ input: z.string().transform(v => Number.parseInt(v)) }) + +const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + +const builder = new ContractProcedureBuilderWithInput({ InputSchema: inputSchema, OutputSchema: undefined, errorMap: baseErrorMap, route: baseRoute }) + +describe('decoratedContractProcedure', () => { + it('is a procedure', () => { + expect(builder).toBeInstanceOf(ContractProcedure) + }) + + it('.errors', () => { + const errors = { BAD_GATEWAY: { data: z.object({ message: z.string() }) } } as const + + const applied = builder.errors(errors) + + expect(applied).toBeInstanceOf(ContractProcedureBuilderWithInput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual({ + ...baseErrorMap, + ...errors, + }) + expect(applied['~orpc'].InputSchema).toEqual(inputSchema) + expect(applied['~orpc'].route).toEqual(baseRoute) + }) + + it('.route', () => { + const applied = builder.route({ method: 'PATCH', description: 'new message' }) + + expect(applied).toBeInstanceOf(ContractProcedureBuilderWithInput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].InputSchema).toEqual(inputSchema) + expect(applied['~orpc'].route).toEqual({ + method: 'PATCH', + description: 'new message', + path: '/v1/users', + tags: ['tag'], + }) + }) + + it('.prefix', () => { + const applied = builder.prefix('/api') + + expect(applied).toBeInstanceOf(ContractProcedureBuilderWithInput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].InputSchema).toEqual(inputSchema) + expect(applied['~orpc'].route).toEqual({ + method: 'GET', + path: '/api/v1/users', + tags: ['tag'], + }) + }) + + it('.unshiftTag', () => { + const applied = builder.unshiftTag('tag2', 'tag3') + + expect(applied).toBeInstanceOf(ContractProcedureBuilderWithInput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].InputSchema).toEqual(inputSchema) + expect(applied['~orpc'].route).toEqual({ + method: 'GET', + tags: ['tag2', 'tag3', 'tag'], + path: '/v1/users', + }) + }) + + it('.output', () => { + const applied = builder.output(schema) + expect(applied).toBeInstanceOf(DecoratedContractProcedure) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].route).toEqual(baseRoute) + expect(applied['~orpc'].InputSchema).toEqual(inputSchema) + expect(applied['~orpc'].OutputSchema).toEqual(schema) + }) +}) diff --git a/packages/contract/src/procedure-builder-with-input.ts b/packages/contract/src/procedure-builder-with-input.ts new file mode 100644 index 000000000..e481bb26f --- /dev/null +++ b/packages/contract/src/procedure-builder-with-input.ts @@ -0,0 +1,44 @@ +import type { ErrorMap, ErrorMapGuard, ErrorMapSuggestions } from './error-map' +import type { RouteOptions } from './procedure' +import type { HTTPPath, Schema, SchemaOutput } from './types' +import { ContractProcedure } from './procedure' +import { DecoratedContractProcedure } from './procedure-decorated' + +/** + * `ContractProcedureBuilderWithInput` is a branch of `ContractProcedureBuilder` which it has input schema. + * + * Why? + * - prevents override input schema after .input + */ +export class ContractProcedureBuilderWithInput< + TInputSchema extends Schema, + TErrorMap extends ErrorMap, +> extends ContractProcedure { + errors & ErrorMapSuggestions>(errors: U): ContractProcedureBuilderWithInput { + const decorated = DecoratedContractProcedure.decorate(this).errors(errors) + return new ContractProcedureBuilderWithInput(decorated['~orpc']) + } + + route(route: RouteOptions): ContractProcedureBuilderWithInput { + const decorated = DecoratedContractProcedure.decorate(this).route(route) + return new ContractProcedureBuilderWithInput(decorated['~orpc']) + } + + prefix(prefix: HTTPPath): ContractProcedureBuilderWithInput { + const decorated = DecoratedContractProcedure.decorate(this).prefix(prefix) + return new ContractProcedureBuilderWithInput(decorated['~orpc']) + } + + unshiftTag(...tags: string[]): ContractProcedureBuilderWithInput { + const decorated = DecoratedContractProcedure.decorate(this).unshiftTag(...tags) + return new ContractProcedureBuilderWithInput(decorated['~orpc']) + } + + output(schema: U, example?: SchemaOutput): DecoratedContractProcedure { + return new DecoratedContractProcedure({ + ...this['~orpc'], + OutputSchema: schema, + outputExample: example, + }) + } +} diff --git a/packages/contract/src/procedure-builder-with-output.test-d.ts b/packages/contract/src/procedure-builder-with-output.test-d.ts new file mode 100644 index 000000000..5ac009963 --- /dev/null +++ b/packages/contract/src/procedure-builder-with-output.test-d.ts @@ -0,0 +1,62 @@ +import type { ContractProcedure } from './procedure' +import type { ContractProcedureBuilderWithOutput } from './procedure-builder-with-output' +import type { DecoratedContractProcedure } from './procedure-decorated' +import { z } from 'zod' + +const baseErrorMap = { + BASE: { + status: 500, + data: z.object({ + message: z.string(), + }), + }, +} + +const outputSchema = z.object({ input: z.string().transform(v => Number.parseInt(v)) }) + +const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + +const builder = {} as ContractProcedureBuilderWithOutput + +describe('DecoratedContractProcedure', () => { + it('is a contract procedure', () => { + expectTypeOf(builder).toMatchTypeOf>() + }) + + it('.errors', () => { + const errors = { BAD_GATEWAY: { data: z.object({ message: z.string() }) } } as const + + expectTypeOf(builder.errors(errors)) + .toEqualTypeOf>() + + // @ts-expect-error - not allow redefine error map + builder.errors({ BASE: baseErrorMap.BASE }) + }) + + it('.route', () => { + expectTypeOf(builder.route({ method: 'GET' })).toEqualTypeOf>() + + // @ts-expect-error - invalid method + builder.route({ method: 'HE' }) + }) + + it('.prefix', () => { + expectTypeOf(builder.prefix('/api')).toEqualTypeOf>() + + // @ts-expect-error - invalid prefix + builder.prefix(1) + }) + + it('.unshiftTag', () => { + expectTypeOf(builder.unshiftTag('tag', 'tag2')).toEqualTypeOf>() + + // @ts-expect-error - invalid tag + builder.unshiftTag(1) + }) + + it('.input', () => { + expectTypeOf(builder.input(schema)).toEqualTypeOf< + DecoratedContractProcedure + >() + }) +}) diff --git a/packages/contract/src/procedure-builder-with-output.test.ts b/packages/contract/src/procedure-builder-with-output.test.ts new file mode 100644 index 000000000..83f6c5350 --- /dev/null +++ b/packages/contract/src/procedure-builder-with-output.test.ts @@ -0,0 +1,98 @@ +import { z } from 'zod' +import { ContractProcedure } from './procedure' +import { ContractProcedureBuilderWithOutput } from './procedure-builder-with-output' +import { DecoratedContractProcedure } from './procedure-decorated' + +const baseErrorMap = { + BASE: { + status: 500, + data: z.object({ + message: z.string(), + }), + }, +} + +const baseRoute = { + method: 'GET', + path: '/v1/users', + tags: ['tag'], +} as const + +const outputSchema = z.object({ input: z.string().transform(v => Number.parseInt(v)) }) + +const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + +const builder = new ContractProcedureBuilderWithOutput({ InputSchema: undefined, OutputSchema: outputSchema, errorMap: baseErrorMap, route: baseRoute }) + +describe('decoratedContractProcedure', () => { + it('is a procedure', () => { + expect(builder).toBeInstanceOf(ContractProcedure) + }) + + it('.errors', () => { + const errors = { BAD_GATEWAY: { data: z.object({ message: z.string() }) } } as const + + const applied = builder.errors(errors) + + expect(applied).toBeInstanceOf(ContractProcedureBuilderWithOutput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual({ + ...baseErrorMap, + ...errors, + }) + expect(applied['~orpc'].OutputSchema).toEqual(outputSchema) + expect(applied['~orpc'].route).toEqual(baseRoute) + }) + + it('.route', () => { + const applied = builder.route({ method: 'PATCH', description: 'new message' }) + + expect(applied).toBeInstanceOf(ContractProcedureBuilderWithOutput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].OutputSchema).toEqual(outputSchema) + expect(applied['~orpc'].route).toEqual({ + method: 'PATCH', + description: 'new message', + path: '/v1/users', + tags: ['tag'], + }) + }) + + it('.prefix', () => { + const applied = builder.prefix('/api') + + expect(applied).toBeInstanceOf(ContractProcedureBuilderWithOutput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].OutputSchema).toEqual(outputSchema) + expect(applied['~orpc'].route).toEqual({ + method: 'GET', + path: '/api/v1/users', + tags: ['tag'], + }) + }) + + it('.unshiftTag', () => { + const applied = builder.unshiftTag('tag2', 'tag3') + + expect(applied).toBeInstanceOf(ContractProcedureBuilderWithOutput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].OutputSchema).toEqual(outputSchema) + expect(applied['~orpc'].route).toEqual({ + method: 'GET', + tags: ['tag2', 'tag3', 'tag'], + path: '/v1/users', + }) + }) + + it('.input', () => { + const applied = builder.input(schema) + expect(applied).toBeInstanceOf(DecoratedContractProcedure) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].route).toEqual(baseRoute) + expect(applied['~orpc'].InputSchema).toEqual(schema) + expect(applied['~orpc'].OutputSchema).toEqual(outputSchema) + }) +}) diff --git a/packages/contract/src/procedure-builder-with-output.ts b/packages/contract/src/procedure-builder-with-output.ts new file mode 100644 index 000000000..632c06e1b --- /dev/null +++ b/packages/contract/src/procedure-builder-with-output.ts @@ -0,0 +1,44 @@ +import type { ErrorMap, ErrorMapGuard, ErrorMapSuggestions } from './error-map' +import type { RouteOptions } from './procedure' +import type { HTTPPath, Schema, SchemaInput } from './types' +import { ContractProcedure } from './procedure' +import { DecoratedContractProcedure } from './procedure-decorated' + +/** + * `ContractProcedureBuilderWithOutput` is a branch of `ContractProcedureBuilder` which it has output schema. + * + * Why? + * - prevents override output schema after .output + */ +export class ContractProcedureBuilderWithOutput< + TOutputSchema extends Schema, + TErrorMap extends ErrorMap, +> extends ContractProcedure { + errors & ErrorMapSuggestions>(errors: U): ContractProcedureBuilderWithOutput { + const decorated = DecoratedContractProcedure.decorate(this).errors(errors) + return new ContractProcedureBuilderWithOutput(decorated['~orpc']) + } + + route(route: RouteOptions): ContractProcedureBuilderWithOutput { + const decorated = DecoratedContractProcedure.decorate(this).route(route) + return new ContractProcedureBuilderWithOutput(decorated['~orpc']) + } + + prefix(prefix: HTTPPath): ContractProcedureBuilderWithOutput { + const decorated = DecoratedContractProcedure.decorate(this).prefix(prefix) + return new ContractProcedureBuilderWithOutput(decorated['~orpc']) + } + + unshiftTag(...tags: string[]): ContractProcedureBuilderWithOutput { + const decorated = DecoratedContractProcedure.decorate(this).unshiftTag(...tags) + return new ContractProcedureBuilderWithOutput(decorated['~orpc']) + } + + input(schema: U, example?: SchemaInput): DecoratedContractProcedure { + return new DecoratedContractProcedure({ + ...this['~orpc'], + InputSchema: schema, + inputExample: example, + }) + } +} diff --git a/packages/contract/src/procedure-builder.test-d.ts b/packages/contract/src/procedure-builder.test-d.ts new file mode 100644 index 000000000..d5b22fa20 --- /dev/null +++ b/packages/contract/src/procedure-builder.test-d.ts @@ -0,0 +1,67 @@ +import type { ContractProcedure } from './procedure' +import type { ContractProcedureBuilder } from './procedure-builder' +import type { ContractProcedureBuilderWithInput } from './procedure-builder-with-input' +import type { ContractProcedureBuilderWithOutput } from './procedure-builder-with-output' +import { z } from 'zod' + +const baseErrorMap = { + BASE: { + status: 500, + data: z.object({ + message: z.string(), + }), + }, +} + +const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + +const builder = {} as ContractProcedureBuilder + +describe('DecoratedContractProcedure', () => { + it('is a contract procedure', () => { + expectTypeOf(builder).toMatchTypeOf>() + }) + + it('.errors', () => { + const errors = { BAD_GATEWAY: { data: z.object({ message: z.string() }) } } as const + + expectTypeOf(builder.errors(errors)) + .toEqualTypeOf>() + + // @ts-expect-error - not allow redefine error map + builder.errors({ BASE: baseErrorMap.BASE }) + }) + + it('.route', () => { + expectTypeOf(builder.route({ method: 'GET' })).toEqualTypeOf>() + + // @ts-expect-error - invalid method + builder.route({ method: 'HE' }) + }) + + it('.prefix', () => { + expectTypeOf(builder.prefix('/api')).toEqualTypeOf>() + + // @ts-expect-error - invalid prefix + builder.prefix(1) + }) + + it('.unshiftTag', () => { + expectTypeOf(builder.unshiftTag('tag', 'tag2')).toEqualTypeOf>() + + // @ts-expect-error - invalid tag + builder.unshiftTag(1) + }) + + it('.input', () => { + expectTypeOf(builder.input(schema)).toEqualTypeOf< + ContractProcedureBuilderWithInput + >() + }) + + it('.output', () => { + expectTypeOf(builder.output(schema)).toEqualTypeOf< + ContractProcedureBuilderWithOutput + >() + }) +}) diff --git a/packages/contract/src/procedure-builder.test.ts b/packages/contract/src/procedure-builder.test.ts new file mode 100644 index 000000000..138be0793 --- /dev/null +++ b/packages/contract/src/procedure-builder.test.ts @@ -0,0 +1,100 @@ +import { z } from 'zod' +import { ContractProcedure } from './procedure' +import { ContractProcedureBuilder } from './procedure-builder' +import { ContractProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ContractProcedureBuilderWithOutput } from './procedure-builder-with-output' + +const baseErrorMap = { + BASE: { + status: 500, + data: z.object({ + message: z.string(), + }), + }, +} + +const baseRoute = { + method: 'GET', + path: '/v1/users', + tags: ['tag'], +} as const + +const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + +const builder = new ContractProcedureBuilder({ InputSchema: undefined, OutputSchema: undefined, errorMap: baseErrorMap, route: baseRoute }) + +describe('decoratedContractProcedure', () => { + it('is a procedure', () => { + expect(builder).toBeInstanceOf(ContractProcedure) + }) + + it('.errors', () => { + const errors = { BAD_GATEWAY: { data: z.object({ message: z.string() }) } } as const + + const applied = builder.errors(errors) + + expect(applied).toBeInstanceOf(ContractProcedureBuilder) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual({ + ...baseErrorMap, + ...errors, + }) + expect(applied['~orpc'].route).toEqual(baseRoute) + }) + + it('.route', () => { + const applied = builder.route({ method: 'PATCH', description: 'new message' }) + + expect(applied).toBeInstanceOf(ContractProcedureBuilder) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].route).toEqual({ + method: 'PATCH', + description: 'new message', + path: '/v1/users', + tags: ['tag'], + }) + }) + + it('.prefix', () => { + const applied = builder.prefix('/api') + + expect(applied).toBeInstanceOf(ContractProcedureBuilder) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].route).toEqual({ + method: 'GET', + path: '/api/v1/users', + tags: ['tag'], + }) + }) + + it('.unshiftTag', () => { + const applied = builder.unshiftTag('tag2', 'tag3') + + expect(applied).toBeInstanceOf(ContractProcedureBuilder) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].route).toEqual({ + method: 'GET', + tags: ['tag2', 'tag3', 'tag'], + path: '/v1/users', + }) + }) + + it('.input', () => { + const applied = builder.input(schema) + expect(applied).toBeInstanceOf(ContractProcedureBuilderWithInput) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].route).toEqual(baseRoute) + expect(applied['~orpc'].InputSchema).toEqual(schema) + }) + + it('.output', () => { + const applied = builder.output(schema) + expect(applied).toBeInstanceOf(ContractProcedureBuilderWithOutput) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].route).toEqual(baseRoute) + expect(applied['~orpc'].OutputSchema).toEqual(schema) + }) +}) diff --git a/packages/contract/src/procedure-builder.ts b/packages/contract/src/procedure-builder.ts new file mode 100644 index 000000000..52b625261 --- /dev/null +++ b/packages/contract/src/procedure-builder.ts @@ -0,0 +1,47 @@ +import type { ErrorMap, ErrorMapGuard, ErrorMapSuggestions } from './error-map' +import type { RouteOptions } from './procedure' +import type { HTTPPath, Schema, SchemaInput, SchemaOutput } from './types' +import { ContractProcedure } from './procedure' +import { ContractProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ContractProcedureBuilderWithOutput } from './procedure-builder-with-output' +import { DecoratedContractProcedure } from './procedure-decorated' + +export class ContractProcedureBuilder< + TErrorMap extends ErrorMap, +> extends ContractProcedure { + errors & ErrorMapSuggestions>(errors: U): ContractProcedureBuilder< TErrorMap & U> { + const decorated = DecoratedContractProcedure.decorate(this).errors(errors) + return new ContractProcedureBuilder(decorated['~orpc']) + } + + route(route: RouteOptions): ContractProcedureBuilder< TErrorMap> { + const decorated = DecoratedContractProcedure.decorate(this).route(route) + return new ContractProcedureBuilder(decorated['~orpc']) + } + + prefix(prefix: HTTPPath): ContractProcedureBuilder< TErrorMap> { + const decorated = DecoratedContractProcedure.decorate(this).prefix(prefix) + return new ContractProcedureBuilder(decorated['~orpc']) + } + + unshiftTag(...tags: string[]): ContractProcedureBuilder< TErrorMap> { + const decorated = DecoratedContractProcedure.decorate(this).unshiftTag(...tags) + return new ContractProcedureBuilder(decorated['~orpc']) + } + + input(schema: U, example?: SchemaInput): ContractProcedureBuilderWithInput { + return new ContractProcedureBuilderWithInput({ + ...this['~orpc'], + InputSchema: schema, + inputExample: example, + }) + } + + output(schema: U, example?: SchemaOutput): ContractProcedureBuilderWithOutput { + return new ContractProcedureBuilderWithOutput({ + ...this['~orpc'], + 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 861cbacf0..78ac171bb 100644 --- a/packages/contract/src/procedure-decorated.test-d.ts +++ b/packages/contract/src/procedure-decorated.test-d.ts @@ -14,170 +14,60 @@ const baseErrorMap = { const InputSchema = z.object({ input: z.string().transform(val => Number(val)) }) const OutputSchema = z.object({ output: z.string().transform(val => Number(val)) }) -const decorated = new DecoratedContractProcedure({ InputSchema, OutputSchema, errorMap: baseErrorMap }) +const baseRoute = { + method: 'GET', + path: '/api/v1/users', +} as const -describe('decorate', () => { - const schema = z.object({ - value: z.string(), - }) +const decorated = new DecoratedContractProcedure({ InputSchema, OutputSchema, errorMap: baseErrorMap, route: baseRoute }) - it('works', () => { - const simpleProcedure = new ContractProcedure({ InputSchema: schema, OutputSchema: undefined, errorMap: baseErrorMap }) +describe('DecoratedContractProcedure', () => { + it('is a contract procedure', () => { + expectTypeOf(decorated).toMatchTypeOf>() + }) - expectTypeOf(DecoratedContractProcedure.decorate(simpleProcedure)).toEqualTypeOf< - DecoratedContractProcedure + it('.decorate', () => { + expectTypeOf( + DecoratedContractProcedure.decorate(new ContractProcedure({ InputSchema, OutputSchema, errorMap: baseErrorMap })), + ).toEqualTypeOf< + DecoratedContractProcedure >() - expectTypeOf(DecoratedContractProcedure.decorate(decorated)).toEqualTypeOf< + expectTypeOf( + DecoratedContractProcedure.decorate(decorated), + ).toEqualTypeOf< DecoratedContractProcedure >() }) -}) -describe('route', () => { - it('return ContractProcedure', () => { - const routed = decorated.route({}) - expectTypeOf(routed).toEqualTypeOf>() + it('.errors', () => { + const errors = { BAD_GATEWAY: { data: z.object({ message: z.string() }) } } as const + + expectTypeOf(decorated.errors(errors)) + .toEqualTypeOf>() + + // @ts-expect-error - not allow redefine error map + decorated.errors({ BASE: baseErrorMap.BASE }) }) - it('throw error on invalid route', () => { - decorated.route({ method: 'POST' }) + it('.route', () => { + expectTypeOf(decorated.route({ method: 'GET' })).toEqualTypeOf>() + // @ts-expect-error - invalid method decorated.route({ method: 'HE' }) - - decorated.route({ method: 'GET', path: '/api/v1/users' }) - // @ts-expect-error - invalid path - decorated.route({ method: 'GET', path: '' }) }) -}) -describe('prefix', () => { - it('return ContractProcedure', () => { - const prefixed = decorated.prefix('/api') - expectTypeOf(prefixed).toEqualTypeOf>() - }) + it('.prefix', () => { + expectTypeOf(decorated.prefix('/api')).toEqualTypeOf>() - it('throw error on invalid prefix', () => { - decorated.prefix('/api') // @ts-expect-error - invalid prefix decorated.prefix(1) - // @ts-expect-error - invalid prefix - decorated.prefix('') }) -}) -describe('pushTag', () => { - it('return ContractProcedure', () => { - const tagged = decorated.unshiftTag('tag', 'tag2') - expectTypeOf(tagged).toEqualTypeOf>() - }) + it('.unshiftTag', () => { + expectTypeOf(decorated.unshiftTag('tag', 'tag2')).toEqualTypeOf>() - it('throw error on invalid tag', () => { - decorated.unshiftTag('tag') - decorated.unshiftTag('tag', 'tag2') // @ts-expect-error - invalid tag decorated.unshiftTag(1) - // @ts-expect-error - invalid tag - decorated.unshiftTag({}) - }) -}) - -describe('input', () => { - const schema = z.object({ - value: z.string(), - }) - - const schema2 = z.number() - - it('can modify one or multiple times', () => { - const modified = decorated.input(schema) - - expectTypeOf(modified).toEqualTypeOf< - DecoratedContractProcedure - >() - - expectTypeOf(modified.input(schema2)).toEqualTypeOf< - DecoratedContractProcedure - >() - }) - - it('typed example', () => { - decorated.input(schema, { value: 'example' }) - decorated.input(schema2, 123) - - // @ts-expect-error - invalid example - decorated.input(schema, { }) - // @ts-expect-error - invalid example - decorated.input(schema2, 'string') - }) -}) - -describe('output', () => { - const schema = z.object({ - value: z.string(), - }) - - const schema2 = z.number() - - it('can modify one or multiple times', () => { - const modified = decorated.output(schema) - - expectTypeOf(modified).toEqualTypeOf< - DecoratedContractProcedure - >() - - expectTypeOf(modified.output(schema2)).toEqualTypeOf< - DecoratedContractProcedure - >() - }) - - it('typed example', () => { - decorated.output(schema, { value: 'example' }) - decorated.output(schema2, 123) - - // @ts-expect-error - invalid example - decorated.output(schema, { }) - // @ts-expect-error - invalid example - decorated.output(schema2, 'string') - }) -}) - -describe('errors', () => { - const schema = z.object({ - value: z.string(), - }) - - const schema2 = z.number() - - it('can modify one or multiple times', () => { - const errors = { - BAD_GATEWAY: { - data: schema, - }, - } - - const modified = decorated.errors(errors) - - expectTypeOf(modified).toEqualTypeOf< - DecoratedContractProcedure - >() - - const errors2 = { - UNAUTHORIZED: { - status: 2001, - data: schema2, - }, - } - - expectTypeOf(modified.errors(errors2)).toEqualTypeOf< - DecoratedContractProcedure - >() - }) - - it('prevent redefine old errorMap', () => { - // @ts-expect-error - not allow redefine errorMap - decorated.errors({ BASE: baseErrorMap.BASE }) - // @ts-expect-error - not allow redefine errorMap --- even with undefined - decorated.errors({ BASE: undefined }) }) }) diff --git a/packages/contract/src/procedure-decorated.test.ts b/packages/contract/src/procedure-decorated.test.ts index 3c86e5c90..bcc757135 100644 --- a/packages/contract/src/procedure-decorated.test.ts +++ b/packages/contract/src/procedure-decorated.test.ts @@ -14,166 +14,116 @@ const baseErrorMap = { const InputSchema = z.object({ input: z.string().transform(val => Number(val)) }) const OutputSchema = z.object({ output: z.string().transform(val => Number(val)) }) -describe('decorate', () => { - const procedure = new ContractProcedure({ InputSchema, OutputSchema, errorMap: baseErrorMap }) +const baseRoute = { + method: 'GET', + path: '/v1/users', + tags: ['tag'], +} as const - it('works', () => { - const decorated = DecoratedContractProcedure.decorate(procedure) - expect(decorated).toBeInstanceOf(DecoratedContractProcedure) - expect(decorated['~orpc']).toBe(procedure['~orpc']) - }) -}) - -describe('route', () => { - const decorated = new DecoratedContractProcedure({ InputSchema, OutputSchema, errorMap: baseErrorMap }) - - it('works', () => { - const route = { method: 'GET', path: '/path' } as const - const routed = decorated.route(route) - expect(routed).toBeInstanceOf(DecoratedContractProcedure) - expect(routed['~orpc']).toEqual({ route, errorMap: baseErrorMap, InputSchema, OutputSchema }) - }) +const decorated = new DecoratedContractProcedure({ InputSchema, OutputSchema, errorMap: baseErrorMap, route: baseRoute }) - it('not reference', () => { - const routed = decorated.route({}) - expect(routed['~orpc']).not.toBe(decorated['~orpc']) - expect(routed).not.toBe(decorated) +describe('decoratedContractProcedure', () => { + it('is a procedure', () => { + expect(decorated).toBeInstanceOf(ContractProcedure) }) - it('should spread merge route options', () => { - const routed = decorated - .route({ inputStructure: 'detailed' }) - .route({ outputStructure: 'detailed' }) - - expect(routed['~orpc']).toEqual({ - route: { - inputStructure: 'detailed', - outputStructure: 'detailed', - }, - errorMap: baseErrorMap, - InputSchema, - OutputSchema, - }) - }) -}) - -describe('prefix', () => { - const decorated = new DecoratedContractProcedure({ InputSchema, OutputSchema, route: { path: '/path' }, errorMap: baseErrorMap }) + it('.decorate', () => { + const applied = DecoratedContractProcedure.decorate(new ContractProcedure({ InputSchema, OutputSchema, errorMap: baseErrorMap, route: baseRoute })) - it('works', () => { - const prefixed = decorated.prefix('/prefix') - expect(prefixed).toBeInstanceOf(DecoratedContractProcedure) - expect(prefixed['~orpc']).toEqual({ route: { path: '/prefix/path' }, errorMap: baseErrorMap, InputSchema, OutputSchema }) - }) + expect(applied).toEqual(decorated) + expect(applied).not.toBe(decorated) - it('do nothing on non-path procedure', () => { - const decorated = new DecoratedContractProcedure({ InputSchema: undefined, OutputSchema: undefined, errorMap: baseErrorMap }) - const prefixed = decorated.prefix('/prefix') - expect(prefixed).toBeInstanceOf(DecoratedContractProcedure) - expect(prefixed['~orpc']).toEqual({ errorMap: baseErrorMap }) + expect(DecoratedContractProcedure.decorate(decorated)) + .toBe(decorated) }) - it('not reference', () => { - const prefixed = decorated.prefix('/prefix') - expect(prefixed['~orpc']).not.toBe(decorated['~orpc']) - expect(prefixed).not.toBe(decorated) - }) -}) + it('.errors', () => { + const errors = { BAD_GATEWAY: { data: z.object({ message: z.string() }) } } as const -describe('unshiftTag', () => { - const decorated = new DecoratedContractProcedure({ InputSchema: undefined, OutputSchema: undefined, errorMap: baseErrorMap }) + const applied = decorated.errors(errors) - it('works', () => { - const tagged = decorated.unshiftTag('tag1', 'tag2') - expect(tagged).toBeInstanceOf(DecoratedContractProcedure) - expect(tagged['~orpc']).toEqual({ route: { tags: ['tag1', 'tag2'] }, errorMap: baseErrorMap }) - - const tagged2 = tagged.unshiftTag('tag3') - expect(tagged2).toBeInstanceOf(DecoratedContractProcedure) - expect(tagged2['~orpc']).toEqual({ route: { tags: ['tag3', 'tag1', 'tag2'] }, errorMap: baseErrorMap }) - }) - - it('not reference', () => { - 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', () => { - const decorated = new DecoratedContractProcedure({ InputSchema, OutputSchema, errorMap: baseErrorMap }) - const schema = z.object({ - value: z.string(), + expect(applied).toBeInstanceOf(DecoratedContractProcedure) + expect(applied).not.toBe(decorated) + expect(applied['~orpc'].errorMap).toEqual({ + ...baseErrorMap, + ...errors, + }) + expect(applied['~orpc'].InputSchema).toEqual(InputSchema) + expect(applied['~orpc'].OutputSchema).toEqual(OutputSchema) + expect(applied['~orpc'].route).toEqual(baseRoute) + }) + + it('.route', () => { + const applied = decorated.route({ method: 'PATCH', description: 'new message' }) + + expect(applied).toBeInstanceOf(DecoratedContractProcedure) + expect(applied).not.toBe(decorated) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].InputSchema).toEqual(InputSchema) + expect(applied['~orpc'].OutputSchema).toEqual(OutputSchema) + expect(applied['~orpc'].route).toEqual({ + method: 'PATCH', + description: 'new message', + path: '/v1/users', + tags: ['tag'], + }) }) - const example = { value: 'example' } - it('works', () => { - const inputted = decorated.input(schema, example) - expect(inputted).toBeInstanceOf(DecoratedContractProcedure) - expect(inputted['~orpc']).toEqual({ InputSchema: schema, inputExample: example, errorMap: baseErrorMap, OutputSchema }) - }) + describe('.prefix', () => { + it('when has path', () => { + const applied = decorated.prefix('/api') + + expect(applied).toBeInstanceOf(DecoratedContractProcedure) + expect(applied).not.toBe(decorated) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].InputSchema).toEqual(InputSchema) + expect(applied['~orpc'].OutputSchema).toEqual(OutputSchema) + expect(applied['~orpc'].route).toEqual({ + method: 'GET', + path: '/api/v1/users', + tags: ['tag'], + }) + }) - it('not reference', () => { - const inputted = decorated.input(schema, example) - expect(inputted['~orpc']).not.toBe(decorated['~orpc']) - expect(inputted).not.toBe(decorated) + it('when has no path', () => { + const decorated = new DecoratedContractProcedure({ InputSchema, OutputSchema, errorMap: baseErrorMap }) + const applied = decorated.prefix('/api') + expect(applied['~orpc'].route).toEqual(undefined) + }) }) -}) -describe('output', () => { - const decorated = new DecoratedContractProcedure({ InputSchema, OutputSchema, errorMap: baseErrorMap }) - const schema = z.object({ - value: z.string(), - }) - const example = { value: 'example' } + describe('.unshiftTag', () => { + it('works', () => { + const applied = decorated.unshiftTag('tag2', 'tag3') + + expect(applied).toBeInstanceOf(DecoratedContractProcedure) + expect(applied).not.toBe(decorated) + expect(applied['~orpc'].errorMap).toEqual(baseErrorMap) + expect(applied['~orpc'].InputSchema).toEqual(InputSchema) + expect(applied['~orpc'].OutputSchema).toEqual(OutputSchema) + expect(applied['~orpc'].route).toEqual({ + method: 'GET', + tags: ['tag2', 'tag3', 'tag'], + path: '/v1/users', + }) + }) - it('works', () => { - const outputted = decorated.output(schema, example) - expect(outputted).toBeInstanceOf(DecoratedContractProcedure) - expect(outputted['~orpc']).toEqual({ OutputSchema: schema, outputExample: example, errorMap: baseErrorMap, InputSchema }) - }) + it('conflict with existing tag', () => { + const applied = decorated.unshiftTag('tag', 'tag2') + expect(applied['~orpc'].route).toEqual({ + method: 'GET', + tags: ['tag', 'tag2'], + path: '/v1/users', + }) + }) - it('not reference', () => { - const outputted = decorated.output(schema, example) - expect(outputted['~orpc']).not.toBe(decorated['~orpc']) - expect(outputted).not.toBe(decorated) - }) -}) + it('decorated without existing tag', () => { + const decorated = new DecoratedContractProcedure({ InputSchema, OutputSchema, errorMap: baseErrorMap }) -describe('errors', () => { - const decorated = new DecoratedContractProcedure({ InputSchema, OutputSchema, errorMap: baseErrorMap }) - const schema = z.object({ - value: z.string(), - }) - const errors = { - BAD_GATEWAY: { - status: 400, - data: schema, - }, - } - - it('works', () => { - const errored = decorated.errors(errors) - expect(errored).toBeInstanceOf(DecoratedContractProcedure) - expect(errored['~orpc']).toEqual({ - InputSchema, - OutputSchema, - errorMap: { - ...errors, - ...baseErrorMap, - }, + const applied = decorated.unshiftTag('tag', 'tag2') + expect(applied['~orpc'].route).toEqual({ + tags: ['tag', 'tag2'], + }) }) }) - - it('not reference', () => { - const errored = decorated.errors(errors) - expect(errored['~orpc']).not.toBe(decorated['~orpc']) - expect(errored).not.toBe(decorated) - }) }) diff --git a/packages/contract/src/procedure-decorated.ts b/packages/contract/src/procedure-decorated.ts index 0cdac8a54..c6da59095 100644 --- a/packages/contract/src/procedure-decorated.ts +++ b/packages/contract/src/procedure-decorated.ts @@ -1,6 +1,6 @@ import type { ErrorMap, ErrorMapGuard, ErrorMapSuggestions } from './error-map' import type { RouteOptions } from './procedure' -import type { HTTPPath, Schema, SchemaInput, SchemaOutput } from './types' +import type { HTTPPath, Schema } from './types' import { ContractProcedure } from './procedure' export class DecoratedContractProcedure< @@ -18,6 +18,16 @@ export class DecoratedContractProcedure< return new DecoratedContractProcedure(procedure['~orpc']) } + errors & ErrorMapSuggestions>(errors: U): DecoratedContractProcedure { + return new DecoratedContractProcedure({ + ...this['~orpc'], + errorMap: { + ...this['~orpc'].errorMap, + ...errors, + }, + }) + } + route(route: RouteOptions): DecoratedContractProcedure { return new DecoratedContractProcedure({ ...this['~orpc'], @@ -54,30 +64,4 @@ export class DecoratedContractProcedure< }, }) } - - input(schema: U, example?: SchemaInput): DecoratedContractProcedure { - return new DecoratedContractProcedure({ - ...this['~orpc'], - InputSchema: schema, - inputExample: example, - }) - } - - output(schema: U, example?: SchemaOutput): DecoratedContractProcedure { - return new DecoratedContractProcedure({ - ...this['~orpc'], - OutputSchema: schema, - outputExample: example, - }) - } - - errors & ErrorMapSuggestions>(errors: U): DecoratedContractProcedure { - return new DecoratedContractProcedure({ - ...this['~orpc'], - errorMap: { - ...this['~orpc'].errorMap, - ...errors, - }, - }) - } } diff --git a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts index 1c155836a..975d93fb8 100644 --- a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts @@ -216,8 +216,9 @@ describe.each(hono)('openAPIHandler: %s', (_, HonoConstructor) => { errorMap: {}, }), handler: vi.fn(), - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }), } @@ -243,8 +244,9 @@ describe.each(hono)('openAPIHandler: %s', (_, HonoConstructor) => { errorMap: {}, }), handler: vi.fn(), - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }), } diff --git a/packages/server/src/adapters/fetch/orpc-handler.test.ts b/packages/server/src/adapters/fetch/orpc-handler.test.ts index e3a46e923..0db82d4f0 100644 --- a/packages/server/src/adapters/fetch/orpc-handler.test.ts +++ b/packages/server/src/adapters/fetch/orpc-handler.test.ts @@ -17,8 +17,9 @@ describe('rpcHandler', () => { errorMap: {}, }), handler: vi.fn(), - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) const pong = new Procedure({ contract: new ContractProcedure({ @@ -27,8 +28,9 @@ describe('rpcHandler', () => { errorMap: {}, }), handler: vi.fn(), - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) const router = { diff --git a/packages/server/src/builder-with-errors-middlewares.test-d.ts b/packages/server/src/builder-with-errors-middlewares.test-d.ts new file mode 100644 index 000000000..f26164191 --- /dev/null +++ b/packages/server/src/builder-with-errors-middlewares.test-d.ts @@ -0,0 +1,162 @@ +import type { BuilderWithErrorsMiddlewares } from './builder-with-errors-middlewares' +import type { ORPCErrorConstructorMap } from './error' +import type { Lazy } from './lazy' +import type { MiddlewareOutputFn } from './middleware' +import type { ANY_PROCEDURE, Procedure } from './procedure' +import type { ProcedureBuilder } from './procedure-builder' +import type { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import type { ProcedureBuilderWithOutput } from './procedure-builder-with-output' +import type { DecoratedProcedure } from './procedure-decorated' +import type { AdaptedRouter, RouterBuilder } from './router-builder' +import type { WELL_CONTEXT } from './types' +import { z } from 'zod' + +const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + +const baseErrors = { + BASE: { + data: z.string(), + }, +} + +const errors = { + CODE: { + status: 404, + data: z.object({ why: z.string() }), + }, +} + +const builder = {} as BuilderWithErrorsMiddlewares<{ db: string }, { auth?: boolean }, typeof baseErrors> + +describe('BuilderWithErrorsMiddlewares', () => { + it('.errors', () => { + expectTypeOf(builder.errors(errors)).toEqualTypeOf>() + + // @ts-expect-error --- not allow redefine error map + builder.errors({ BASE: baseErrors.BASE }) + }) + + it('.use', () => { + const applied = builder.use(({ context, next, path, procedure, errors }, input, output) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ db: string } & { auth?: boolean }>() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(output).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() + + return next({ + context: { + extra: true, + }, + }) + }) + + expectTypeOf(applied).toEqualTypeOf < BuilderWithErrorsMiddlewares < { db: string }, { auth?: boolean } & { extra: boolean }, typeof baseErrors>>() + + // @ts-expect-error --- conflict context + builder.use(({ next }) => next({ db: 123 })) + // @ts-expect-error --- input is not match + builder.use(({ next }, input: 'invalid') => next({})) + // @ts-expect-error --- output is not match + builder.use(({ next }, input, output: MiddlewareOutputFn<'invalid'>) => next({})) + }) + + it('.route', () => { + expectTypeOf(builder.route({ path: '/test', method: 'GET' })).toEqualTypeOf< + ProcedureBuilder<{ db: string }, { auth?: boolean }, typeof baseErrors> + >() + }) + + it('.input', () => { + expectTypeOf(builder.input(schema)).toEqualTypeOf< + ProcedureBuilderWithInput<{ db: string }, { auth?: boolean }, typeof schema, typeof baseErrors> + >() + }) + + it('.output', () => { + expectTypeOf(builder.output(schema)).toEqualTypeOf< + ProcedureBuilderWithOutput<{ db: string }, { auth?: boolean }, typeof schema, typeof baseErrors> + >() + }) + + it('.handler', () => { + const procedure = builder.handler(({ input, context, procedure, path, signal, errors }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ db: string } & { auth?: boolean }>() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(signal).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() + + return 456 + }) + + expectTypeOf(procedure).toMatchTypeOf< + DecoratedProcedure<{ db: string }, { auth?: boolean }, undefined, undefined, number, typeof baseErrors> + >() + }) + + it('.prefix', () => { + expectTypeOf(builder.prefix('/test')).toEqualTypeOf< + RouterBuilder<{ db: string }, { auth?: boolean }, typeof baseErrors> + >() + }) + + it('.tag', () => { + expectTypeOf(builder.tag('test', 'test2')).toEqualTypeOf< + RouterBuilder<{ db: string }, { auth?: boolean }, typeof baseErrors> + >() + }) + + it('.router', () => { + const router = { + ping: {} as Procedure<{ db: string }, { auth?: boolean }, undefined, undefined, unknown, typeof errors>, + pong: {} as Procedure>, + } + + expectTypeOf(builder.router(router)).toEqualTypeOf< + AdaptedRouter<{ db: string }, typeof router, typeof baseErrors> + >() + + builder.router({ + // @ts-expect-error - context is not match + ping: {} as Procedure<{ auth: 'invalid' }, undefined, undefined, undefined, unknown, typeof errors>, + }) + + const invalidErrorMap = { + BASE: { + ...baseErrors.BASE, + status: 400, + }, + } + + builder.router({ + // @ts-expect-error - error map is not match + ping: {} as ContractProcedure, + }) + }) + + it('.lazy', () => { + const router = { + ping: {} as Procedure<{ db: string }, { auth?: boolean }, undefined, undefined, unknown, typeof errors>, + pong: {} as Procedure>, + } + + expectTypeOf(builder.lazy(() => Promise.resolve({ default: router }))).toEqualTypeOf< + AdaptedRouter<{ db: string }, Lazy, typeof baseErrors> + >() + + // @ts-expect-error - context is not match + builder.lazy(() => Promise.resolve({ default: { + ping: {} as Procedure<{ auth: 'invalid' }, undefined, undefined, undefined, unknown, typeof errors>, + } })) + + // @ts-expect-error - error map is not match + builder.lazy(() => Promise.resolve({ + default: { + ping: {} as Procedure, + }, + })) + }) +}) diff --git a/packages/server/src/builder-with-errors-middlewares.test.ts b/packages/server/src/builder-with-errors-middlewares.test.ts new file mode 100644 index 000000000..60151dc9a --- /dev/null +++ b/packages/server/src/builder-with-errors-middlewares.test.ts @@ -0,0 +1,169 @@ +import { z } from 'zod' +import { BuilderWithErrorsMiddlewares } from './builder-with-errors-middlewares' +import { unlazy } from './lazy' +import { ProcedureBuilder } from './procedure-builder' +import { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ProcedureBuilderWithOutput } from './procedure-builder-with-output' +import { DecoratedProcedure } from './procedure-decorated' +import { RouterBuilder } from './router-builder' + +vi.mock('./router-builder', async (origin) => { + const RouterBuilder = vi.fn() + RouterBuilder.prototype.router = vi.fn(() => '__router__') + RouterBuilder.prototype.lazy = vi.fn(() => '__lazy__') + + return { + RouterBuilder, + } +}) + +const RouterBuilderRouterSpy = vi.spyOn(RouterBuilder.prototype, 'router') +const RouterBuilderLazySpy = vi.spyOn(RouterBuilder.prototype, 'lazy') + +const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + +const errors = { + CODE: { + status: 404, + data: z.object({ why: z.string() }), + }, +} + +const baseErrors = { + BASE: { + status: 404, + data: z.object({ why: z.string() }), + }, +} + +const mid = vi.fn() + +const builder = new BuilderWithErrorsMiddlewares({ + middlewares: [mid], + errorMap: baseErrors, + inputValidationIndex: 1, + outputValidationIndex: 1, +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('builder', () => { + it('.errors', () => { + const applied = builder.errors(errors) + expect(applied).not.toBe(builder) + expect(applied).toBeInstanceOf(BuilderWithErrorsMiddlewares) + expect(applied['~orpc'].errorMap).toEqual({ ...baseErrors, ...errors }) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + }) + + it('.use', () => { + const mid2 = vi.fn() + const applied = builder.use(mid2) + expect(applied).toBeInstanceOf(BuilderWithErrorsMiddlewares) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual(baseErrors) + expect(applied['~orpc'].middlewares).toEqual([mid, mid2]) + expect(applied['~orpc'].inputValidationIndex).toEqual(2) + expect(applied['~orpc'].outputValidationIndex).toEqual(2) + }) + + it('.route', () => { + const route = { path: '/test', method: 'GET' } as const + const applied = builder.route(route) + expect(applied).toBeInstanceOf(ProcedureBuilder) + expect(applied['~orpc'].contract['~orpc'].route).toEqual(route) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + }) + + it('.input', () => { + const applied = builder.input(schema) + expect(applied).toBeInstanceOf(ProcedureBuilderWithInput) + expect(applied['~orpc'].contract['~orpc'].InputSchema).toEqual(schema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + }) + + it('.output', () => { + const applied = builder.output(schema) + expect(applied).toBeInstanceOf(ProcedureBuilderWithOutput) + expect(applied['~orpc'].contract['~orpc'].OutputSchema).toEqual(schema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + }) + + it('.handler', () => { + const handler = vi.fn() + const applied = builder.handler(handler) + expect(applied).toBeInstanceOf(DecoratedProcedure) + expect(applied['~orpc'].handler).toEqual(handler) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + }) + + it('.prefix', () => { + const applied = builder.prefix('/test') + expect(applied).toBe(vi.mocked(RouterBuilder).mock.results[0]!.value) + expect(applied).toBeInstanceOf(RouterBuilder) + expect(RouterBuilder).toHaveBeenCalledWith(expect.objectContaining({ + middlewares: [mid], + errorMap: baseErrors, + prefix: '/test', + })) + }) + + it('.tag', () => { + const applied = builder.tag('test', 'test2') + expect(applied).toBe(vi.mocked(RouterBuilder).mock.results[0]!.value) + expect(applied).toBeInstanceOf(RouterBuilder) + expect(RouterBuilder).toHaveBeenCalledWith(expect.objectContaining({ + middlewares: [mid], + errorMap: baseErrors, + tags: ['test', 'test2'], + })) + }) + + it('.router', () => { + const router = { + ping: {} as any, + pong: {} as any, + } + + const applied = builder.router(router) + + expect(applied).toBe(RouterBuilderRouterSpy.mock.results[0]!.value) + expect(RouterBuilderRouterSpy).toHaveBeenCalledWith(router) + expect(RouterBuilder).toHaveBeenCalledWith(expect.objectContaining({ + middlewares: [mid], + errorMap: baseErrors, + })) + }) + + it('.lazy', () => { + const router = { + ping: {} as any, + pong: {} as any, + } + + const applied = builder.lazy(() => Promise.resolve({ default: router })) + + expect(applied).toBe(RouterBuilderLazySpy.mock.results[0]!.value) + expect(RouterBuilderLazySpy).toHaveBeenCalledWith(expect.any(Function)) + expect(RouterBuilder).toHaveBeenCalledWith(expect.objectContaining({ + middlewares: [mid], + errorMap: baseErrors, + })) + expect(unlazy(RouterBuilderLazySpy.mock.results[0]!.value)).resolves.toEqual({ default: '__lazy__' }) + }) +}) diff --git a/packages/server/src/builder-with-errors-middlewares.ts b/packages/server/src/builder-with-errors-middlewares.ts new file mode 100644 index 000000000..238405862 --- /dev/null +++ b/packages/server/src/builder-with-errors-middlewares.ts @@ -0,0 +1,136 @@ +import type { ContractRouter, ErrorMap, ErrorMapGuard, ErrorMapSuggestions, HTTPPath, RouteOptions, Schema, SchemaInput, SchemaOutput, StrictErrorMap } from '@orpc/contract' +import type { ContextGuard } from './context' +import type { ORPCErrorConstructorMap } from './error' +import type { FlattenLazy } from './lazy' +import type { Middleware } from './middleware' +import type { ProcedureHandler } from './procedure' +import type { Router } from './router' +import type { AdaptedRouter } from './router-builder' +import type { Context, MergeContext } from './types' +import { ContractProcedure } from '@orpc/contract' +import { ProcedureBuilder } from './procedure-builder' +import { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ProcedureBuilderWithOutput } from './procedure-builder-with-output' +import { DecoratedProcedure } from './procedure-decorated' +import { RouterBuilder } from './router-builder' + +export interface BuilderWithErrorsMiddlewaresDef { + types?: { context: TContext } + errorMap: TErrorMap + middlewares: Middleware, Partial | undefined, unknown, any, ORPCErrorConstructorMap>[] + inputValidationIndex: number + outputValidationIndex: number +} + +/** + * `BuilderWithErrorsMiddlewares` is a combination of `BuilderWithErrorsMiddlewares` and `BuilderWithErrors`. + * + * Why? + * - prevents .middleware after .use (can mislead the behavior) + * - prevents .contract after .errors (add error map to existing contract can make the contract invalid) + * - prevents .context after .use (middlewares required current context, so it tricky when change the current context) + * + */ +export class BuilderWithErrorsMiddlewares { + '~type' = 'BuilderWithErrorsMiddlewares' as const + '~orpc': BuilderWithErrorsMiddlewaresDef + + constructor(def: BuilderWithErrorsMiddlewaresDef) { + this['~orpc'] = def + } + + errors & ErrorMapSuggestions>(errors: U): BuilderWithErrorsMiddlewares { + return new BuilderWithErrorsMiddlewares({ + ...this['~orpc'], + errorMap: { + ...this['~orpc'].errorMap, + ...errors, + }, + }) + } + + use>>( + middleware: Middleware, U, unknown, unknown, ORPCErrorConstructorMap>, + ): BuilderWithErrorsMiddlewares, TErrorMap> { + return new BuilderWithErrorsMiddlewares, TErrorMap>({ + ...this['~orpc'], + inputValidationIndex: this['~orpc'].inputValidationIndex + 1, + outputValidationIndex: this['~orpc'].outputValidationIndex + 1, + middlewares: [...this['~orpc'].middlewares, middleware as any], // FIXME: I believe we can remove `as any` here + }) + } + + route(route: RouteOptions): ProcedureBuilder { + return new ProcedureBuilder({ + ...this['~orpc'], + contract: new ContractProcedure({ + route, + InputSchema: undefined, + OutputSchema: undefined, + errorMap: this['~orpc'].errorMap, + }), + }) + } + + input(schema: USchema, example?: SchemaInput): ProcedureBuilderWithInput { + return new ProcedureBuilderWithInput({ + ...this['~orpc'], + contract: new ContractProcedure({ + OutputSchema: undefined, + InputSchema: schema, + inputExample: example, + errorMap: this['~orpc'].errorMap, + }), + }) + } + + output(schema: USchema, example?: SchemaOutput): ProcedureBuilderWithOutput { + return new ProcedureBuilderWithOutput({ + ...this['~orpc'], + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: schema, + outputExample: example, + errorMap: this['~orpc'].errorMap, + }), + }) + } + + handler(handler: ProcedureHandler): DecoratedProcedure { + return new DecoratedProcedure({ + ...this['~orpc'], + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + errorMap: this['~orpc'].errorMap, + }), + handler, + }) + } + + prefix(prefix: HTTPPath): RouterBuilder { + return new RouterBuilder({ + ...this['~orpc'], + prefix, + }) + } + + tag(...tags: string[]): RouterBuilder { + return new RouterBuilder({ + ...this['~orpc'], + tags, + }) + } + + router, ContractRouter>>>>( + router: U, + ): AdaptedRouter { + return new RouterBuilder(this['~orpc']).router(router) + } + + lazy, ContractRouter>>>>( + loader: () => Promise<{ default: U }>, + ): AdaptedRouter, TErrorMap> { + return new RouterBuilder(this['~orpc']).lazy(loader) + } +} diff --git a/packages/server/src/builder-with-errors.test-d.ts b/packages/server/src/builder-with-errors.test-d.ts new file mode 100644 index 000000000..66d356cd8 --- /dev/null +++ b/packages/server/src/builder-with-errors.test-d.ts @@ -0,0 +1,199 @@ +import type { BuilderWithErrors } from './builder-with-errors' +import type { BuilderWithErrorsMiddlewares } from './builder-with-errors-middlewares' +import type { ORPCErrorConstructorMap } from './error' +import type { Lazy } from './lazy' +import type { MiddlewareOutputFn } from './middleware' +import type { DecoratedMiddleware } from './middleware-decorated' +import type { ANY_PROCEDURE, Procedure } from './procedure' +import type { ProcedureBuilder } from './procedure-builder' +import type { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import type { ProcedureBuilderWithOutput } from './procedure-builder-with-output' +import type { DecoratedProcedure } from './procedure-decorated' +import type { AdaptedRouter, RouterBuilder } from './router-builder' +import type { WELL_CONTEXT } from './types' +import { z } from 'zod' + +const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + +const baseErrors = { + BASE: { + data: z.string(), + }, +} + +const errors = { + CODE: { + status: 404, + data: z.object({ why: z.string() }), + }, +} + +const builder = {} as BuilderWithErrors<{ db: string }, typeof baseErrors> + +describe('BuilderWithErrors', () => { + it('.context', () => { + expectTypeOf(builder.context()).toEqualTypeOf>() + expectTypeOf(builder.context<{ anything: string }>()).toEqualTypeOf>() + }) + + it('.middleware', () => { + const mid = builder.middleware(({ context, next, path, procedure, errors }, input, output) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ db: string }>() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(output).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() + + return next({ + context: { + extra: true, + }, + }) + }) + + expectTypeOf(mid).toEqualTypeOf< + DecoratedMiddleware<{ db: string }, { extra: boolean }, unknown, any, ORPCErrorConstructorMap> + >() + + const mid2 = builder.middleware(({ next }, input: 'input', output: MiddlewareOutputFn<'output'>) => next({})) + + expectTypeOf(mid2).toEqualTypeOf< + DecoratedMiddleware<{ db: string }, undefined, 'input', 'output', ORPCErrorConstructorMap> + >() + + // @ts-expect-error --- conflict context + builder.middleware(({ next }) => next({ db: 123 })) + }) + + it('.errors', () => { + expectTypeOf(builder.errors(errors)).toEqualTypeOf>() + + // @ts-expect-error --- not allow redefine error map + builder.errors({ BASE: baseErrors.BASE }) + }) + + it('.use', () => { + const applied = builder.use(({ context, next, path, procedure, errors }, input, output) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ db: string }>() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(output).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() + + return next({ + context: { + extra: true, + }, + }) + }) + + expectTypeOf(applied).toEqualTypeOf>() + + // @ts-expect-error --- conflict context + builder.use(({ next }) => next({ db: 123 })) + // @ts-expect-error --- input is not match + builder.use(({ next }, input: 'invalid') => next({})) + // @ts-expect-error --- output is not match + builder.use(({ next }, input, output: MiddlewareOutputFn<'invalid'>) => next({})) + }) + + it('.route', () => { + expectTypeOf(builder.route({ path: '/test', method: 'GET' })).toEqualTypeOf< + ProcedureBuilder<{ db: string }, undefined, typeof baseErrors> + >() + }) + + it('.input', () => { + expectTypeOf(builder.input(schema)).toEqualTypeOf< + ProcedureBuilderWithInput<{ db: string }, undefined, typeof schema, typeof baseErrors> + >() + }) + + it('.output', () => { + expectTypeOf(builder.output(schema)).toEqualTypeOf< + ProcedureBuilderWithOutput<{ db: string }, undefined, typeof schema, typeof baseErrors> + >() + }) + + it('.handler', () => { + const procedure = builder.handler(({ input, context, procedure, path, signal, errors }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ db: string }>() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(signal).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() + + return 456 + }) + + expectTypeOf(procedure).toMatchTypeOf< + DecoratedProcedure<{ db: string }, undefined, undefined, undefined, number, typeof baseErrors> + >() + }) + + it('.prefix', () => { + expectTypeOf(builder.prefix('/test')).toEqualTypeOf< + RouterBuilder<{ db: string }, undefined, typeof baseErrors> + >() + }) + + it('.tag', () => { + expectTypeOf(builder.tag('test', 'test2')).toEqualTypeOf< + RouterBuilder<{ db: string }, undefined, typeof baseErrors> + >() + }) + + it('.router', () => { + const router = { + ping: {} as Procedure<{ db: string }, undefined, undefined, undefined, unknown, typeof errors>, + pong: {} as Procedure>, + } + + expectTypeOf(builder.router(router)).toEqualTypeOf< + AdaptedRouter<{ db: string }, typeof router, typeof baseErrors> + >() + + builder.router({ + // @ts-expect-error - context is not match + ping: {} as Procedure<{ auth: 'invalid' }, undefined, undefined, undefined, unknown, typeof errors>, + }) + + const invalidErrorMap = { + BASE: { + ...baseErrors.BASE, + status: 400, + }, + } + + builder.router({ + // @ts-expect-error - error map is not match + ping: {} as ContractProcedure, + }) + }) + + it('.lazy', () => { + const router = { + ping: {} as Procedure<{ db: string }, undefined, undefined, undefined, unknown, typeof errors>, + pong: {} as Procedure>, + } + + expectTypeOf(builder.lazy(() => Promise.resolve({ default: router }))).toEqualTypeOf< + AdaptedRouter<{ db: string }, Lazy, typeof baseErrors> + >() + + // @ts-expect-error - context is not match + builder.lazy(() => Promise.resolve({ default: { + ping: {} as Procedure<{ auth: 'invalid' }, undefined, undefined, undefined, unknown, typeof errors>, + } })) + + // @ts-expect-error - error map is not match + builder.lazy(() => Promise.resolve({ + default: { + ping: {} as Procedure, + }, + })) + }) +}) diff --git a/packages/server/src/builder-with-errors.test.ts b/packages/server/src/builder-with-errors.test.ts new file mode 100644 index 000000000..eafe1921c --- /dev/null +++ b/packages/server/src/builder-with-errors.test.ts @@ -0,0 +1,159 @@ +import { z } from 'zod' +import { BuilderWithErrors } from './builder-with-errors' +import { BuilderWithErrorsMiddlewares } from './builder-with-errors-middlewares' +import { unlazy } from './lazy' +import * as middlewareDecorated from './middleware-decorated' +import { ProcedureBuilder } from './procedure-builder' +import { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ProcedureBuilderWithOutput } from './procedure-builder-with-output' +import { DecoratedProcedure } from './procedure-decorated' +import { RouterBuilder } from './router-builder' + +vi.mock('./router-builder', async (origin) => { + const RouterBuilder = vi.fn() + RouterBuilder.prototype.router = vi.fn(() => '__router__') + RouterBuilder.prototype.lazy = vi.fn(() => '__lazy__') + + return { + RouterBuilder, + } +}) + +const decorateMiddlewareSpy = vi.spyOn(middlewareDecorated, 'decorateMiddleware') +const RouterBuilderRouterSpy = vi.spyOn(RouterBuilder.prototype, 'router') +const RouterBuilderLazySpy = vi.spyOn(RouterBuilder.prototype, 'lazy') + +const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + +const errors = { + CODE: { + status: 404, + data: z.object({ why: z.string() }), + }, +} + +const baseErrors = { + BASE: { + status: 404, + data: z.object({ why: z.string() }), + }, +} + +const builder = new BuilderWithErrors({ + errorMap: baseErrors, +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('builder', () => { + it('.context', () => { + const applied = builder.context() + expect(applied).toBe(builder) + }) + + it('.middleware', () => { + const fn = vi.fn() + const mid = builder.middleware(fn) + + expect(mid).toBe(decorateMiddlewareSpy.mock.results[0]!.value) + expect(decorateMiddlewareSpy).toHaveBeenCalledWith(fn) + }) + + it('.errors', () => { + const applied = builder.errors(errors) + expect(applied).toBeInstanceOf(BuilderWithErrors) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].errorMap).toEqual({ ...baseErrors, ...errors }) + }) + + it('.use', () => { + const mid = vi.fn() + const applied = builder.use(mid) + expect(applied).toBeInstanceOf(BuilderWithErrorsMiddlewares) + expect(applied['~orpc'].errorMap).toEqual(baseErrors) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + }) + + it('.route', () => { + const route = { path: '/test', method: 'GET' } as const + const applied = builder.route(route) + expect(applied).toBeInstanceOf(ProcedureBuilder) + expect(applied['~orpc'].contract['~orpc'].route).toEqual(route) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + expect(applied['~orpc'].inputValidationIndex).toEqual(0) + expect(applied['~orpc'].outputValidationIndex).toEqual(0) + }) + + it('.input', () => { + const applied = builder.input(schema) + expect(applied).toBeInstanceOf(ProcedureBuilderWithInput) + expect(applied['~orpc'].contract['~orpc'].InputSchema).toEqual(schema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + expect(applied['~orpc'].inputValidationIndex).toEqual(0) + expect(applied['~orpc'].outputValidationIndex).toEqual(0) + }) + + it('.output', () => { + const applied = builder.output(schema) + expect(applied).toBeInstanceOf(ProcedureBuilderWithOutput) + expect(applied['~orpc'].contract['~orpc'].OutputSchema).toEqual(schema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + expect(applied['~orpc'].inputValidationIndex).toEqual(0) + expect(applied['~orpc'].outputValidationIndex).toEqual(0) + }) + + it('.handler', () => { + const handler = vi.fn() + const applied = builder.handler(handler) + expect(applied).toBeInstanceOf(DecoratedProcedure) + expect(applied['~orpc'].handler).toEqual(handler) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + expect(applied['~orpc'].inputValidationIndex).toEqual(0) + expect(applied['~orpc'].outputValidationIndex).toEqual(0) + }) + + it('.prefix', () => { + const applied = builder.prefix('/test') + expect(applied).toBe(vi.mocked(RouterBuilder).mock.results[0]!.value) + expect(applied).toBeInstanceOf(RouterBuilder) + expect(RouterBuilder).toHaveBeenCalledWith(expect.objectContaining({ errorMap: baseErrors, prefix: '/test' })) + }) + + it('.tag', () => { + const applied = builder.tag('test', 'test2') + expect(applied).toBe(vi.mocked(RouterBuilder).mock.results[0]!.value) + expect(applied).toBeInstanceOf(RouterBuilder) + expect(RouterBuilder).toHaveBeenCalledWith(expect.objectContaining({ errorMap: baseErrors, tags: ['test', 'test2'] })) + }) + + it('.router', () => { + const router = { + ping: {} as any, + pong: {} as any, + } + + const applied = builder.router(router) + + expect(applied).toBe(RouterBuilderRouterSpy.mock.results[0]!.value) + expect(RouterBuilderRouterSpy).toHaveBeenCalledWith(router) + expect(RouterBuilder).toHaveBeenCalledWith(expect.objectContaining({ errorMap: baseErrors })) + }) + + it('.lazy', () => { + const router = { + ping: {} as any, + pong: {} as any, + } + + const applied = builder.lazy(() => Promise.resolve({ default: router })) + + expect(applied).toBe(RouterBuilderLazySpy.mock.results[0]!.value) + expect(RouterBuilderLazySpy).toHaveBeenCalledWith(expect.any(Function)) + expect(RouterBuilder).toHaveBeenCalledWith(expect.objectContaining({ errorMap: baseErrors })) + expect(unlazy(RouterBuilderLazySpy.mock.results[0]!.value)).resolves.toEqual({ default: '__lazy__' }) + }) +}) diff --git a/packages/server/src/builder-with-errors.ts b/packages/server/src/builder-with-errors.ts new file mode 100644 index 000000000..801ae231d --- /dev/null +++ b/packages/server/src/builder-with-errors.ts @@ -0,0 +1,160 @@ +import type { ContractRouter, ErrorMap, ErrorMapGuard, ErrorMapSuggestions, HTTPPath, RouteOptions, Schema, SchemaInput, SchemaOutput, StrictErrorMap } from '@orpc/contract' +import type { ContextGuard } from './context' +import type { ORPCErrorConstructorMap } from './error' +import type { FlattenLazy } from './lazy' +import type { Middleware } from './middleware' +import type { DecoratedMiddleware } from './middleware-decorated' +import type { ProcedureHandler } from './procedure' +import type { Router } from './router' +import type { AdaptedRouter } from './router-builder' +import type { Context, MergeContext } from './types' +import { ContractProcedure } from '@orpc/contract' +import { BuilderWithErrorsMiddlewares } from './builder-with-errors-middlewares' +import { decorateMiddleware } from './middleware-decorated' +import { ProcedureBuilder } from './procedure-builder' +import { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ProcedureBuilderWithOutput } from './procedure-builder-with-output' +import { DecoratedProcedure } from './procedure-decorated' +import { RouterBuilder } from './router-builder' + +export interface BuilderWithErrorsDef { + types?: { context: TContext } + errorMap: TErrorMap +} + +/** + * `BuilderWithErrors` is a branch of `Builder` which it has error map. + * + * Why? + * - prevents .contract after .errors (add error map to existing contract can make the contract invalid) + * + */ +export class BuilderWithErrors { + '~type' = 'BuilderWithErrors' as const + '~orpc': BuilderWithErrorsDef + + constructor(def: BuilderWithErrorsDef) { + this['~orpc'] = def + } + + context(): BuilderWithErrors { + return this as any // just change at type level so safely cast here + } + + errors & ErrorMapSuggestions>(errors: U): BuilderWithErrors { + return new BuilderWithErrors({ + ...this['~orpc'], + errorMap: { + ...this['~orpc'].errorMap, + ...errors, + }, + }) + } + + middleware, TInput, TOutput = any>( + middleware: Middleware>, + ): DecoratedMiddleware> { + return decorateMiddleware(middleware) + } + + use>( + middleware: Middleware>, + ): BuilderWithErrorsMiddlewares { + return new BuilderWithErrorsMiddlewares({ + ...this['~orpc'], + inputValidationIndex: 1, + outputValidationIndex: 1, + middlewares: [middleware as any], // FIXME: I believe we can remove `as any` here + }) + } + + route(route: RouteOptions): ProcedureBuilder { + return new ProcedureBuilder({ + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, + contract: new ContractProcedure({ + route, + InputSchema: undefined, + OutputSchema: undefined, + errorMap: this['~orpc'].errorMap, + }), + }) + } + + input(schema: USchema, example?: SchemaInput): ProcedureBuilderWithInput { + return new ProcedureBuilderWithInput({ + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, + contract: new ContractProcedure({ + OutputSchema: undefined, + InputSchema: schema, + inputExample: example, + errorMap: this['~orpc'].errorMap, + }), + }) + } + + output(schema: USchema, example?: SchemaOutput): ProcedureBuilderWithOutput { + return new ProcedureBuilderWithOutput({ + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: schema, + outputExample: example, + errorMap: this['~orpc'].errorMap, + }), + }) + } + + handler(handler: ProcedureHandler): DecoratedProcedure { + return new DecoratedProcedure({ + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + errorMap: this['~orpc'].errorMap, + }), + handler, + }) + } + + prefix(prefix: HTTPPath): RouterBuilder { + return new RouterBuilder({ + middlewares: [], + errorMap: this['~orpc'].errorMap, + prefix, + }) + } + + tag(...tags: string[]): RouterBuilder { + return new RouterBuilder({ + middlewares: [], + errorMap: this['~orpc'].errorMap, + tags, + }) + } + + router, ContractRouter>>>>( + router: U, + ): AdaptedRouter { + return new RouterBuilder({ + middlewares: [], + ...this['~orpc'], + }).router(router) + } + + lazy, ContractRouter>>>>( + loader: () => Promise<{ default: U }>, + ): AdaptedRouter, TErrorMap> { + return new RouterBuilder({ + middlewares: [], + ...this['~orpc'], + }).lazy(loader) + } +} diff --git a/packages/server/src/builder-with-middlewares.test-d.ts b/packages/server/src/builder-with-middlewares.test-d.ts new file mode 100644 index 000000000..947bd0473 --- /dev/null +++ b/packages/server/src/builder-with-middlewares.test-d.ts @@ -0,0 +1,146 @@ +import type { BuilderWithErrorsMiddlewares } from './builder-with-errors-middlewares' +import type { BuilderWithMiddlewares } from './builder-with-middlewares' +import type { ChainableImplementer } from './implementer-chainable' +import type { Lazy } from './lazy' +import type { MiddlewareOutputFn } from './middleware' +import type { ANY_PROCEDURE, Procedure } from './procedure' +import type { ProcedureBuilder } from './procedure-builder' +import type { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import type { ProcedureBuilderWithOutput } from './procedure-builder-with-output' +import type { DecoratedProcedure } from './procedure-decorated' +import type { AdaptedRouter, RouterBuilder } from './router-builder' +import type { WELL_CONTEXT } from './types' +import { oc } from '@orpc/contract' +import { z } from 'zod' + +const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + +const errors = { + CODE: { + status: 404, + data: z.object({ why: z.string() }), + }, +} + +const builder = {} as BuilderWithMiddlewares<{ db: string }, { auth?: boolean }> + +describe('BuilderWithMiddlewares', () => { + it('.use', () => { + const applied = builder.use(({ context, next, path, procedure, errors }, input, output) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ db: string } & { auth?: boolean }>() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(output).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() + + return next({ + context: { + extra: true, + }, + }) + }) + + expectTypeOf(applied).toEqualTypeOf>() + + // @ts-expect-error --- conflict context + builder.use(({ next }) => next({ db: 123 })) + // @ts-expect-error --- input is not match + builder.use(({ next }, input: 'invalid') => next({})) + // @ts-expect-error --- output is not match + builder.use(({ next }, input, output: MiddlewareOutputFn<'invalid'>) => next({})) + }) + + it('.errors', () => { + expectTypeOf(builder.errors(errors)).toEqualTypeOf>() + }) + + it('.route', () => { + expectTypeOf(builder.route({ path: '/test', method: 'GET' })).toEqualTypeOf< + ProcedureBuilder<{ db: string }, { auth?: boolean }, Record> + >() + }) + + it('.input', () => { + expectTypeOf(builder.input(schema)).toEqualTypeOf< + ProcedureBuilderWithInput<{ db: string }, { auth?: boolean }, typeof schema, Record> + >() + }) + + it('.output', () => { + expectTypeOf(builder.output(schema)).toEqualTypeOf< + ProcedureBuilderWithOutput<{ db: string }, { auth?: boolean }, typeof schema, Record> + >() + }) + + it('.handler', () => { + const procedure = builder.handler(({ input, context, procedure, path, signal, errors }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ db: string } & { auth?: boolean }>() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(signal).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() + + return 456 + }) + + expectTypeOf(procedure).toMatchTypeOf< + DecoratedProcedure<{ db: string }, { auth?: boolean }, undefined, undefined, number, Record> + >() + }) + + it('.prefix', () => { + expectTypeOf(builder.prefix('/test')).toEqualTypeOf< + RouterBuilder<{ db: string }, { auth?: boolean }, Record> + >() + }) + + it('.tag', () => { + expectTypeOf(builder.tag('test', 'test2')).toEqualTypeOf< + RouterBuilder<{ db: string }, { auth?: boolean }, Record> + >() + }) + + it('.router', () => { + const router = { + ping: {} as Procedure<{ db: string }, { auth?: boolean }, undefined, undefined, unknown, typeof errors>, + pong: {} as Procedure>, + } + + expectTypeOf(builder.router(router)).toEqualTypeOf< + AdaptedRouter<{ db: string }, typeof router, Record> + >() + + builder.router({ + // @ts-expect-error - context is not match + ping: {} as Procedure<{ auth: 'invalid' }, undefined, undefined, undefined, unknown, typeof errors>, + }) + }) + + it('.lazy', () => { + const router = { + ping: {} as Procedure<{ db: string }, { auth?: boolean }, undefined, undefined, unknown, typeof errors>, + pong: {} as Procedure>, + } + + expectTypeOf(builder.lazy(() => Promise.resolve({ default: router }))).toEqualTypeOf< + AdaptedRouter<{ db: string }, Lazy, Record> + >() + + // @ts-expect-error - context is not match + builder.lazy(() => Promise.resolve({ default: { + ping: {} as Procedure<{ auth: 'invalid' }, undefined, undefined, undefined, unknown, typeof errors>, + } })) + }) + + it('.contract', () => { + const contract = oc.router({ + ping: oc.input(schema).output(schema), + }) + + expectTypeOf(builder.contract(contract)).toEqualTypeOf< + ChainableImplementer<{ db: string }, { auth?: boolean }, typeof contract> + >() + }) +}) diff --git a/packages/server/src/builder-with-middlewares.test.ts b/packages/server/src/builder-with-middlewares.test.ts new file mode 100644 index 000000000..9f86394af --- /dev/null +++ b/packages/server/src/builder-with-middlewares.test.ts @@ -0,0 +1,161 @@ +import { oc } from '@orpc/contract' +import { z } from 'zod' +import { BuilderWithErrorsMiddlewares } from './builder-with-errors-middlewares' +import { BuilderWithMiddlewares } from './builder-with-middlewares' +import * as implementerChainable from './implementer-chainable' +import { unlazy } from './lazy' +import { ProcedureBuilder } from './procedure-builder' +import { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ProcedureBuilderWithOutput } from './procedure-builder-with-output' +import { DecoratedProcedure } from './procedure-decorated' +import { RouterBuilder } from './router-builder' + +vi.mock('./router-builder', async (origin) => { + const RouterBuilder = vi.fn() + RouterBuilder.prototype.router = vi.fn(() => '__router__') + RouterBuilder.prototype.lazy = vi.fn(() => '__lazy__') + + return { + RouterBuilder, + } +}) + +const RouterBuilderRouterSpy = vi.spyOn(RouterBuilder.prototype, 'router') +const RouterBuilderLazySpy = vi.spyOn(RouterBuilder.prototype, 'lazy') +const createChainableImplementerSpy = vi.spyOn(implementerChainable, 'createChainableImplementer') + +const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) + +const errors = { + CODE: { + status: 404, + data: z.object({ why: z.string() }), + }, +} + +const mid = vi.fn() + +const builder = new BuilderWithMiddlewares({ + middlewares: [mid], + inputValidationIndex: 1, + outputValidationIndex: 1, +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('builderWithMiddlewares', () => { + it('.use', () => { + const mid2 = vi.fn() + const applied = builder.use(mid2) + expect(applied).toBeInstanceOf(BuilderWithMiddlewares) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].middlewares).toEqual([mid, mid2]) + expect(applied['~orpc'].inputValidationIndex).toEqual(2) + expect(applied['~orpc'].outputValidationIndex).toEqual(2) + }) + + it('.errors', () => { + const applied = builder.errors(errors) + expect(applied).toBeInstanceOf(BuilderWithErrorsMiddlewares) + expect(applied['~orpc'].errorMap).toEqual(errors) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + }) + + it('.route', () => { + const route = { path: '/test', method: 'GET' } as const + const applied = builder.route(route) + expect(applied).toBeInstanceOf(ProcedureBuilder) + expect(applied['~orpc'].contract['~orpc'].route).toEqual(route) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + }) + + it('.input', () => { + const applied = builder.input(schema) + expect(applied).toBeInstanceOf(ProcedureBuilderWithInput) + expect(applied['~orpc'].contract['~orpc'].InputSchema).toEqual(schema) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + }) + + it('.output', () => { + const applied = builder.output(schema) + expect(applied).toBeInstanceOf(ProcedureBuilderWithOutput) + expect(applied['~orpc'].contract['~orpc'].OutputSchema).toEqual(schema) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + }) + + it('.handler', () => { + const handler = vi.fn() + const applied = builder.handler(handler) + expect(applied).toBeInstanceOf(DecoratedProcedure) + expect(applied['~orpc'].handler).toEqual(handler) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + }) + + it('.prefix', () => { + const applied = builder.prefix('/test') + expect(applied).toBe(vi.mocked(RouterBuilder).mock.results[0]!.value) + expect(applied).toBeInstanceOf(RouterBuilder) + expect(RouterBuilder).toHaveBeenCalledWith(expect.objectContaining({ middlewares: [mid], prefix: '/test' })) + }) + + it('.tag', () => { + const applied = builder.tag('test', 'test2') + expect(applied).toBe(vi.mocked(RouterBuilder).mock.results[0]!.value) + expect(applied).toBeInstanceOf(RouterBuilder) + expect(RouterBuilder).toHaveBeenCalledWith(expect.objectContaining({ middlewares: [mid], tags: ['test', 'test2'] })) + }) + + it('.router', () => { + const router = { + ping: {} as any, + pong: {} as any, + } + + const applied = builder.router(router) + + expect(applied).toBe(RouterBuilderRouterSpy.mock.results[0]!.value) + expect(RouterBuilderRouterSpy).toHaveBeenCalledWith(router) + expect(RouterBuilder).toHaveBeenCalledWith(expect.objectContaining({ middlewares: [mid] })) + }) + + it('.lazy', () => { + const router = { + ping: {} as any, + pong: {} as any, + } + + const applied = builder.lazy(() => Promise.resolve({ default: router })) + + expect(applied).toBe(RouterBuilderLazySpy.mock.results[0]!.value) + expect(RouterBuilderLazySpy).toHaveBeenCalledWith(expect.any(Function)) + expect(RouterBuilder).toHaveBeenCalledWith(expect.objectContaining({ middlewares: [mid] })) + expect(unlazy(RouterBuilderLazySpy.mock.results[0]!.value)).resolves.toEqual({ default: '__lazy__' }) + }) + + it('.contract', () => { + const contract = oc.router({ + ping: oc.input(schema).output(schema), + }) + + const applied = builder.contract(contract) + + expect(applied).toBe(createChainableImplementerSpy.mock.results[0]!.value) + expect(createChainableImplementerSpy).toHaveBeenCalledWith(contract, { + middlewares: [mid], + inputValidationIndex: 1, + outputValidationIndex: 1, + }) + }) +}) diff --git a/packages/server/src/builder-with-middlewares.ts b/packages/server/src/builder-with-middlewares.ts new file mode 100644 index 000000000..fae7bfc03 --- /dev/null +++ b/packages/server/src/builder-with-middlewares.ts @@ -0,0 +1,159 @@ +import type { ContractRouter, ErrorMap, ErrorMapSuggestions, HTTPPath, RouteOptions, Schema, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { ContextGuard } from './context' +import type { FlattenLazy } from './lazy' +import type { Middleware } from './middleware' +import type { ProcedureHandler } from './procedure' +import type { Router } from './router' +import type { AdaptedRouter } from './router-builder' +import type { Context, MergeContext } from './types' +import { ContractProcedure } from '@orpc/contract' +import { BuilderWithErrorsMiddlewares } from './builder-with-errors-middlewares' +import { type ChainableImplementer, createChainableImplementer } from './implementer-chainable' +import { ProcedureBuilder } from './procedure-builder' +import { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ProcedureBuilderWithOutput } from './procedure-builder-with-output' +import { DecoratedProcedure } from './procedure-decorated' +import { RouterBuilder } from './router-builder' + +/** + * `BuilderWithMiddlewares` is a branch of `Builder` which it has middlewares. + * + * Why? + * - prevents .middleware after .use (can mislead the behavior) + * - prevents .context after .use (middlewares required current context, so it tricky when change the current context) + * + */ +export interface BuilderWithMiddlewaresDef { + middlewares: Middleware, Partial | undefined, unknown, any, Record>[] + inputValidationIndex: number + outputValidationIndex: number +} + +export class BuilderWithMiddlewares { + '~type' = 'BuilderHasMiddlewares' as const + '~orpc': BuilderWithMiddlewaresDef + + constructor(def: BuilderWithMiddlewaresDef) { + this['~orpc'] = def + } + + use>>( + middleware: Middleware< + MergeContext, + U, + unknown, + unknown, + Record + >, + ): BuilderWithMiddlewares> { + return new BuilderWithMiddlewares({ + ...this['~orpc'], + inputValidationIndex: this['~orpc'].inputValidationIndex + 1, + outputValidationIndex: this['~orpc'].outputValidationIndex + 1, + middlewares: [...this['~orpc'].middlewares, middleware as any], + }) + } + + errors(errors: U): BuilderWithErrorsMiddlewares { + return new BuilderWithErrorsMiddlewares({ + ...this['~orpc'], + errorMap: errors, + }) + } + + route(route: RouteOptions): ProcedureBuilder> { + return new ProcedureBuilder({ + ...this['~orpc'], + contract: new ContractProcedure({ + route, + InputSchema: undefined, + OutputSchema: undefined, + errorMap: {}, + }), + }) + } + + input( + schema: USchema, + example?: SchemaInput, + ): ProcedureBuilderWithInput> { + return new ProcedureBuilderWithInput({ + ...this['~orpc'], + contract: new ContractProcedure({ + OutputSchema: undefined, + InputSchema: schema, + inputExample: example, + errorMap: {}, + }), + }) + } + + output( + schema: USchema, + example?: SchemaOutput, + ): ProcedureBuilderWithOutput> { + return new ProcedureBuilderWithOutput({ + ...this['~orpc'], + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: schema, + outputExample: example, + errorMap: {}, + }), + }) + } + + handler( + handler: ProcedureHandler>, + ): DecoratedProcedure> { + return new DecoratedProcedure({ + ...this['~orpc'], + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: undefined, + errorMap: {}, + }), + handler, + }) + } + + prefix(prefix: HTTPPath): RouterBuilder> { + return new RouterBuilder({ + middlewares: this['~orpc'].middlewares, + errorMap: {}, + prefix, + }) + } + + tag(...tags: string[]): RouterBuilder> { + return new RouterBuilder({ + middlewares: this['~orpc'].middlewares, + errorMap: {}, + tags, + }) + } + + router, any>>( + router: U, + ): AdaptedRouter> { + return new RouterBuilder>({ + errorMap: {}, + ...this['~orpc'], + }).router(router) + } + + lazy, any>>( + loader: () => Promise<{ default: U }>, + ): AdaptedRouter, Record> { + return new RouterBuilder>({ + errorMap: {}, + ...this['~orpc'], + }).lazy(loader) + } + + contract>( + contract: U, + ): ChainableImplementer { + return createChainableImplementer(contract, this['~orpc']) + } +} diff --git a/packages/server/src/builder.test-d.ts b/packages/server/src/builder.test-d.ts index df545e034..06ca5277c 100644 --- a/packages/server/src/builder.test-d.ts +++ b/packages/server/src/builder.test-d.ts @@ -1,10 +1,14 @@ import type { Builder } from './builder' +import type { BuilderWithErrors } from './builder-with-errors' +import type { BuilderWithMiddlewares } from './builder-with-middlewares' import type { ChainableImplementer } from './implementer-chainable' -import type { DecoratedLazy } from './lazy-decorated' -import type { Middleware, MiddlewareOutputFn } from './middleware' +import type { Lazy } from './lazy' +import type { MiddlewareOutputFn } from './middleware' import type { DecoratedMiddleware } from './middleware-decorated' import type { ANY_PROCEDURE, Procedure } from './procedure' import type { ProcedureBuilder } from './procedure-builder' +import type { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import type { ProcedureBuilderWithOutput } from './procedure-builder-with-output' import type { DecoratedProcedure } from './procedure-decorated' import type { AdaptedRouter, RouterBuilder } from './router-builder' import type { WELL_CONTEXT } from './types' @@ -13,224 +17,167 @@ import { z } from 'zod' const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) -const baseErrors = { - BASE: { - status: 500, - data: z.object({ - message: z.string(), - }), +const errors = { + CODE: { + status: 404, + data: z.object({ why: z.string() }), }, } -const builder = {} as Builder<{ auth: boolean }, { db: string }, typeof baseErrors> +const builder = {} as Builder<{ db: string }> -describe('self chainable', () => { - it('define context', () => { - expectTypeOf(builder.context()).toEqualTypeOf>>() - expectTypeOf(builder.context<{ db: string }>()).toEqualTypeOf>>() - expectTypeOf(builder.context<{ auth: boolean }>()).toEqualTypeOf>>() +describe('Builder', () => { + it('.context', () => { + expectTypeOf(builder.context()).toEqualTypeOf>() + expectTypeOf(builder.context<{ anything: string }>()).toEqualTypeOf>() }) - it('use middleware', () => { - expectTypeOf( - builder.use({} as Middleware<{ auth: boolean }, undefined, unknown, unknown, Record>), - ).toEqualTypeOf>() - expectTypeOf( - builder.use({} as Middleware<{ auth: boolean }, { dev: string }, unknown, unknown, Record>), - ).toEqualTypeOf>() - expectTypeOf( - builder.use({} as Middleware>), - ).toEqualTypeOf>() + it('.middleware', () => { + const mid = builder.middleware(({ context, next, path, procedure, errors }, input, output) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ db: string }>() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(output).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() - // @ts-expect-error - context is not match - builder.use({} as Middleware<{ auth: 'invalid' }, undefined, unknown, unknown, Record>) + return next({ + context: { + extra: true, + }, + }) + }) - // @ts-expect-error - extra context is conflict with context - builder.use({} as Middleware>) + expectTypeOf(mid).toEqualTypeOf< + DecoratedMiddleware<{ db: string }, { extra: boolean }, unknown, any, Record> + >() - // @ts-expect-error - expected input is not match with unknown - builder.use({} as Middleware>) + const mid2 = builder.middleware(({ next }, input: 'input', output: MiddlewareOutputFn<'output'>) => next({})) - // @ts-expect-error - expected output is not match with unknown - builder.use({} as Middleware>) + expectTypeOf(mid2).toEqualTypeOf< + DecoratedMiddleware<{ db: string }, undefined, 'input', 'output', Record> + >() - // @ts-expect-error - invalid middleware - builder.use(() => {}) + // @ts-expect-error --- conflict context + builder.middleware(({ next }) => next({ db: 123 })) }) - it('errors', () => { - expectTypeOf(builder.errors({ ANYTHING: { data: schema } })).toEqualTypeOf< - Builder<{ auth: boolean }, { db: string }, { ANYTHING: { data: typeof schema } } & typeof baseErrors> - >() - - // @ts-expect-error - not allow redefine errorMap - builder.errors({ BASE: baseErrors.BASE }) - // @ts-expect-error - not allow redefine errorMap --- even with undefined - builder.errors({ BASE: undefined }) - // @ts-expect-error - invalid schema - builder.errors({ ANYTHING: { data: {} } }) + it('.errors', () => { + expectTypeOf(builder.errors(errors)).toEqualTypeOf>() }) -}) -describe('create middleware', () => { - it('works', () => { - const mid = builder.middleware(({ context, next, path, procedure }, input, output) => { + it('.use', () => { + const applied = builder.use(({ context, next, path, procedure, errors }, input, output) => { expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & { db: string }>() + expectTypeOf(context).toEqualTypeOf<{ db: string }>() expectTypeOf(path).toEqualTypeOf() expectTypeOf(procedure).toEqualTypeOf() - expectTypeOf(output).toEqualTypeOf>() + expectTypeOf(output).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() return next({ context: { - dev: true, + extra: true, }, }) }) - expectTypeOf(mid).toEqualTypeOf< - DecoratedMiddleware<{ auth: boolean } & { db: string }, { dev: boolean }, unknown, any, Record> - >() + expectTypeOf(applied).toEqualTypeOf>() - // @ts-expect-error - conflict extra context and context - builder.middleware(({ context, next, path }, input) => next({ - context: { - auth: 'invalid', - }, - })) + // @ts-expect-error --- conflict context + builder.use(({ next }) => next({ db: 123 })) + // @ts-expect-error --- input is not match + builder.use(({ next }, input: 'invalid') => next({})) + // @ts-expect-error --- output is not match + builder.use(({ next }, input, output: MiddlewareOutputFn<'invalid'>) => next({})) }) -}) -describe('to ProcedureBuilder', () => { - it('route', () => { + it('.route', () => { expectTypeOf(builder.route({ path: '/test', method: 'GET' })).toEqualTypeOf< - ProcedureBuilder<{ auth: boolean }, { db: string }, undefined, undefined, typeof baseErrors> + ProcedureBuilder<{ db: string }, undefined, Record> >() - - // @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, typeof baseErrors> + it('.input', () => { + expectTypeOf(builder.input(schema)).toEqualTypeOf< + ProcedureBuilderWithInput<{ db: string }, undefined, typeof schema, Record> >() - - 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, typeof baseErrors> + it('.output', () => { + expectTypeOf(builder.output(schema)).toEqualTypeOf< + ProcedureBuilderWithOutput<{ db: string }, undefined, typeof schema, Record> >() - - builder.output(schema) - // @ts-expect-error - invalid example - builder.output(schema, { val: '123' }) - // @ts-expect-error - invalid schema - builder.output({}) }) -}) -describe('to DecoratedProcedure', () => { - it('handler', () => { - expectTypeOf(builder.handler(({ input, context, procedure, path, signal }) => { + it('.handler', () => { + const procedure = builder.handler(({ input, context, procedure, path, signal, errors }) => { expectTypeOf(input).toEqualTypeOf() - expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & { db: string }>() + expectTypeOf(context).toEqualTypeOf<{ db: string }>() expectTypeOf(procedure).toEqualTypeOf() expectTypeOf(path).toEqualTypeOf() expectTypeOf(signal).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() return 456 - })).toMatchTypeOf< - DecoratedProcedure<{ auth: boolean }, { db: string }, undefined, undefined, number, Record> + }) + + expectTypeOf(procedure).toMatchTypeOf< + DecoratedProcedure<{ db: string }, undefined, undefined, undefined, number, Record> >() }) -}) -describe('to RouterBuilder', () => { - it('prefix', () => { + it('.prefix', () => { expectTypeOf(builder.prefix('/test')).toEqualTypeOf< - RouterBuilder<{ auth: boolean }, { db: string }, typeof baseErrors> + RouterBuilder<{ db: string }, undefined, Record> >() - - // @ts-expect-error invalid prefix - builder.prefix('') }) - it('tags', () => { + it('.tag', () => { expectTypeOf(builder.tag('test', 'test2')).toEqualTypeOf< - RouterBuilder<{ auth: boolean }, { db: string }, typeof baseErrors> + RouterBuilder<{ db: string }, undefined, Record> >() - - // @ts-expect-error invalid tags - builder.tag(123) }) -}) -it('to AdaptedRouter', () => { - const ping = {} as Procedure<{ auth: boolean, db: string }, undefined, undefined, undefined, unknown, Record> - const router = { - ping, - nested: { - ping, - }, - } - expectTypeOf(builder.router(router)).toEqualTypeOf< - AdaptedRouter<{ auth: boolean }, typeof router, typeof baseErrors> - >() - - // @ts-expect-error - context is not match - builder.router({ ping: {} as Procedure<{ invalid: true }, undefined, undefined, undefined, unknown> }) -}) + it('.router', () => { + const router = { + ping: {} as Procedure<{ db: string }, undefined, undefined, undefined, unknown, typeof errors>, + pong: {} as Procedure>, + } -it('to DecoratedLazy', () => { - const ping = {} as Procedure<{ auth: boolean, db: string }, undefined, undefined, undefined, unknown, Record> - 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, Record>, - } })) -}) + expectTypeOf(builder.router(router)).toEqualTypeOf< + AdaptedRouter<{ db: string }, typeof router, Record> + >() + + builder.router({ + // @ts-expect-error - context is not match + ping: {} as Procedure<{ auth: 'invalid' }, undefined, undefined, undefined, unknown, typeof errors>, + }) + }) -it('to ChainableImplementer', () => { - const schema = z.object({ val: z.string().transform(val => Number(val)) }) + it('.lazy', () => { + const router = { + ping: {} as Procedure<{ db: string }, undefined, undefined, undefined, unknown, typeof errors>, + pong: {} as Procedure>, + } - const ping = oc.input(schema).output(schema) - const pong = oc.route({ method: 'GET', path: '/ping' }) + expectTypeOf(builder.lazy(() => Promise.resolve({ default: router }))).toEqualTypeOf< + AdaptedRouter<{ db: string }, Lazy, Record> + >() - const contract = oc.router({ - ping, - pong, - nested: { - ping, - pong, - }, + // @ts-expect-error - context is not match + builder.lazy(() => Promise.resolve({ default: { + ping: {} as Procedure<{ auth: 'invalid' }, undefined, undefined, undefined, unknown, typeof errors>, + } })) }) - expectTypeOf(builder.contract(contract)).toEqualTypeOf< - ChainableImplementer<{ auth: boolean }, { db: string }, typeof contract> - >() + it('.contract', () => { + const contract = oc.router({ + ping: oc.input(schema).output(schema), + }) - /// @ts-expect-error - context is not match - builder.contract({} as ANY_PROCEDURE) + expectTypeOf(builder.contract(contract)).toEqualTypeOf< + ChainableImplementer<{ db: string }, undefined, typeof contract> + >() + }) }) diff --git a/packages/server/src/builder.test.ts b/packages/server/src/builder.test.ts index b64ec7c18..925703fbe 100644 --- a/packages/server/src/builder.test.ts +++ b/packages/server/src/builder.test.ts @@ -1,208 +1,145 @@ +import { oc } from '@orpc/contract' import { z } from 'zod' import { Builder } from './builder' -import { createChainableImplementer } from './implementer-chainable' -import { isProcedure } from './procedure' +import { BuilderWithErrors } from './builder-with-errors' +import { BuilderWithMiddlewares } from './builder-with-middlewares' +import * as implementerChainable from './implementer-chainable' +import { unlazy } from './lazy' +import * as middlewareDecorated from './middleware-decorated' import { ProcedureBuilder } from './procedure-builder' +import { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ProcedureBuilderWithOutput } from './procedure-builder-with-output' +import { DecoratedProcedure } from './procedure-decorated' import { RouterBuilder } from './router-builder' -vi.mock('./router-builder', () => ({ - RouterBuilder: vi.fn(() => ({ - router: vi.fn(() => ({ mocked: true })), - lazy: vi.fn(() => ({ mocked: true })), - })), -})) - -vi.mock('./implementer-chainable', () => ({ - createChainableImplementer: vi.fn(() => ({ mocked: true })), -})) - -beforeEach(() => { - vi.clearAllMocks() -}) +const decorateMiddlewareSpy = vi.spyOn(middlewareDecorated, 'decorateMiddleware') +const RouterBuilderRouterSpy = vi.spyOn(RouterBuilder.prototype, 'router') +const RouterBuilderLazySpy = vi.spyOn(RouterBuilder.prototype, 'lazy') +const createChainableImplementerSpy = vi.spyOn(implementerChainable, 'createChainableImplementer') const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) -const baseErrors = { - BASE: { - status: 500, - data: z.object({ - message: z.string(), - }), +const errors = { + CODE: { + status: 404, + data: z.object({ why: z.string() }), }, } -const mid = vi.fn() -const builder = new Builder({ - middlewares: [mid], - errorMap: baseErrors, -}) +const builder = new Builder({}) -describe('self chainable', () => { - it('define context', () => { +describe('builder', () => { + it('.context', () => { const applied = builder.context() - - expect(applied).not.toBe(builder) - expect(applied).toBeInstanceOf(Builder) - - expect(applied['~orpc'].middlewares).toEqual([]) + expect(applied).toBe(builder) }) - it('use middleware', () => { - const builder = new Builder({ - middlewares: [], - errorMap: {}, - }) - - const mid1 = vi.fn() - const mid2 = vi.fn() - const mid3 = vi.fn() + it('.middleware', () => { + const fn = vi.fn() + const mid = builder.middleware(fn) - 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]) + expect(mid).toBe(decorateMiddlewareSpy.mock.results[0]!.value) + expect(decorateMiddlewareSpy).toHaveBeenCalledWith(fn) }) - it('errors', () => { - const result = builder.errors({ ANYTHING: { data: schema } }) - - expect(result).instanceOf(Builder) - expect(result).not.toBe(builder) - expect(result['~orpc'].middlewares).toEqual([mid]) - expect(result['~orpc'].errorMap).toEqual({ - ...baseErrors, - ANYTHING: { data: schema }, - }) + it('.errors', () => { + const applied = builder.errors(errors) + expect(applied).toBeInstanceOf(BuilderWithErrors) + expect(applied['~orpc'].errorMap).toEqual(errors) }) -}) -describe('create middleware', () => { - it('works', () => { - const fn = vi.fn() - const mid = builder.middleware(fn) as any - - fn.mockReturnValueOnce('__mocked__') - expect(mid).toBeTypeOf('function') - expect(mid(1, 2, 3)).toBe('__mocked__') - - expect(fn).toBeCalledTimes(1) - expect(fn).toBeCalledWith(1, 2, 3) + it('.use', () => { + const mid = vi.fn() + const applied = builder.use(mid) + expect(applied).toBeInstanceOf(BuilderWithMiddlewares) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) }) -}) -describe('to ProcedureBuilder', () => { - it('route', () => { - const route = { path: '/test', method: 'GET', description: '124', tags: ['hi ho'] } as const - const result = builder.route(route) - - expect(result).instanceOf(ProcedureBuilder) - expect(result['~orpc'].middlewares).toEqual([mid]) - expect(result['~orpc'].contract['~orpc'].route).toBe(route) - expect(result['~orpc'].contract['~orpc'].errorMap).toBe(baseErrors) + it('.route', () => { + const route = { path: '/test', method: 'GET' } as const + const applied = builder.route(route) + expect(applied).toBeInstanceOf(ProcedureBuilder) + expect(applied['~orpc'].contract['~orpc'].route).toEqual(route) + expect(applied['~orpc'].inputValidationIndex).toEqual(0) + expect(applied['~orpc'].outputValidationIndex).toEqual(0) }) - it('input', () => { - const example = { val: '123' } - const result = builder.input(schema, example) - - 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(result['~orpc'].contract['~orpc'].errorMap).toBe(baseErrors) + it('.input', () => { + const applied = builder.input(schema) + expect(applied).toBeInstanceOf(ProcedureBuilderWithInput) + expect(applied['~orpc'].contract['~orpc'].InputSchema).toEqual(schema) + expect(applied['~orpc'].inputValidationIndex).toEqual(0) + expect(applied['~orpc'].outputValidationIndex).toEqual(0) }) - it('output', () => { - const example = { val: 123 } - const result = builder.output(schema, example) - - 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) - expect(result['~orpc'].contract['~orpc'].errorMap).toBe(baseErrors) + it('.output', () => { + const applied = builder.output(schema) + expect(applied).toBeInstanceOf(ProcedureBuilderWithOutput) + expect(applied['~orpc'].contract['~orpc'].OutputSchema).toEqual(schema) + expect(applied['~orpc'].inputValidationIndex).toEqual(0) + expect(applied['~orpc'].outputValidationIndex).toEqual(0) }) -}) -describe('to DecoratedProcedure', () => { - it('handler', () => { - const fn = vi.fn() - const result = builder.handler(fn) + it('.handler', () => { + const handler = vi.fn() + const applied = builder.handler(handler) + expect(applied).toBeInstanceOf(DecoratedProcedure) + expect(applied['~orpc'].handler).toEqual(handler) + expect(applied['~orpc'].inputValidationIndex).toEqual(0) + expect(applied['~orpc'].outputValidationIndex).toEqual(0) + }) - expect(result).toSatisfy(isProcedure) - expect(result['~orpc'].preMiddlewares).toEqual([mid]) - expect(result['~orpc'].handler).toBe(fn) - expect(result['~orpc'].contract['~orpc'].errorMap).toBe(baseErrors) + it('.prefix', () => { + const applied = builder.prefix('/test') + expect(applied).toBeInstanceOf(RouterBuilder) + expect(applied['~orpc'].prefix).toEqual('/test') }) -}) -describe('to RouterBuilder', () => { - it('prefix', () => { - vi.mocked(RouterBuilder).mockReturnValueOnce({ mocked: true } as any) - expect(builder.prefix('/test')).toEqual({ mocked: true }) - - expect(RouterBuilder).toBeCalledTimes(1) - expect(RouterBuilder).toBeCalledWith(expect.objectContaining({ - middlewares: [mid], - prefix: '/test', - errorMap: baseErrors, - })) + it('.tag', () => { + const applied = builder.tag('test', 'test2') + expect(applied).toBeInstanceOf(RouterBuilder) + expect(applied['~orpc'].tags).toEqual(['test', 'test2']) }) - it('tag', () => { - vi.mocked(RouterBuilder).mockReturnValueOnce({ mocked: true } as any) - expect(builder.tag('tag1', 'tag2')).toEqual({ mocked: true }) + it('.router', () => { + const router = { + ping: {} as any, + pong: {} as any, + } + + const applied = builder.router(router) - expect(RouterBuilder).toBeCalledTimes(1) - expect(RouterBuilder).toBeCalledWith(expect.objectContaining({ - middlewares: [mid], - tags: ['tag1', 'tag2'], - errorMap: baseErrors, - })) + expect(applied).toBe(RouterBuilderRouterSpy.mock.results[0]!.value) + expect(RouterBuilderRouterSpy).toHaveBeenCalledWith(router) }) -}) -it('to AdaptedRouter', () => { - const ping = vi.fn() as any - const router = { - ping, - nested: { - ping, - }, - } - - expect(builder.router(router)).toEqual({ mocked: true }) - - expect(RouterBuilder).toBeCalledTimes(1) - expect(RouterBuilder).toBeCalledWith(expect.objectContaining({ - middlewares: [mid], - errorMap: baseErrors, - })) - - const routerBuilder = vi.mocked(RouterBuilder).mock.results[0]?.value - expect(vi.mocked(routerBuilder.router)).toBeCalledTimes(1) - expect(vi.mocked(routerBuilder.router)).toBeCalledWith(router) -}) + it('.lazy', () => { + const router = { + ping: {} as any, + pong: {} as any, + } -it('to DecoratedLazy', () => { - const loader = vi.fn() as any + const applied = builder.lazy(() => Promise.resolve({ default: router })) - expect(builder.lazy(loader)).toEqual({ mocked: true }) - expect(RouterBuilder).toBeCalledTimes(1) - expect(RouterBuilder).toBeCalledWith(expect.objectContaining({ - middlewares: [mid], - errorMap: baseErrors, - })) + expect(applied).toBe(RouterBuilderLazySpy.mock.results[0]!.value) + expect(RouterBuilderLazySpy).toHaveBeenCalledWith(expect.any(Function)) + expect(unlazy(RouterBuilderLazySpy.mock.results[0]!.value)).resolves.toEqual({ default: router }) + }) - const routerBuilder = vi.mocked(RouterBuilder).mock.results[0]?.value - expect(vi.mocked(routerBuilder.lazy)).toBeCalledTimes(1) - expect(vi.mocked(routerBuilder.lazy)).toBeCalledWith(loader) -}) + it('.contract', () => { + const contract = oc.router({ + ping: oc.input(schema).output(schema), + }) -it('to ChainableImplementer', () => { - const contract = vi.fn() as any + const applied = builder.contract(contract) - expect(builder.contract(contract)).toEqual({ mocked: true }) - expect(createChainableImplementer).toBeCalledTimes(1) - expect(createChainableImplementer).toBeCalledWith(contract, [mid]) + expect(applied).toBe(createChainableImplementerSpy.mock.results[0]!.value) + expect(createChainableImplementerSpy).toHaveBeenCalledWith(contract, { + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, + }) + }) }) diff --git a/packages/server/src/builder.ts b/packages/server/src/builder.ts index c46ef0354..c70f7fcdd 100644 --- a/packages/server/src/builder.ts +++ b/packages/server/src/builder.ts @@ -1,176 +1,161 @@ -import type { ContractRouter, ErrorMap, ErrorMapGuard, ErrorMapSuggestions, HTTPPath, RouteOptions, Schema, SchemaInput, SchemaOutput, StrictErrorMap } from '@orpc/contract' -import type { ORPCErrorConstructorMap } from './error' +import type { ContractRouter, ErrorMap, ErrorMapSuggestions, HTTPPath, RouteOptions, Schema, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { ContextGuard } from './context' import type { FlattenLazy } from './lazy' import type { Middleware } from './middleware' import type { DecoratedMiddleware } from './middleware-decorated' import type { ProcedureHandler } from './procedure' import type { Router } from './router' import type { AdaptedRouter } from './router-builder' -import type { Context, MergeContext, WELL_CONTEXT } from './types' +import type { Context, MergeContext } from './types' import { ContractProcedure } from '@orpc/contract' +import { BuilderWithErrors } from './builder-with-errors' +import { BuilderWithMiddlewares } from './builder-with-middlewares' import { type ChainableImplementer, createChainableImplementer } from './implementer-chainable' import { decorateMiddleware } from './middleware-decorated' import { ProcedureBuilder } from './procedure-builder' +import { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ProcedureBuilderWithOutput } from './procedure-builder-with-output' import { DecoratedProcedure } from './procedure-decorated' import { RouterBuilder } from './router-builder' -export interface BuilderDef { - middlewares: Middleware, Partial | undefined, unknown, any, Record>[] - errorMap: TErrorMap +export interface BuilderDef { + types?: { context: TContext } } -export class Builder { +export class Builder { '~type' = 'Builder' as const - '~orpc': BuilderDef + '~orpc': BuilderDef - constructor(def: BuilderDef) { + constructor(def: BuilderDef) { this['~orpc'] = def } - // TODO: separate it - context(): Builder> { - return new Builder({ - middlewares: [], - errorMap: {}, - }) + context(): Builder { + return this as any // just change at type level so safely cast here } - use> | undefined = undefined>( - middleware: Middleware< - MergeContext, - U, - unknown, - unknown, - ORPCErrorConstructorMap - >, - ): Builder, TErrorMap> { - return new Builder({ - ...this['~orpc'], - middlewares: [...this['~orpc'].middlewares, middleware as any], - }) + middleware, TInput, TOutput = any >( + middleware: Middleware>, + ): DecoratedMiddleware> { + return decorateMiddleware(middleware) } - errors & ErrorMapSuggestions>(errors: U): Builder { - return new Builder({ - ...this['~orpc'], - errorMap: { - ...this['~orpc'].errorMap, - ...errors, - }, + errors(errors: U): BuilderWithErrors { + return new BuilderWithErrors({ + errorMap: errors, }) } - // TODO: not allow define middleware after has context, or anything else - middleware< - UExtraContext extends Context & Partial> | undefined = undefined, - TInput = unknown, - TOutput = any, - >( - middleware: Middleware< - MergeContext, - UExtraContext, - TInput, - TOutput, - Record - >, - ): DecoratedMiddleware< - MergeContext, - UExtraContext, - TInput, - TOutput, - Record - > { - return decorateMiddleware(middleware) + use>( + middleware: Middleware>, + ): BuilderWithMiddlewares { + return new BuilderWithMiddlewares({ + ...this['~orpc'], + inputValidationIndex: 1, + outputValidationIndex: 1, + middlewares: [middleware as any], // FIXME: I believe we can remove `as any` here + }) } - route(route: RouteOptions): ProcedureBuilder { + route(route: RouteOptions): ProcedureBuilder> { return new ProcedureBuilder({ - middlewares: this['~orpc'].middlewares, + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, contract: new ContractProcedure({ route, InputSchema: undefined, OutputSchema: undefined, - errorMap: this['~orpc'].errorMap, + errorMap: {}, }), }) } - input( - schema: USchema, - example?: SchemaInput, - ): ProcedureBuilder { - return new ProcedureBuilder({ - middlewares: this['~orpc'].middlewares, + input(schema: USchema, example?: SchemaInput): ProcedureBuilderWithInput> { + return new ProcedureBuilderWithInput({ + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, contract: new ContractProcedure({ OutputSchema: undefined, InputSchema: schema, inputExample: example, - errorMap: this['~orpc'].errorMap, + errorMap: {}, }), }) } - output( - schema: USchema, - example?: SchemaOutput, - ): ProcedureBuilder { - return new ProcedureBuilder({ - middlewares: this['~orpc'].middlewares, + output(schema: USchema, example?: SchemaOutput): ProcedureBuilderWithOutput> { + return new ProcedureBuilderWithOutput({ + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, contract: new ContractProcedure({ InputSchema: undefined, OutputSchema: schema, outputExample: example, - errorMap: this['~orpc'].errorMap, + errorMap: {}, }), }) } - handler( - handler: ProcedureHandler, - ): DecoratedProcedure { + handler( + handler: ProcedureHandler>, + ): DecoratedProcedure> { return new DecoratedProcedure({ - preMiddlewares: this['~orpc'].middlewares, - postMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, contract: new ContractProcedure({ InputSchema: undefined, OutputSchema: undefined, - errorMap: this['~orpc'].errorMap, + errorMap: {}, }), handler, }) } - prefix(prefix: HTTPPath): RouterBuilder { + prefix(prefix: HTTPPath): RouterBuilder> { return new RouterBuilder({ - middlewares: this['~orpc'].middlewares, - errorMap: this['~orpc'].errorMap, + middlewares: [], + errorMap: {}, prefix, }) } - tag(...tags: string[]): RouterBuilder { + tag(...tags: string[]): RouterBuilder> { return new RouterBuilder({ - middlewares: this['~orpc'].middlewares, - errorMap: this['~orpc'].errorMap, + middlewares: [], + errorMap: {}, tags, }) } - router, ContractRouter>>>>( + router, any>>( router: U, - ): AdaptedRouter { - return new RouterBuilder(this['~orpc']).router(router) + ): AdaptedRouter> { + return new RouterBuilder>({ + middlewares: [], + errorMap: [], + }).router(router) } - lazy, ContractRouter>>>>( + lazy, any>>( loader: () => Promise<{ default: U }>, - ): AdaptedRouter, TErrorMap> { - return new RouterBuilder(this['~orpc']).lazy(loader) + ): AdaptedRouter, Record> { + return new RouterBuilder>({ + middlewares: [], + errorMap: {}, + }).lazy(loader) } contract>( contract: U, - ): ChainableImplementer { - return createChainableImplementer(contract, this['~orpc'].middlewares) + ): ChainableImplementer { + return createChainableImplementer(contract, { + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, + }) } } diff --git a/packages/server/src/context.ts b/packages/server/src/context.ts new file mode 100644 index 000000000..9ccb87a5c --- /dev/null +++ b/packages/server/src/context.ts @@ -0,0 +1,11 @@ +import type { Context } from './types' + +/** + * U extends Context & ContextGuard + * + * Purpose: + * - Ensures that any extension `U` of `Context` must conform to the current `TContext`. + * - This is useful when redefining `TContext` to maintain type compatibility with the existing context. + * + */ +export type ContextGuard = Partial | undefined diff --git a/packages/server/src/implementer-chainable.test-d.ts b/packages/server/src/implementer-chainable.test-d.ts index 237c9c2b9..40ef1b364 100644 --- a/packages/server/src/implementer-chainable.test-d.ts +++ b/packages/server/src/implementer-chainable.test-d.ts @@ -23,17 +23,17 @@ const contract = oc.router({ describe('ChainableImplementer', () => { it('with procedure', () => { - expectTypeOf(createChainableImplementer(ping)).toEqualTypeOf< + expectTypeOf(createChainableImplementer(ping, { middlewares: [], inputValidationIndex: 0, outputValidationIndex: 0 })).toEqualTypeOf< ProcedureImplementer> >() - expectTypeOf(createChainableImplementer(pong)).toEqualTypeOf< + expectTypeOf(createChainableImplementer(pong, { middlewares: [], inputValidationIndex: 0, outputValidationIndex: 0 })).toEqualTypeOf< ProcedureImplementer> >() }) it('with router', () => { - const implementer = createChainableImplementer(contract) + const implementer = createChainableImplementer(contract, { middlewares: [], inputValidationIndex: 0, outputValidationIndex: 0 }) expectTypeOf(implementer).toMatchTypeOf< Omit, '~type' | '~orpc'> @@ -61,7 +61,7 @@ describe('ChainableImplementer', () => { }) it('not expose properties of router implementer', () => { - const implementer = createChainableImplementer(contract) + const implementer = createChainableImplementer(contract, { middlewares: [], inputValidationIndex: 0, outputValidationIndex: 0 }) expectTypeOf(implementer).not.toHaveProperty('~orpc') expectTypeOf(implementer).not.toHaveProperty('~type') @@ -78,7 +78,7 @@ describe('ChainableImplementer', () => { }, }) - const implementer = createChainableImplementer(contract) + const implementer = createChainableImplementer(contract, { middlewares: [], inputValidationIndex: 0, outputValidationIndex: 0 }) expectTypeOf(implementer).toMatchTypeOf< Omit, '~type' | '~orpc'> @@ -104,18 +104,18 @@ describe('ChainableImplementer', () => { describe('createChainableImplementer', () => { it('with procedure', () => { - const implementer = createChainableImplementer(ping) + const implementer = createChainableImplementer(ping, { middlewares: [], inputValidationIndex: 0, outputValidationIndex: 0 }) expectTypeOf(implementer).toEqualTypeOf>() }) it('with router', () => { - const implementer = createChainableImplementer(contract) + const implementer = createChainableImplementer(contract, { middlewares: [], inputValidationIndex: 0, outputValidationIndex: 0 }) expectTypeOf(implementer).toEqualTypeOf>() }) it('with middlewares', () => { const mid = {} as Middleware<{ auth: boolean }, { db: string }, unknown, unknown, Record> - const implementer = createChainableImplementer(contract, [mid]) + const implementer = createChainableImplementer(contract, { middlewares: [mid], inputValidationIndex: 1, outputValidationIndex: 1 }) expectTypeOf(implementer).toEqualTypeOf>() }) }) diff --git a/packages/server/src/implementer-chainable.test.ts b/packages/server/src/implementer-chainable.test.ts index 66a83d865..06cd51021 100644 --- a/packages/server/src/implementer-chainable.test.ts +++ b/packages/server/src/implementer-chainable.test.ts @@ -24,36 +24,54 @@ describe('createChainableImplementer', () => { const mid3 = vi.fn() it('with procedure', () => { - const implementer = createChainableImplementer(ping, [mid1, mid2]) + const implementer = createChainableImplementer(ping, { + middlewares: [mid1, mid2], + inputValidationIndex: 2, + outputValidationIndex: 2, + }) expect(implementer).toBeInstanceOf(ProcedureImplementer) - expect(implementer['~orpc'].preMiddlewares).toEqual([mid1, mid2]) + expect(implementer['~orpc'].middlewares).toEqual([mid1, mid2]) + expect(implementer['~orpc'].inputValidationIndex).toEqual(2) + expect(implementer['~orpc'].outputValidationIndex).toEqual(2) expect(implementer['~orpc'].contract).toBe(ping) }) it('with router', () => { - const implementer = createChainableImplementer(contract, [mid1, mid2]) + const implementer = createChainableImplementer(contract, { + middlewares: [mid1, mid2], + inputValidationIndex: 2, + outputValidationIndex: 2, + }) 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'].preMiddlewares).toEqual([mid1, mid2]) + expect(implementer.ping['~orpc'].middlewares).toEqual([mid1, mid2]) + expect(implementer.ping['~orpc'].inputValidationIndex).toEqual(2) + expect(implementer.ping['~orpc'].outputValidationIndex).toEqual(2) expect(implementer.ping['~orpc'].contract).toBe(ping) expect(implementer.pong).toBeInstanceOf(ProcedureImplementer) - expect(implementer.pong['~orpc'].preMiddlewares).toEqual([mid1, mid2]) + expect(implementer.pong['~orpc'].middlewares).toEqual([mid1, mid2]) + expect(implementer.pong['~orpc'].inputValidationIndex).toEqual(2) + expect(implementer.pong['~orpc'].outputValidationIndex).toEqual(2) 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'].preMiddlewares).toEqual([mid1, mid2]) + expect(implementer.nested.ping['~orpc'].middlewares).toEqual([mid1, mid2]) + expect(implementer.nested.ping['~orpc'].inputValidationIndex).toEqual(2) + expect(implementer.nested.ping['~orpc'].outputValidationIndex).toEqual(2) expect(implementer.nested.ping['~orpc'].contract).toBe(contract.nested.ping) expect(implementer.nested.pong).toBeInstanceOf(ProcedureImplementer) - expect(implementer.nested.pong['~orpc'].preMiddlewares).toEqual([mid1, mid2]) + expect(implementer.nested.pong['~orpc'].middlewares).toEqual([mid1, mid2]) + expect(implementer.nested.pong['~orpc'].inputValidationIndex).toEqual(2) + expect(implementer.nested.pong['~orpc'].outputValidationIndex).toEqual(2) expect(implementer.nested.pong['~orpc'].contract).toBe(contract.nested.pong) }) @@ -74,7 +92,11 @@ describe('createChainableImplementer', () => { }, } - const implementer = createChainableImplementer(contract, [mid1, mid2]) + const implementer = createChainableImplementer(contract, { + middlewares: [mid1, mid2], + inputValidationIndex: 2, + outputValidationIndex: 2, + }) it('still works', () => { expect(implementer.use(mid3)['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) @@ -82,8 +104,9 @@ describe('createChainableImplementer', () => { expect(implementer.use).toBeTypeOf('function') expect(implementer.use.use(mid3)).toBeInstanceOf(ProcedureImplementer) - expect(implementer.use.use(mid3)['~orpc'].preMiddlewares).toEqual([mid1, mid2]) - expect(implementer.use.use(mid3)['~orpc'].postMiddlewares).toEqual([mid3]) + expect(implementer.use.use(mid3)['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) + expect(implementer.use.use(mid3)['~orpc'].inputValidationIndex).toEqual(2) + expect(implementer.use.use(mid3)['~orpc'].outputValidationIndex).toEqual(2) expect(implementer.use['~orpc'].contract).toBe(ping) expect(implementer.router).toBeTypeOf('function') @@ -93,20 +116,23 @@ describe('createChainableImplementer', () => { expect(implementer.router.router).toBeTypeOf('function') expect(implementer.router.router.use(mid3)).toBeInstanceOf(ProcedureImplementer) - expect(implementer.router.router.use(mid3)['~orpc'].preMiddlewares).toEqual([mid1, mid2]) - expect(implementer.router.router.use(mid3)['~orpc'].postMiddlewares).toEqual([mid3]) + expect(implementer.router.router.use(mid3)['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) + expect(implementer.use.use(mid3)['~orpc'].inputValidationIndex).toEqual(2) + expect(implementer.use.use(mid3)['~orpc'].outputValidationIndex).toEqual(2) 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'].preMiddlewares).toEqual([mid1, mid2]) - expect(implementer.router.use.use(mid3)['~orpc'].postMiddlewares).toEqual([mid3]) + expect(implementer.router.use.use(mid3)['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) + expect(implementer.use.use(mid3)['~orpc'].inputValidationIndex).toEqual(2) + expect(implementer.use.use(mid3)['~orpc'].outputValidationIndex).toEqual(2) 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'].preMiddlewares).toEqual([mid1, mid2]) - expect(implementer['~orpc'].use.use(mid3)['~orpc'].postMiddlewares).toEqual([mid3]) + expect(implementer['~orpc'].use.use(mid3)['~orpc'].middlewares).toEqual([mid1, mid2, mid3]) + expect(implementer.use.use(mid3)['~orpc'].inputValidationIndex).toEqual(2) + expect(implementer.use.use(mid3)['~orpc'].outputValidationIndex).toEqual(2) expect(implementer['~orpc'].use['~orpc'].contract).toBe(contract.router.use) }) diff --git a/packages/server/src/implementer-chainable.ts b/packages/server/src/implementer-chainable.ts index 4333e4856..1b98f72e8 100644 --- a/packages/server/src/implementer-chainable.ts +++ b/packages/server/src/implementer-chainable.ts @@ -21,13 +21,18 @@ export function createChainableImplementer< TContract extends ContractRouter = any, >( contract: TContract, - middlewares: Middleware, Partial | undefined, unknown, any, Record>[] = [], + options: { + middlewares: Middleware, Partial | undefined, unknown, any, any>[] + inputValidationIndex: number + outputValidationIndex: number + }, ): ChainableImplementer { if (isContractProcedure(contract)) { const implementer = new ProcedureImplementer({ contract, - preMiddlewares: middlewares, - postMiddlewares: [], + middlewares: options.middlewares, + inputValidationIndex: options.inputValidationIndex, + outputValidationIndex: options.outputValidationIndex, }) return implementer as any @@ -36,10 +41,13 @@ export function createChainableImplementer< const chainable = {} as ChainableImplementer for (const key in contract) { - (chainable as any)[key] = createChainableImplementer(contract[key]!, middlewares) + (chainable as any)[key] = createChainableImplementer(contract[key]!, options) } - const routerImplementer = new RouterImplementer({ contract, middlewares }) + const routerImplementer = new RouterImplementer({ + contract, + middlewares: options.middlewares, + }) const merged = new Proxy(chainable, { get(target, key) { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f27e04619..b13b30dec 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -23,7 +23,4 @@ export * from './types' export * from './utils' export { configGlobal, fallbackToGlobalConfig, isDefinedError, ORPCError, safe } from '@orpc/contract' -export const os = new Builder>({ - middlewares: [], - errorMap: {}, -}) +export const os = new Builder({}) diff --git a/packages/server/src/lazy-decorated.test.ts b/packages/server/src/lazy-decorated.test.ts index c4dc6b107..80e6787ed 100644 --- a/packages/server/src/lazy-decorated.test.ts +++ b/packages/server/src/lazy-decorated.test.ts @@ -18,8 +18,9 @@ describe('decorated lazy', () => { errorMap: {}, }), handler: vi.fn(), - preMiddlewares: [], - postMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) const lazyPing = lazy(() => Promise.resolve({ default: ping })) diff --git a/packages/server/src/lazy-utils.test.ts b/packages/server/src/lazy-utils.test.ts index 6496cfc26..7a1140aa0 100644 --- a/packages/server/src/lazy-utils.test.ts +++ b/packages/server/src/lazy-utils.test.ts @@ -11,8 +11,9 @@ describe('createLazyProcedureFormAnyLazy', () => { errorMap: {}, }), handler: vi.fn(), - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) it('return a Lazy', async () => { diff --git a/packages/server/src/lazy.test.ts b/packages/server/src/lazy.test.ts index 5c9d79bff..e7aaa1b4b 100644 --- a/packages/server/src/lazy.test.ts +++ b/packages/server/src/lazy.test.ts @@ -10,8 +10,9 @@ const procedure = new Procedure, > extends Middleware { concat: (< - UExtraContext extends Context & Partial> | undefined = undefined, - UInput = unknown, + UExtraContext extends Context & ContextGuard>, + UInput, >( middleware: Middleware< MergeContext, @@ -28,7 +29,7 @@ export interface DecoratedMiddleware< TOutput, TErrorConstructorMap >) & (< - UExtraContext extends Context & Partial> | undefined = undefined, + UExtraContext extends Context & ContextGuard>, UInput = TInput, UMappedInput = unknown, >( diff --git a/packages/server/src/procedure-builder-with-input.test-d.ts b/packages/server/src/procedure-builder-with-input.test-d.ts new file mode 100644 index 000000000..aa7e71444 --- /dev/null +++ b/packages/server/src/procedure-builder-with-input.test-d.ts @@ -0,0 +1,120 @@ +import type { ORPCErrorConstructorMap } from './error' +import type { MiddlewareOutputFn } from './middleware' +import type { ANY_PROCEDURE } from './procedure' +import type { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import type { DecoratedProcedure } from './procedure-decorated' +import type { ProcedureImplementer } from './procedure-implementer' +import { z } from 'zod' + +const baseErrors = { + BASE: { + status: 402, + message: 'default message', + data: z.object({ + why: z.string(), + }), + }, +} + +const inputSchema = z.object({ input: z.string().transform(v => Number.parseInt(v)) }) + +const builder = {} as ProcedureBuilderWithInput<{ db: string }, { auth?: boolean }, typeof inputSchema, typeof baseErrors> + +const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + +describe('ProcedureBuilderWithInput', () => { + it('.errors', () => { + const errors = { CODE: { message: 'MESSAGE' } } + + expectTypeOf(builder.errors(errors)).toEqualTypeOf< + ProcedureBuilderWithInput<{ db: string }, { auth?: boolean }, typeof inputSchema, typeof baseErrors & typeof errors> + >() + + // @ts-expect-error - not allow redefine error map + builder.errors({ BASE: baseErrors.BASE }) + }) + + it('.route', () => { + expectTypeOf(builder.route({ tags: ['a'] })).toEqualTypeOf() + }) + + describe('.use', () => { + it('without map input', () => { + const applied = builder.use(({ context, next, path, procedure, errors }, input, output) => { + expectTypeOf(input).toEqualTypeOf<{ input: number }>() + expectTypeOf(context).toEqualTypeOf<{ db: string } & { auth?: boolean }>() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(output).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() + + return next({ + context: { + extra: true, + }, + }) + }) + + expectTypeOf(applied).toEqualTypeOf>() + + // @ts-expect-error --- conflict context + builder.use(({ next }) => next({ db: 123 })) + // @ts-expect-error --- input is not match + builder.use(({ next }, input: 'invalid') => next({})) + // @ts-expect-error --- output is not match + builder.use(({ next }, input, output: MiddlewareOutputFn<'invalid'>) => next({})) + }) + + it('with map input', () => { + const applied = builder.use(({ context, next, path, procedure, errors }, input: { mapped: number }, output) => { + expectTypeOf(input).toEqualTypeOf<{ mapped: number }>() + expectTypeOf(context).toEqualTypeOf<{ db: string } & { auth?: boolean }>() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(output).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() + + return next({ + context: { + extra: true, + }, + }) + }, (input) => { + expectTypeOf(input).toEqualTypeOf<{ input: number }>() + return { mapped: input.input } + }) + + expectTypeOf(applied).toEqualTypeOf>() + + // @ts-expect-error --- conflict context + builder.use(({ next }) => ({ db: 123 }), () => {}) + // @ts-expect-error --- input is not match + builder.use(({ next }, input: 'invalid') => next({}), () => {}) + // @ts-expect-error --- output is not match + builder.use(({ next }, input, output: MiddlewareOutputFn<'invalid'>) => next({}), () => {}) + }) + }) + + it('.output', () => { + expectTypeOf(builder.output(schema)).toEqualTypeOf< + ProcedureImplementer<{ db: string }, { auth?: boolean }, typeof inputSchema, typeof schema, typeof baseErrors> + >() + }) + + it('.handler', () => { + const procedure = builder.handler(({ input, context, procedure, path, signal, errors }) => { + expectTypeOf(input).toEqualTypeOf<{ input: number }>() + expectTypeOf(context).toEqualTypeOf<{ db: string } & { auth?: boolean }>() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(signal).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() + + return 456 + }) + + expectTypeOf(procedure).toMatchTypeOf< + DecoratedProcedure<{ db: string }, { auth?: boolean }, typeof inputSchema, undefined, number, typeof baseErrors> + >() + }) +}) diff --git a/packages/server/src/procedure-builder-with-input.test.ts b/packages/server/src/procedure-builder-with-input.test.ts new file mode 100644 index 000000000..14bf6f2ea --- /dev/null +++ b/packages/server/src/procedure-builder-with-input.test.ts @@ -0,0 +1,127 @@ +import { ContractProcedure } from '@orpc/contract' +import { z } from 'zod' +import * as middlewareDecorated from './middleware-decorated' +import { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import { DecoratedProcedure } from './procedure-decorated' +import { ProcedureImplementer } from './procedure-implementer' + +const decorateMiddlewareSpy = vi.spyOn(middlewareDecorated, 'decorateMiddleware') + +const baseErrors = { + BASE: { + status: 402, + message: 'default message', + data: z.object({ + why: z.string(), + }), + }, +} + +const mid = vi.fn() + +const inputSchema = z.object({ input: z.string().transform(v => Number.parseInt(v)) }) + +const builder = new ProcedureBuilderWithInput({ + middlewares: [mid], + inputValidationIndex: 1, + outputValidationIndex: 1, + contract: new ContractProcedure({ + InputSchema: inputSchema, + OutputSchema: undefined, + errorMap: baseErrors, + }), +}) + +const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + +describe('procedureBuilderWithInput', () => { + it('.errors', () => { + const errors = { CODE: { message: 'MESSAGE' } } + const applied = builder.errors(errors) + + expect(applied).toBeInstanceOf(ProcedureBuilderWithInput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].InputSchema).toEqual(inputSchema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual({ + ...baseErrors, + ...errors, + }) + }) + + it('.route', () => { + const applied = builder.route({ tags: ['a'] }) + + expect(applied).toBeInstanceOf(ProcedureBuilderWithInput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].InputSchema).toEqual(inputSchema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + }) + + describe('.use', () => { + it('without map input', () => { + const mid2 = vi.fn() + + const applied = builder.use(mid2) + + expect(applied).toBeInstanceOf(ProcedureBuilderWithInput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].middlewares).toEqual([mid, mid2]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(2) + expect(applied['~orpc'].contract['~orpc'].InputSchema).toEqual(inputSchema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + }) + + it('with map input', () => { + const mappedMid = vi.fn() + const mapInput = vi.fn(() => mappedMid) + decorateMiddlewareSpy.mockReturnValueOnce({ mapInput } as any) + + const mid2 = vi.fn() + const mid2MapInput = vi.fn() + + const applied = builder.use(mid2, mid2MapInput) + + expect(applied).toBeInstanceOf(ProcedureBuilderWithInput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].middlewares).toEqual([mid, mappedMid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(2) + expect(applied['~orpc'].contract['~orpc'].InputSchema).toEqual(inputSchema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + + expect(decorateMiddlewareSpy).toHaveBeenCalledWith(mid2) + expect(mapInput).toHaveBeenCalledWith(mid2MapInput) + }) + }) + + it('.output', () => { + const applied = builder.output(schema) + + expect(applied).toBeInstanceOf(ProcedureImplementer) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].InputSchema).toEqual(inputSchema) + expect(applied['~orpc'].contract['~orpc'].OutputSchema).toEqual(schema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + }) + + it('.handler', () => { + const handler = vi.fn() + const applied = builder.handler(handler) + + expect(applied).toBeInstanceOf(DecoratedProcedure) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].InputSchema).toEqual(inputSchema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + }) +}) diff --git a/packages/server/src/procedure-builder-with-input.ts b/packages/server/src/procedure-builder-with-input.ts new file mode 100644 index 000000000..d09a92daf --- /dev/null +++ b/packages/server/src/procedure-builder-with-input.ts @@ -0,0 +1,109 @@ +import type { ContractProcedure, ErrorMap, ErrorMapGuard, ErrorMapSuggestions, RouteOptions, Schema, SchemaOutput } from '@orpc/contract' +import type { ContextGuard } from './context' +import type { ORPCErrorConstructorMap } from './error' +import type { MapInputMiddleware, Middleware } from './middleware' +import type { ProcedureHandler } from './procedure' +import type { Context, MergeContext } from './types' +import { ContractProcedureBuilderWithInput, DecoratedContractProcedure } from '@orpc/contract' +import { decorateMiddleware } from './middleware-decorated' +import { DecoratedProcedure } from './procedure-decorated' +import { ProcedureImplementer } from './procedure-implementer' + +export interface ProcedureBuilderWithInputDef< + TContext extends Context, + TExtraContext extends Context, + TInputSchema extends Schema, + TErrorMap extends ErrorMap, +> { + contract: ContractProcedure + middlewares: Middleware, Partial | undefined, unknown, unknown, ORPCErrorConstructorMap>[] + inputValidationIndex: number + outputValidationIndex: number +} + +/** + * `ProcedureBuilderWithInput` is a branch of `ProcedureBuilder` which it has input schema. + * + * Why? + * - prevents override input schema after .input + * - allows .use between .input and .output + * + */ +export class ProcedureBuilderWithInput< + TContext extends Context, + TExtraContext extends Context, + TInputSchema extends Schema, + TErrorMap extends ErrorMap, +> { + '~type' = 'ProcedureBuilderWithInput' as const + '~orpc': ProcedureBuilderWithInputDef + + constructor(def: ProcedureBuilderWithInputDef) { + this['~orpc'] = def + } + + errors & ErrorMapSuggestions>( + errors: U, + ): ProcedureBuilderWithInput { + return new ProcedureBuilderWithInput({ + ...this['~orpc'], + contract: DecoratedContractProcedure + .decorate(this['~orpc'].contract) + .errors(errors), + }) + } + + route(route: RouteOptions): ProcedureBuilderWithInput { + return new ProcedureBuilderWithInput({ + ...this['~orpc'], + contract: DecoratedContractProcedure + .decorate(this['~orpc'].contract) + .route(route), + }) + } + + use>>( + middleware: Middleware, U, SchemaOutput, unknown, ORPCErrorConstructorMap>, + ): ProcedureBuilderWithInput, TInputSchema, TErrorMap> + + use< + UExtra extends Context & ContextGuard>, + UInput, + >( + middleware: Middleware, UExtra, UInput, unknown, ORPCErrorConstructorMap>, + mapInput: MapInputMiddleware, UInput>, + ): ProcedureBuilderWithInput, TInputSchema, TErrorMap> + + use( + middleware: Middleware, + mapInput?: MapInputMiddleware, + ): ProcedureBuilderWithInput { + const maybeWithMapInput = mapInput + ? decorateMiddleware(middleware).mapInput(mapInput) + : middleware + + // TODO: order of middleware before/after validation? + + return new ProcedureBuilderWithInput({ + ...this['~orpc'], + outputValidationIndex: this['~orpc'].outputValidationIndex + 1, + middlewares: [...this['~orpc'].middlewares, maybeWithMapInput], + }) + } + + output(schema: U, example?: SchemaOutput): ProcedureImplementer { + return new ProcedureImplementer({ + ...this['~orpc'], + contract: new ContractProcedureBuilderWithInput(this['~orpc'].contract['~orpc']).output(schema, example), + }) + } + + handler( + handler: ProcedureHandler, + ): DecoratedProcedure { + return new DecoratedProcedure({ + ...this['~orpc'], + handler, + }) + } +} diff --git a/packages/server/src/procedure-builder-with-output.test-d.ts b/packages/server/src/procedure-builder-with-output.test-d.ts new file mode 100644 index 000000000..405fe241e --- /dev/null +++ b/packages/server/src/procedure-builder-with-output.test-d.ts @@ -0,0 +1,92 @@ +import type { ORPCErrorConstructorMap } from './error' +import type { MiddlewareOutputFn } from './middleware' +import type { ANY_PROCEDURE } from './procedure' +import type { ProcedureBuilderWithOutput } from './procedure-builder-with-output' +import type { DecoratedProcedure } from './procedure-decorated' +import type { ProcedureImplementer } from './procedure-implementer' +import { z } from 'zod' + +const baseErrors = { + BASE: { + status: 402, + message: 'default message', + data: z.object({ + why: z.string(), + }), + }, +} + +const outputSchema = z.object({ output: z.string().transform(v => Number.parseInt(v)) }) + +const builder = {} as ProcedureBuilderWithOutput<{ db: string }, { auth?: boolean }, typeof outputSchema, typeof baseErrors> + +const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + +describe('ProcedureBuilderWithOutput', () => { + it('.errors', () => { + const errors = { CODE: { message: 'MESSAGE' } } + + expectTypeOf(builder.errors(errors)).toEqualTypeOf< + ProcedureBuilderWithOutput<{ db: string }, { auth?: boolean }, typeof outputSchema, typeof baseErrors & typeof errors> + >() + + // @ts-expect-error - not allow redefine error map + builder.errors({ BASE: baseErrors.BASE }) + }) + + it('.route', () => { + expectTypeOf(builder.route({ tags: ['a'] })).toEqualTypeOf() + }) + + describe('.use', () => { + const applied = builder.use(({ context, next, path, procedure, errors }, input, output) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ db: string } & { auth?: boolean }>() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(output).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() + + return next({ + context: { + extra: true, + }, + }) + }) + + expectTypeOf(applied).toEqualTypeOf>() + + // @ts-expect-error --- conflict context + builder.use(({ next }) => next({ db: 123 })) + // @ts-expect-error --- input is not match + builder.use(({ next }, input: 'invalid') => next({})) + // @ts-expect-error --- output is not match + builder.use(({ next }, input, output: MiddlewareOutputFn<'invalid'>) => next({})) + }) + + it('.input', () => { + expectTypeOf(builder.input(schema)).toEqualTypeOf< + ProcedureImplementer<{ db: string }, { auth?: boolean }, typeof schema, typeof outputSchema, typeof baseErrors> + >() + }) + + it('.handler', () => { + const procedure = builder.handler(({ input, context, procedure, path, signal, errors }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ db: string } & { auth?: boolean }>() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(signal).toEqualTypeOf>() + expectTypeOf(errors).toEqualTypeOf>() + + return { output: '123' } + }) + + expectTypeOf(procedure).toMatchTypeOf< + DecoratedProcedure < { db: string }, { auth?: boolean }, undefined, typeof outputSchema, { output: string }, typeof baseErrors> + >() + + // @ts-expect-error --- invalid output + builder.handler(() => ({ output: 123 })) + }) +}) diff --git a/packages/server/src/procedure-builder-with-output.test.ts b/packages/server/src/procedure-builder-with-output.test.ts new file mode 100644 index 000000000..bb7345abe --- /dev/null +++ b/packages/server/src/procedure-builder-with-output.test.ts @@ -0,0 +1,100 @@ +import { ContractProcedure } from '@orpc/contract' +import { z } from 'zod' +import { ProcedureBuilderWithOutput } from './procedure-builder-with-output' +import { DecoratedProcedure } from './procedure-decorated' +import { ProcedureImplementer } from './procedure-implementer' + +const baseErrors = { + BASE: { + status: 402, + message: 'default message', + data: z.object({ + why: z.string(), + }), + }, +} + +const mid = vi.fn() + +const outputSchema = z.object({ input: z.string().transform(v => Number.parseInt(v)) }) + +const builder = new ProcedureBuilderWithOutput({ + middlewares: [mid], + inputValidationIndex: 1, + outputValidationIndex: 1, + contract: new ContractProcedure({ + InputSchema: undefined, + OutputSchema: outputSchema, + errorMap: baseErrors, + }), +}) + +const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) + +describe('procedureBuilderWithOutput', () => { + it('.errors', () => { + const errors = { CODE: { message: 'MESSAGE' } } + const applied = builder.errors(errors) + + expect(applied).toBeInstanceOf(ProcedureBuilderWithOutput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].OutputSchema).toEqual(outputSchema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual({ + ...baseErrors, + ...errors, + }) + }) + + it('.route', () => { + const applied = builder.route({ tags: ['a'] }) + + expect(applied).toBeInstanceOf(ProcedureBuilderWithOutput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].OutputSchema).toEqual(outputSchema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + }) + + it('.use', () => { + const mid2 = vi.fn() + + const applied = builder.use(mid2) + + expect(applied).toBeInstanceOf(ProcedureBuilderWithOutput) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].middlewares).toEqual([mid, mid2]) + expect(applied['~orpc'].inputValidationIndex).toEqual(2) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].OutputSchema).toEqual(outputSchema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + }) + + it('.input', () => { + const applied = builder.input(schema) + + expect(applied).toBeInstanceOf(ProcedureImplementer) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].OutputSchema).toEqual(outputSchema) + expect(applied['~orpc'].contract['~orpc'].InputSchema).toEqual(schema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + }) + + it('.handler', () => { + const handler = vi.fn() + const applied = builder.handler(handler) + + expect(applied).toBeInstanceOf(DecoratedProcedure) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].OutputSchema).toEqual(outputSchema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + }) +}) diff --git a/packages/server/src/procedure-builder-with-output.ts b/packages/server/src/procedure-builder-with-output.ts new file mode 100644 index 000000000..c166bb202 --- /dev/null +++ b/packages/server/src/procedure-builder-with-output.ts @@ -0,0 +1,92 @@ +import type { ContractProcedure, ErrorMap, ErrorMapGuard, ErrorMapSuggestions, RouteOptions, Schema, SchemaInput } from '@orpc/contract' +import type { ContextGuard } from './context' +import type { ORPCErrorConstructorMap } from './error' +import type { Middleware } from './middleware' +import type { ProcedureHandler } from './procedure' +import type { Context, MergeContext } from './types' +import { ContractProcedureBuilderWithOutput, DecoratedContractProcedure } from '@orpc/contract' +import { DecoratedProcedure } from './procedure-decorated' +import { ProcedureImplementer } from './procedure-implementer' + +export interface ProcedureBuilderWithOutputDef< + TContext extends Context, + TExtraContext extends Context, + TOutputSchema extends Schema, + TErrorMap extends ErrorMap, +> { + contract: ContractProcedure + middlewares: Middleware, Partial | undefined, unknown, any, ORPCErrorConstructorMap>[] + inputValidationIndex: number + outputValidationIndex: number +} + +/** + * `ProcedureBuilderWithOutput` is a branch of `ProcedureBuilder` which it has output schema. + * + * Why? + * - prevents override output schema after .output + * - allows .use between .input and .output + * + */ +export class ProcedureBuilderWithOutput< + TContext extends Context, + TExtraContext extends Context, + TOutputSchema extends Schema, + TErrorMap extends ErrorMap, +> { + '~type' = 'ProcedureBuilderWithOutput' as const + '~orpc': ProcedureBuilderWithOutputDef + + constructor(def: ProcedureBuilderWithOutputDef) { + this['~orpc'] = def + } + + errors & ErrorMapSuggestions>( + errors: U, + ): ProcedureBuilderWithOutput { + return new ProcedureBuilderWithOutput({ + ...this['~orpc'], + contract: DecoratedContractProcedure + .decorate(this['~orpc'].contract) + .errors(errors), + }) + } + + route(route: RouteOptions): ProcedureBuilderWithOutput { + return new ProcedureBuilderWithOutput({ + ...this['~orpc'], + contract: DecoratedContractProcedure + .decorate(this['~orpc'].contract) + .route(route), + }) + } + + use>>( + middleware: Middleware, U, unknown, SchemaInput, ORPCErrorConstructorMap>, + ): ProcedureBuilderWithOutput, TOutputSchema, TErrorMap> { + return new ProcedureBuilderWithOutput({ + ...this['~orpc'], + inputValidationIndex: this['~orpc'].inputValidationIndex + 1, + middlewares: [...this['~orpc'].middlewares, middleware as any], + }) + } + + input( + schema: U, + example?: SchemaInput, + ): ProcedureImplementer { + return new ProcedureImplementer({ + ...this['~orpc'], + contract: new ContractProcedureBuilderWithOutput(this['~orpc'].contract['~orpc']).input(schema, example), + }) + } + + handler>( + handler: ProcedureHandler, + ): DecoratedProcedure { + return new DecoratedProcedure({ + ...this['~orpc'], + handler, + }) + } +} diff --git a/packages/server/src/procedure-builder.test-d.ts b/packages/server/src/procedure-builder.test-d.ts index 4c0d7f85b..13965e55c 100644 --- a/packages/server/src/procedure-builder.test-d.ts +++ b/packages/server/src/procedure-builder.test-d.ts @@ -1,180 +1,88 @@ -import type { RouteOptions } from '@orpc/contract' import type { ORPCErrorConstructorMap } from './error' -import type { Middleware } from './middleware' +import type { MiddlewareOutputFn } from './middleware' import type { ANY_PROCEDURE } from './procedure' import type { ProcedureBuilder } from './procedure-builder' +import type { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import type { ProcedureBuilderWithOutput } from './procedure-builder-with-output' import type { DecoratedProcedure } from './procedure-decorated' -import type { ProcedureImplementer } from './procedure-implementer' -import type { WELL_CONTEXT } from './types' import { z } from 'zod' -const baseSchema = z.object({ base: z.string().transform(v => Number.parseInt(v)) }) const baseErrors = { - PAYMENT_REQUIRED: { + BASE: { status: 402, message: 'default message', - data: baseSchema, + data: z.object({ + why: z.string(), + }), }, } -const builder = {} as ProcedureBuilder<{ id?: string }, { extra: true }, typeof baseSchema, typeof baseSchema, typeof baseErrors> +const builder = {} as ProcedureBuilder<{ db: string }, { auth?: boolean }, typeof baseErrors> const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) -describe('self chainable', () => { - 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>() +describe('ProcedureBuilder', () => { + it('.errors', () => { + const errors = { CODE: { message: 'MESSAGE' } } - // @ts-expect-error - invalid schema - builder.output({}) + expectTypeOf(builder.errors(errors)).toEqualTypeOf>() - // @ts-expect-error - invalid example - builder.output(schema, {}) - - // @ts-expect-error - invalid example - builder.output(schema, { id: '1' }) + // @ts-expect-error - not allow redefine error map + builder.errors({ BASE: baseErrors.BASE }) }) - it('errors', () => { - expectTypeOf(builder.errors({ ANYTHING: { data: schema } })).toEqualTypeOf< - ProcedureBuilder<{ id?: string }, { extra: true }, typeof baseSchema, typeof baseSchema, { ANYTHING: { data: typeof schema } } & typeof baseErrors> - >() - - // @ts-expect-error - invalid schema - builder.errors({ ANYTHING: { data: {} } }) - // @ts-expect-error - not allow redefine errorMap - builder.errors({ PAYMENT_REQUIRED: baseErrors.PAYMENT_REQUIRED }) - // @ts-expect-error - not allow redefine errorMap --- even with undefined - builder.errors({ PAYMENT_REQUIRED: undefined }) + it('.route', () => { + expectTypeOf(builder.route({ tags: ['a'] })).toEqualTypeOf() }) -}) -describe('to ProcedureImplementer', () => { - it('use middleware', () => { - const implementer = builder.use(async ({ context, path, next, errors }, input) => { - expectTypeOf(context).toEqualTypeOf<{ id?: string } & { extra: true }>() - expectTypeOf(input).toEqualTypeOf<{ base: number }>() + it('.use', () => { + const applied = builder.use(({ context, next, path, procedure, errors }, input, output) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ db: string } & { auth?: boolean }>() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(output).toEqualTypeOf>() expectTypeOf(errors).toEqualTypeOf>() - const result = await next({}) - - expectTypeOf(result.output).toEqualTypeOf<{ base: string }>() - - return next({ context: { id: '1', extra: true } }) - }) - - expectTypeOf(implementer).toEqualTypeOf< - ProcedureImplementer<{ id?: string }, { extra: true } & { id: string, extra: true }, typeof baseSchema, typeof baseSchema, typeof baseErrors> - >() - }) - - it('use middleware with map input', () => { - const mid: Middleware> = ({ next }) => { return next({ - context: { id: 'string', extra: true }, + context: { + extra: true, + }, }) - } - - const implementer = builder.use(mid, (input) => { - expectTypeOf(input).toEqualTypeOf<{ base: number }>() - return input.base }) - expectTypeOf(implementer).toEqualTypeOf< - ProcedureImplementer<{ id?: string }, { extra: true } & { id: string, extra: true }, typeof baseSchema, typeof baseSchema, typeof baseErrors> - >() - - // @ts-expect-error - invalid input - builder.use(mid) + expectTypeOf(applied).toEqualTypeOf>() - // @ts-expect-error - invalid mapped input - builder.use(mid, input => input) + // @ts-expect-error --- conflict context + builder.use(({ next }) => next({ db: 123 })) + // @ts-expect-error --- input is not match + builder.use(({ next }, input: 'invalid') => next({})) + // @ts-expect-error --- output is not match + builder.use(({ next }, input, output: MiddlewareOutputFn<'invalid'>) => next({})) }) - it('use middleware prevent conflict on context', () => { - builder.use(({ context, path, next }, input) => next({})) - builder.use(({ context, path, next }, input) => next({ context: { id: '1' } })) - builder.use(({ context, path, next }, input) => next({ context: { id: '1', extra: true } })) - builder.use(({ context, path, next }, input) => next({ context: { auth: true } })) - - builder.use(({ context, path, next }, input) => next({}), () => 'anything') - builder.use(({ context, path, next }, input) => next({ context: { id: '1' } }), () => 'anything') - builder.use(({ context, path, next }, input) => next({ context: { id: '1', extra: true } }), () => 'anything') - builder.use(({ context, path, next }, input) => next({ context: { auth: true } }), () => 'anything') - - // @ts-expect-error - conflict with context - builder.use(({ context, path, next }, input) => next({ context: { id: 1 } })) - - // @ts-expect-error - conflict with context - builder.use(({ context, path, next }, input) => next({ context: { id: 1, extra: true } })) - - // @ts-expect-error - conflict with context - builder.use(({ context, path, next }, input) => next({ context: { id: 1 } }), () => 'anything') - - // @ts-expect-error - conflict with context - builder.use(({ context, path, next }, input) => next({ context: { id: 1, extra: true } }), () => 'anything') + it('.input', () => { + expectTypeOf(builder.input(schema)).toEqualTypeOf>() }) - 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) + it('.output', () => { + expectTypeOf(builder.output(schema)).toEqualTypeOf>() }) -}) -describe('to DecoratedProcedure', () => { - it('handler', () => { - const procedure = builder.handler(async ({ input, context, path, procedure, signal, errors }) => { - expectTypeOf(context).toEqualTypeOf<{ id?: string } & { extra: true }>() - expectTypeOf(input).toEqualTypeOf<{ base: number }>() + it('.handler', () => { + const procedure = builder.handler(({ input, context, procedure, path, signal, errors }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ db: string } & { auth?: boolean }>() expectTypeOf(procedure).toEqualTypeOf() expectTypeOf(path).toEqualTypeOf() expectTypeOf(signal).toEqualTypeOf>() expectTypeOf(errors).toEqualTypeOf>() - return { base: '1' } + return 456 }) - expectTypeOf(procedure).toEqualTypeOf< - DecoratedProcedure<{ id?: string }, { extra: true }, typeof baseSchema, typeof baseSchema, { base: string }, typeof baseErrors> + expectTypeOf(procedure).toMatchTypeOf< + DecoratedProcedure<{ db: string }, { auth?: boolean }, undefined, undefined, number, typeof baseErrors> >() - - // @ts-expect-error - invalid output - builder.handler(async ({ input, context }) => ({ id: 1 })) - - // @ts-expect-error - invalid output - builder.handler(async ({ input, context }) => (true)) }) }) diff --git a/packages/server/src/procedure-builder.test.ts b/packages/server/src/procedure-builder.test.ts index dbffc7c6a..512dd411c 100644 --- a/packages/server/src/procedure-builder.test.ts +++ b/packages/server/src/procedure-builder.test.ts @@ -1,144 +1,105 @@ import { ContractProcedure } from '@orpc/contract' import { z } from 'zod' -import { isProcedure } from './procedure' import { ProcedureBuilder } from './procedure-builder' -import { ProcedureImplementer } from './procedure-implementer' +import { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ProcedureBuilderWithOutput } from './procedure-builder-with-output' +import { DecoratedProcedure } from './procedure-decorated' -const baseSchema = z.object({ base: z.string().transform(v => Number.parseInt(v)) }) const baseErrors = { - PAYMENT_REQUIRED: { + BASE: { status: 402, message: 'default message', - data: baseSchema, + data: z.object({ + why: z.string(), + }), }, } -const baseMid = vi.fn() + +const mid = vi.fn() const builder = new ProcedureBuilder({ + middlewares: [mid], + inputValidationIndex: 1, + outputValidationIndex: 1, contract: new ContractProcedure({ - InputSchema: baseSchema, - OutputSchema: baseSchema, + InputSchema: undefined, + OutputSchema: undefined, errorMap: baseErrors, }), - middlewares: [baseMid], }) const schema = z.object({ id: z.string().transform(v => Number.parseInt(v)) }) -const example = { id: '1' } -const out_example = { id: 1 } - -describe('self chainable', () => { - it('route', () => { - const route = { method: 'GET', path: '/test', deprecated: true, description: 'des', summary: 'sum', tags: ['hi'] } as const - const routed = builder.route(route) - - expect(routed).not.toBe(builder) - expect(routed).toBeInstanceOf(ProcedureBuilder) - expect(routed['~orpc'].contract['~orpc'].route).toEqual(route) - expect(routed['~orpc'].contract['~orpc'].InputSchema).toEqual(baseSchema) - expect(routed['~orpc'].contract['~orpc'].OutputSchema).toEqual(baseSchema) - expect(routed['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) - expect(routed['~orpc'].middlewares).toEqual([baseMid]) - }) - - it('input', () => { - const input_ed = builder.input(schema, example) - - 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(input_ed['~orpc'].contract['~orpc'].OutputSchema).toEqual(baseSchema) - expect(input_ed['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) - expect(input_ed['~orpc'].contract['~orpc'].inputExample).toEqual(example) - expect(input_ed['~orpc'].middlewares).toEqual([baseMid]) - }) - - 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) - expect(output_ed['~orpc'].contract['~orpc'].InputSchema).toEqual(baseSchema) - expect(output_ed['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) - expect(output_ed['~orpc'].contract['~orpc'].outputExample).toEqual(out_example) - expect(output_ed['~orpc'].middlewares).toEqual([baseMid]) - }) - - it('errors', () => { - const errors = { - BAD: { - status: 500, - data: schema, - }, - } - - const errors_ed = builder.errors(errors) - expect(errors_ed).not.toBe(builder) - expect(errors_ed).toBeInstanceOf(ProcedureBuilder) - expect(errors_ed['~orpc'].contract['~orpc'].errorMap).toEqual({ +describe('procedureBuilder', () => { + it('.errors', () => { + const errors = { CODE: { message: 'MESSAGE' } } + const applied = builder.errors(errors) + + expect(applied).toBeInstanceOf(ProcedureBuilder) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual({ ...baseErrors, ...errors, }) - expect(errors_ed['~orpc'].contract['~orpc'].InputSchema).toEqual(baseSchema) - expect(errors_ed['~orpc'].contract['~orpc'].OutputSchema).toEqual(baseSchema) - expect(errors_ed['~orpc'].middlewares).toEqual([baseMid]) }) -}) - -describe('to ProcedureImplementer', () => { - it('use middleware', () => { - const mid = vi.fn() - const implementer = builder.use(mid) + it('.route', () => { + const applied = builder.route({ tags: ['a'] }) - expect(implementer).toBeInstanceOf(ProcedureImplementer) - expect(implementer['~orpc'].preMiddlewares).toEqual([baseMid]) - expect(implementer['~orpc'].postMiddlewares).toEqual([mid]) - expect(implementer['~orpc'].contract['~orpc'].InputSchema).toEqual(baseSchema) - expect(implementer['~orpc'].contract['~orpc'].OutputSchema).toEqual(baseSchema) - expect(implementer['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + expect(applied).toBeInstanceOf(ProcedureBuilder) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) }) - it('use middleware with map input', () => { - const mid = vi.fn() - const map_input = vi.fn() + it('.use', () => { + const mid2 = vi.fn() - const implementer = builder.use(mid, map_input) - expect(implementer).toBeInstanceOf(ProcedureImplementer) - expect(implementer['~orpc'].preMiddlewares).toEqual([baseMid]) - expect(implementer['~orpc'].postMiddlewares).toEqual([expect.any(Function)]) - expect(implementer['~orpc'].contract['~orpc'].InputSchema).toEqual(baseSchema) - expect(implementer['~orpc'].contract['~orpc'].OutputSchema).toEqual(baseSchema) - expect(implementer['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + const applied = builder.use(mid2) - map_input.mockReturnValueOnce('__input__') - mid.mockReturnValueOnce('__mid__') + expect(applied).toBeInstanceOf(ProcedureBuilder) + expect(applied).not.toBe(builder) + expect(applied['~orpc'].middlewares).toEqual([mid, mid2]) + expect(applied['~orpc'].inputValidationIndex).toEqual(2) + expect(applied['~orpc'].outputValidationIndex).toEqual(2) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + }) - expect((implementer as any)['~orpc'].postMiddlewares[0]({}, 'input', '__output__')).toBe('__mid__') + it('.input', () => { + const applied = builder.input(schema) - expect(map_input).toBeCalledTimes(1) - expect(map_input).toBeCalledWith('input') + expect(applied).toBeInstanceOf(ProcedureBuilderWithInput) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].InputSchema).toEqual(schema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + }) - expect(mid).toBeCalledTimes(1) - expect(mid).toBeCalledWith({}, '__input__', '__output__') + it('.output', () => { + const applied = builder.output(schema) + + expect(applied).toBeInstanceOf(ProcedureBuilderWithOutput) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].OutputSchema).toEqual(schema) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) }) -}) -describe('to DecoratedProcedure', () => { - it('handler', () => { + it('.handler', () => { const handler = vi.fn() - const procedure = builder.handler(handler) - - expect(procedure).toSatisfy(isProcedure) + const applied = builder.handler(handler) - expect(procedure['~orpc'].handler).toBe(handler) - expect(procedure['~orpc'].preMiddlewares).toEqual([baseMid]) - expect(procedure['~orpc'].contract['~orpc'].InputSchema).toEqual(baseSchema) - expect(procedure['~orpc'].contract['~orpc'].OutputSchema).toEqual(baseSchema) - expect(procedure['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + expect(applied).toBeInstanceOf(DecoratedProcedure) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) + expect(applied['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) }) }) diff --git a/packages/server/src/procedure-builder.ts b/packages/server/src/procedure-builder.ts index c7eaaa87a..6885acb8f 100644 --- a/packages/server/src/procedure-builder.ts +++ b/packages/server/src/procedure-builder.ts @@ -1,156 +1,85 @@ -import type { - ContractProcedure, - ErrorMap, - ErrorMapGuard, - ErrorMapSuggestions, - RouteOptions, - Schema, - SchemaInput, - SchemaOutput, -} from '@orpc/contract' +import type { ContractProcedure, ErrorMap, ErrorMapGuard, ErrorMapSuggestions, RouteOptions, Schema, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { ContextGuard } from './context' import type { ORPCErrorConstructorMap } from './error' -import type { MapInputMiddleware, Middleware } from './middleware' -import type { - ProcedureHandler, -} from './procedure' +import type { Middleware } from './middleware' +import type { ProcedureHandler } from './procedure' import type { Context, MergeContext } from './types' -import { - DecoratedContractProcedure, -} from '@orpc/contract' +import { ContractProcedureBuilder, DecoratedContractProcedure } from '@orpc/contract' +import { ProcedureBuilderWithInput } from './procedure-builder-with-input' +import { ProcedureBuilderWithOutput } from './procedure-builder-with-output' import { DecoratedProcedure } from './procedure-decorated' -import { ProcedureImplementer } from './procedure-implementer' -export interface ProcedureBuilderDef< - TContext extends Context, - TExtraContext extends Context, - TInputSchema extends Schema, - TOutputSchema extends Schema, - TErrorMap extends ErrorMap, -> { - contract: ContractProcedure - middlewares: Middleware, Partial | undefined, unknown, any, Record>[] +export interface ProcedureBuilderDef { + contract: ContractProcedure + middlewares: Middleware, Partial | undefined, unknown, any, ORPCErrorConstructorMap>[] + inputValidationIndex: number + outputValidationIndex: number } -export class ProcedureBuilder< - TContext extends Context, - TExtraContext extends Context, - TInputSchema extends Schema, - TOutputSchema extends Schema, - TErrorMap extends ErrorMap, -> { +export class ProcedureBuilder { '~type' = 'ProcedureBuilder' as const - '~orpc': ProcedureBuilderDef + '~orpc': ProcedureBuilderDef - constructor(def: ProcedureBuilderDef) { + constructor(def: ProcedureBuilderDef) { this['~orpc'] = def } - route(route: RouteOptions): ProcedureBuilder { + errors & ErrorMapSuggestions>( + errors: U, + ): ProcedureBuilder { return new ProcedureBuilder({ ...this['~orpc'], contract: DecoratedContractProcedure .decorate(this['~orpc'].contract) - .route(route), + .errors(errors), }) } - input( - schema: U, - example?: SchemaInput, - ): ProcedureBuilder { + route(route: RouteOptions): ProcedureBuilder { return new ProcedureBuilder({ ...this['~orpc'], contract: DecoratedContractProcedure .decorate(this['~orpc'].contract) - .input(schema, example), + .route(route), }) } - output( - schema: U, - example?: SchemaOutput, - ): ProcedureBuilder { + use>>( + middleware: Middleware< + MergeContext, + U, + unknown, + unknown, + ORPCErrorConstructorMap + >, + ): ProcedureBuilder, TErrorMap> { return new ProcedureBuilder({ ...this['~orpc'], - contract: DecoratedContractProcedure - .decorate(this['~orpc'].contract) - .output(schema, example), + inputValidationIndex: this['~orpc'].inputValidationIndex + 1, + outputValidationIndex: this['~orpc'].outputValidationIndex + 1, + middlewares: [...this['~orpc'].middlewares, middleware as any], }) } - errors & ErrorMapSuggestions>( - errors: U, - ): ProcedureBuilder { - return new ProcedureBuilder({ + input(schema: U, example?: SchemaInput): ProcedureBuilderWithInput { + return new ProcedureBuilderWithInput({ ...this['~orpc'], - contract: DecoratedContractProcedure - .decorate(this['~orpc'].contract) - .errors(errors), + contract: new ContractProcedureBuilder(this['~orpc'].contract['~orpc']).input(schema, example), }) } - use> | undefined = undefined>( - middleware: Middleware< - MergeContext, - U, - SchemaOutput, - SchemaInput, - ORPCErrorConstructorMap - >, - ): ProcedureImplementer< - TContext, - MergeContext, - TInputSchema, - TOutputSchema, - TErrorMap - > - - use< - UExtra extends Context & Partial> | undefined = undefined, - UInput = unknown, - >( - middleware: Middleware< - MergeContext, - UExtra, - UInput, - SchemaInput, - ORPCErrorConstructorMap - >, - mapInput: MapInputMiddleware, UInput>, - ): ProcedureImplementer< - TContext, - MergeContext, - TInputSchema, - TOutputSchema, - TErrorMap - > - - use( - middleware: Middleware, - mapInput?: MapInputMiddleware, - ): ProcedureImplementer { - if (!mapInput) { - return new ProcedureImplementer({ - contract: this['~orpc'].contract, - preMiddlewares: this['~orpc'].middlewares, - postMiddlewares: [], - }).use(middleware) - } - - return new ProcedureImplementer({ - contract: this['~orpc'].contract, - preMiddlewares: this['~orpc'].middlewares, - postMiddlewares: [], - }).use(middleware, mapInput) + output(schema: U, example?: SchemaOutput): ProcedureBuilderWithOutput { + return new ProcedureBuilderWithOutput({ + ...this['~orpc'], + contract: new ContractProcedureBuilder(this['~orpc'].contract['~orpc']).output(schema, example), + }) } - handler>( - handler: ProcedureHandler, - ): DecoratedProcedure { + handler( + handler: ProcedureHandler, + ): DecoratedProcedure { return new DecoratedProcedure({ - preMiddlewares: this['~orpc'].middlewares, - postMiddlewares: [], - contract: this['~orpc'].contract, + ...this['~orpc'], handler, }) } diff --git a/packages/server/src/procedure-client.test.ts b/packages/server/src/procedure-client.test.ts index f00b8c01a..c02910ec8 100644 --- a/packages/server/src/procedure-client.test.ts +++ b/packages/server/src/procedure-client.test.ts @@ -7,7 +7,7 @@ import { createProcedureClient } from './procedure-client' vi.mock('@orpc/contract', async origin => ({ ...await origin(), - validateORPCError: vi.fn(), + validateORPCError: vi.fn((map, error) => error), })) vi.mock('./error', async origin => ({ @@ -38,8 +38,9 @@ const procedure = new Procedure({ errorMap: baseErrors, }), handler, - preMiddlewares: [preMid1, preMid2], - postMiddlewares: [postMid1, postMid2], + middlewares: [preMid1, preMid2, postMid1, postMid2], + inputValidationIndex: 2, + outputValidationIndex: 2, }) const procedureCases = [ @@ -110,7 +111,7 @@ describe.each(procedureCases)('createProcedureClient - case %s', async (_, proce }), { val: 123 }, expect.any(Function)) }) - it('validate input and output', () => { + it('validate input and output', async () => { const client = createProcedureClient(procedure) // @ts-expect-error - invalid input @@ -119,6 +120,9 @@ describe.each(procedureCases)('createProcedureClient - case %s', async (_, proce // @ts-expect-error - invalid output handler.mockReturnValueOnce({ val: 1234 }) expect(client({ val: '1234' })).rejects.toThrow('Output validation failed') + + postMid1.mockReturnValueOnce({ output: { val: 1234 } }) + expect(client({ val: '1234' })).rejects.toThrow('Output validation failed') }) it('middleware can return output directly', async () => { @@ -502,8 +506,9 @@ it('still work without InputSchema', async () => { errorMap: {}, }), handler, - preMiddlewares: [], - postMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) const client = createProcedureClient(procedure) @@ -522,8 +527,9 @@ it('still work without OutputSchema', async () => { errorMap: {}, }), handler, - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) const client = createProcedureClient(procedure) diff --git a/packages/server/src/procedure-client.ts b/packages/server/src/procedure-client.ts index c972cbb63..81d0b91c0 100644 --- a/packages/server/src/procedure-client.ts +++ b/packages/server/src/procedure-client.ts @@ -1,7 +1,7 @@ import type { Client, ErrorFromErrorMap, ErrorMap, Schema, SchemaInput, SchemaOutput } from '@orpc/contract' import type { Hooks, Value } from '@orpc/shared' import type { Lazyable } from './lazy' -import type { ANY_MIDDLEWARE, MiddlewareNextFn, MiddlewareOptions, MiddlewareResult } from './middleware' +import type { MiddlewareNextFn } from './middleware' import type { ANY_PROCEDURE, Procedure, ProcedureHandlerOptions } from './procedure' import type { Context, Meta } from './types' import { ORPCError, validateORPCError, ValidationError } from '@orpc/contract' @@ -136,55 +136,40 @@ async function validateOutput(procedure: ANY_PROCEDURE, output: unknown): Promis return result.value } -function executeMiddlewareChain(middlewares: ANY_MIDDLEWARE[], opt: MiddlewareOptions, input: any): MiddlewareResult { +async function executeProcedureInternal(procedure: ANY_PROCEDURE, options: ProcedureHandlerOptions): Promise { + const middlewares = procedure['~orpc'].middlewares + const inputValidationIndex = Math.min(Math.max(0, procedure['~orpc'].inputValidationIndex), middlewares.length) + const outputValidationIndex = Math.min(Math.max(0, procedure['~orpc'].outputValidationIndex), middlewares.length) let currentIndex = 0 - let currentContext = opt.context + let currentContext = options.context + let currentInput = options.input - const executeMiddlewareChain: MiddlewareNextFn = async (nextOptions) => { - const mid = middlewares[currentIndex] + const next: MiddlewareNextFn = async (nextOptions) => { + const index = currentIndex currentIndex += 1 - currentContext = mergeContext(currentContext, nextOptions.context) - if (mid) { - return await mid({ ...opt, context: currentContext, next: executeMiddlewareChain }, input, middlewareOutputFn) + if (index === inputValidationIndex) { + currentInput = await validateInput(procedure, currentInput) } - // final next must be called with full context - return opt.next({ context: currentContext }) - } + const mid = middlewares[index] - return executeMiddlewareChain({}) -} - -async function executeProcedureInternal(procedure: ANY_PROCEDURE, options: ProcedureHandlerOptions): Promise { - const executeHandler = async (context: any, input: any) => { - return await procedure['~orpc'].handler({ ...options, context, input }) - } + const result = mid + ? await mid({ ...options, context: currentContext, next }, currentInput, middlewareOutputFn) + : { output: await procedure['~orpc'].handler({ ...options, context: currentContext, input: currentInput }), context: currentContext } - const executePostMiddlewares = async (context: any, input: any) => { - const validatedInput = await validateInput(procedure, input) + if (index === outputValidationIndex) { + const validatedOutput = await validateOutput(procedure, result.output) - const result = await executeMiddlewareChain(procedure['~orpc'].postMiddlewares, { - ...options, - context, - next: async ({ context }) => { - return middlewareOutputFn( - await executeHandler(context, validatedInput), - ) as any - }, - }, validatedInput) - - const validatedOutput = await validateOutput(procedure, result.output) + return { + ...result, + output: validatedOutput, + } + } - return { ...result, output: validatedOutput } + return result } - const result = await executeMiddlewareChain(procedure['~orpc'].preMiddlewares, { - ...options, - context: options.context, - next: ({ context }) => executePostMiddlewares(context, options.input), - }, options.input) - - return result.output + return (await next({})).output } diff --git a/packages/server/src/procedure-decorated.test.ts b/packages/server/src/procedure-decorated.test.ts index d8089975d..3f20d37e1 100644 --- a/packages/server/src/procedure-decorated.test.ts +++ b/packages/server/src/procedure-decorated.test.ts @@ -33,8 +33,9 @@ const decorated = new DecoratedProcedure({ errorMap: baseErrors, }), handler, - preMiddlewares: [mid], - postMiddlewares: [mid], + middlewares: [mid], + inputValidationIndex: 1, + outputValidationIndex: 1, }) describe('decorate', () => { @@ -47,8 +48,9 @@ describe('decorate', () => { errorMap: baseErrors, }), handler, - preMiddlewares: [mid], - postMiddlewares: [mid], + middlewares: [mid], + inputValidationIndex: 0, + outputValidationIndex: 0, }) expect(DecoratedProcedure.decorate(procedure)).toBeInstanceOf(DecoratedProcedure) @@ -72,7 +74,9 @@ describe('self chainable', () => { expect(prefixed['~orpc'].contract['~orpc'].errorMap).toBe(baseErrors) expect(prefixed['~orpc'].contract['~orpc'].InputSchema).toBe(schema) expect(prefixed['~orpc'].contract['~orpc'].OutputSchema).toBe(schema) - expect(prefixed['~orpc'].postMiddlewares).toEqual([mid]) + expect(prefixed['~orpc'].inputValidationIndex).toEqual(1) + expect(prefixed['~orpc'].outputValidationIndex).toEqual(1) + expect(prefixed['~orpc'].middlewares).toEqual([mid]) expect(prefixed['~orpc'].handler).toBe(handler) }) @@ -94,7 +98,9 @@ describe('self chainable', () => { expect(routed['~orpc'].contract['~orpc'].errorMap).toBe(baseErrors) expect(routed['~orpc'].contract['~orpc'].InputSchema).toBe(schema) expect(routed['~orpc'].contract['~orpc'].OutputSchema).toBe(schema) - expect(routed['~orpc'].postMiddlewares).toEqual([mid]) + expect(routed['~orpc'].middlewares).toEqual([mid]) + expect(routed['~orpc'].inputValidationIndex).toEqual(1) + expect(routed['~orpc'].outputValidationIndex).toEqual(1) expect(routed['~orpc'].handler).toBe(handler) }) @@ -118,7 +124,9 @@ describe('self chainable', () => { expect(applied['~orpc'].contract['~orpc'].InputSchema).toBe(schema) expect(applied['~orpc'].contract['~orpc'].OutputSchema).toBe(schema) - expect(applied['~orpc'].postMiddlewares).toEqual([mid]) + expect(applied['~orpc'].middlewares).toEqual([mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) expect(applied['~orpc'].handler).toBe(handler) }) @@ -129,7 +137,9 @@ describe('self chainable', () => { expect(applied).not.toBe(decorated) expect(applied).toSatisfy(isProcedure) - expect(applied['~orpc'].postMiddlewares).toEqual([mid, extraMid]) + expect(applied['~orpc'].middlewares).toEqual([mid, extraMid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) expect(applied['~orpc'].contract['~orpc'].errorMap).toBe(baseErrors) expect(applied['~orpc'].contract['~orpc'].InputSchema).toBe(schema) @@ -144,7 +154,9 @@ describe('self chainable', () => { const applied = decorated.use(extraMid, map) expect(applied).not.toBe(decorated) expect(applied).toSatisfy(isProcedure) - expect(applied['~orpc'].postMiddlewares).toEqual([mid, expect.any(Function)]) + expect(applied['~orpc'].middlewares).toEqual([mid, expect.any(Function)]) + expect(applied['~orpc'].inputValidationIndex).toEqual(1) + expect(applied['~orpc'].outputValidationIndex).toEqual(1) expect(applied['~orpc'].contract['~orpc'].errorMap).toBe(baseErrors) expect(applied['~orpc'].contract['~orpc'].InputSchema).toBe(schema) expect(applied['~orpc'].contract['~orpc'].OutputSchema).toBe(schema) @@ -153,7 +165,7 @@ describe('self chainable', () => { extraMid.mockReturnValueOnce('__extra__') map.mockReturnValueOnce('__map__') - expect((applied as any)['~orpc'].postMiddlewares[1]({}, 'input', '__output__')).toBe('__extra__') + expect((applied as any)['~orpc'].middlewares[1]({}, 'input', '__output__')).toBe('__extra__') expect(map).toBeCalledTimes(1) expect(map).toBeCalledWith('input') @@ -171,7 +183,9 @@ describe('self chainable', () => { expect(tagged['~orpc'].contract['~orpc'].errorMap).toBe(baseErrors) expect(tagged['~orpc'].contract['~orpc'].InputSchema).toBe(schema) expect(tagged['~orpc'].contract['~orpc'].OutputSchema).toBe(schema) - expect(tagged['~orpc'].postMiddlewares).toEqual([mid]) + expect(tagged['~orpc'].middlewares).toEqual([mid]) + expect(tagged['~orpc'].inputValidationIndex).toEqual(1) + expect(tagged['~orpc'].outputValidationIndex).toEqual(1) expect(tagged['~orpc'].handler).toBe(handler) }) @@ -182,7 +196,9 @@ describe('self chainable', () => { const applied = decorated.unshiftMiddleware(mid1, mid2) expect(applied).not.toBe(decorated) expect(applied).toSatisfy(isProcedure) - expect(applied['~orpc'].preMiddlewares).toEqual([mid1, mid2, mid]) + expect(applied['~orpc'].middlewares).toEqual([mid1, mid2, mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(3) + expect(applied['~orpc'].outputValidationIndex).toEqual(3) expect(applied['~orpc'].contract['~orpc'].errorMap).toBe(baseErrors) expect(applied['~orpc'].contract['~orpc'].InputSchema).toBe(schema) @@ -198,43 +214,46 @@ describe('self chainable', () => { const mid5 = vi.fn() it('no duplicate', () => { - expect( - decorated.unshiftMiddleware(mid1, mid2)['~orpc'].preMiddlewares, - ).toEqual([mid1, mid2, mid]) + const applied = decorated.unshiftMiddleware(mid1, mid2) + + expect(applied['~orpc'].middlewares).toEqual([mid1, mid2, mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(3) + expect(applied['~orpc'].outputValidationIndex).toEqual(3) }) it('case 1', () => { - expect( - decorated.unshiftMiddleware(mid1, mid2).unshiftMiddleware(mid1, mid3)['~orpc'].preMiddlewares, - ).toEqual([mid1, mid3, mid2, mid]) + const applied = decorated.unshiftMiddleware(mid1, mid2).unshiftMiddleware(mid1, mid3) + expect(applied['~orpc'].middlewares).toEqual([mid1, mid3, mid2, mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(4) + expect(applied['~orpc'].outputValidationIndex).toEqual(4) }) it('case 2', () => { - expect( - decorated.unshiftMiddleware(mid1, mid2, mid3, mid4).unshiftMiddleware(mid1, mid4, mid2, mid3)['~orpc'].preMiddlewares, - ).toEqual([mid1, mid4, mid2, mid3, mid4, mid]) + const applied = decorated.unshiftMiddleware(mid1, mid2, mid3, mid4).unshiftMiddleware(mid1, mid4, mid2, mid3) + expect(applied['~orpc'].middlewares).toEqual([mid1, mid4, mid2, mid3, mid4, mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(6) + expect(applied['~orpc'].outputValidationIndex).toEqual(6) }) it('case 3', () => { - expect( - decorated.unshiftMiddleware(mid1, mid5, mid2, mid3, mid4).unshiftMiddleware(mid1, mid4, mid2, mid3)['~orpc'].preMiddlewares, - ).toEqual([mid1, mid4, mid2, mid3, mid5, mid2, mid3, mid4, mid]) + const applied = decorated.unshiftMiddleware(mid1, mid5, mid2, mid3, mid4).unshiftMiddleware(mid1, mid4, mid2, mid3) + expect(applied['~orpc'].middlewares).toEqual([mid1, mid4, mid2, mid3, mid5, mid2, mid3, mid4, mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(9) + expect(applied['~orpc'].outputValidationIndex).toEqual(9) }) it('case 4', () => { - expect( - decorated - .unshiftMiddleware(mid2, mid2) - .unshiftMiddleware(mid1, mid2)['~orpc'].preMiddlewares, - ).toEqual([mid1, mid2, mid2, mid]) + const applied = decorated.unshiftMiddleware(mid2, mid2).unshiftMiddleware(mid1, mid2) + expect(applied['~orpc'].middlewares).toEqual([mid1, mid2, mid2, mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(4) + expect(applied['~orpc'].outputValidationIndex).toEqual(4) }) it('case 5', () => { - expect( - decorated - .unshiftMiddleware(mid2, mid2) - .unshiftMiddleware(mid1, mid2, mid2)['~orpc'].preMiddlewares, - ).toEqual([mid1, mid2, mid2, mid]) + const applied = decorated.unshiftMiddleware(mid2, mid2).unshiftMiddleware(mid1, mid2, mid2) + expect(applied['~orpc'].middlewares).toEqual([mid1, mid2, mid2, mid]) + expect(applied['~orpc'].inputValidationIndex).toEqual(4) + expect(applied['~orpc'].outputValidationIndex).toEqual(4) }) }) diff --git a/packages/server/src/procedure-decorated.ts b/packages/server/src/procedure-decorated.ts index 2ace99fe3..5975f22ad 100644 --- a/packages/server/src/procedure-decorated.ts +++ b/packages/server/src/procedure-decorated.ts @@ -1,4 +1,5 @@ import type { ClientRest, ErrorMap, ErrorMapGuard, ErrorMapSuggestions, HTTPPath, RouteOptions, Schema, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { ContextGuard } from './context' import type { ORPCErrorConstructorMap } from './error' import type { ANY_MIDDLEWARE, MapInputMiddleware, Middleware } from './middleware' import type { CreateProcedureClientRest, ProcedureClient } from './procedure-client' @@ -58,7 +59,7 @@ export class DecoratedProcedure< }) } - use> | undefined = undefined>( + use>>( middleware: Middleware< MergeContext, U, @@ -76,7 +77,7 @@ export class DecoratedProcedure< > use< - UExtra extends Context & Partial> | undefined = undefined, + UExtra extends Context & ContextGuard>, UInput = unknown, >( middleware: Middleware< @@ -103,7 +104,7 @@ export class DecoratedProcedure< return new DecoratedProcedure({ ...this['~orpc'], - postMiddlewares: [...this['~orpc'].postMiddlewares, middleware_], + middlewares: [...this['~orpc'].middlewares, middleware_], }) } @@ -126,14 +127,14 @@ export class DecoratedProcedure< // FIXME: this is a hack to make the type checker happy, but it's not a good solution const castedMiddlewares = middlewares as ANY_MIDDLEWARE[] - if (this['~orpc'].preMiddlewares.length) { + if (this['~orpc'].middlewares.length) { let min = 0 - for (let i = 0; i < this['~orpc'].preMiddlewares.length; i++) { - const index = castedMiddlewares.indexOf(this['~orpc'].preMiddlewares[i]!, min) + for (let i = 0; i < this['~orpc'].middlewares.length; i++) { + const index = castedMiddlewares.indexOf(this['~orpc'].middlewares[i]!, min) if (index === -1) { - castedMiddlewares.push(...this['~orpc'].preMiddlewares.slice(i)) + castedMiddlewares.push(...this['~orpc'].middlewares.slice(i)) break } @@ -141,9 +142,13 @@ export class DecoratedProcedure< } } + const numNewMiddlewares = castedMiddlewares.length - this['~orpc'].middlewares.length + return new DecoratedProcedure({ ...this['~orpc'], - preMiddlewares: castedMiddlewares, + inputValidationIndex: this['~orpc'].inputValidationIndex + numNewMiddlewares, + outputValidationIndex: this['~orpc'].outputValidationIndex + numNewMiddlewares, + middlewares: castedMiddlewares, }) } diff --git a/packages/server/src/procedure-implementer.test.ts b/packages/server/src/procedure-implementer.test.ts index 894676500..abafc6802 100644 --- a/packages/server/src/procedure-implementer.test.ts +++ b/packages/server/src/procedure-implementer.test.ts @@ -19,12 +19,11 @@ const implementer = new ProcedureImplementer({ OutputSchema: baseSchema, errorMap: baseErrors, }), - preMiddlewares: [baseMid], - postMiddlewares: [baseMid], + middlewares: [baseMid], + inputValidationIndex: 1, + outputValidationIndex: 1, }) -const schema = z.object({ val: z.string().transform(v => Number.parseInt(v)) }) - describe('self chainable', () => { it('use middleware', () => { const mid1 = vi.fn() @@ -33,10 +32,12 @@ describe('self chainable', () => { expect(i).not.toBe(implementer) expect(i).toBeInstanceOf(ProcedureImplementer) - expect(i['~orpc'].postMiddlewares).toEqual([baseMid, mid1, mid2]) + expect(i['~orpc'].middlewares).toEqual([baseMid, mid1, mid2]) expect(i['~orpc'].contract['~orpc'].InputSchema).toEqual(baseSchema) expect(i['~orpc'].contract['~orpc'].OutputSchema).toEqual(baseSchema) expect(i['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + expect(i['~orpc'].inputValidationIndex).toEqual(1) + expect(i['~orpc'].outputValidationIndex).toEqual(1) }) it('use middleware with map input', () => { @@ -47,15 +48,17 @@ describe('self chainable', () => { expect(i).not.toBe(implementer) expect(i).toBeInstanceOf(ProcedureImplementer) - expect(i['~orpc'].postMiddlewares).toEqual([baseMid, expect.any(Function)]) + expect(i['~orpc'].middlewares).toEqual([baseMid, expect.any(Function)]) expect(i['~orpc'].contract['~orpc'].InputSchema).toEqual(baseSchema) expect(i['~orpc'].contract['~orpc'].OutputSchema).toEqual(baseSchema) expect(i['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + expect(i['~orpc'].inputValidationIndex).toEqual(1) + expect(i['~orpc'].outputValidationIndex).toEqual(1) map.mockReturnValueOnce('__input__') mid.mockReturnValueOnce('__mid__') - expect((i as any)['~orpc'].postMiddlewares[1]({}, 'input', '__output__')).toBe('__mid__') + expect((i as any)['~orpc'].middlewares[1]({}, 'input', '__output__')).toBe('__mid__') expect(map).toBeCalledTimes(1) expect(map).toBeCalledWith('input') @@ -72,9 +75,11 @@ describe('to DecoratedProcedure', () => { expect(procedure).toSatisfy(isProcedure) expect(procedure['~orpc'].handler).toBe(handler) - expect(procedure['~orpc'].postMiddlewares).toEqual([baseMid]) + expect(procedure['~orpc'].middlewares).toEqual([baseMid]) expect(procedure['~orpc'].contract['~orpc'].InputSchema).toEqual(baseSchema) expect(procedure['~orpc'].contract['~orpc'].OutputSchema).toEqual(baseSchema) expect(procedure['~orpc'].contract['~orpc'].errorMap).toEqual(baseErrors) + expect(procedure['~orpc'].inputValidationIndex).toEqual(1) + expect(procedure['~orpc'].outputValidationIndex).toEqual(1) }) }) diff --git a/packages/server/src/procedure-implementer.ts b/packages/server/src/procedure-implementer.ts index 8b8b53048..3a04742dd 100644 --- a/packages/server/src/procedure-implementer.ts +++ b/packages/server/src/procedure-implementer.ts @@ -1,4 +1,5 @@ import type { ContractProcedure, ErrorMap, Schema, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { ContextGuard } from './context' import type { ORPCErrorConstructorMap } from './error' import type { ANY_MAP_INPUT_MIDDLEWARE, ANY_MIDDLEWARE, MapInputMiddleware, Middleware } from './middleware' import type { ProcedureHandler } from './procedure' @@ -14,8 +15,9 @@ export type ProcedureImplementerDef< TErrorMap extends ErrorMap, > = { contract: ContractProcedure - preMiddlewares: Middleware, Partial | undefined, unknown, any, Record>[] - postMiddlewares: Middleware, Partial | undefined, SchemaOutput, SchemaInput, Record>[] + middlewares: Middleware, Partial | undefined, unknown, unknown, any>[] + inputValidationIndex: number + outputValidationIndex: number } export class ProcedureImplementer< @@ -32,7 +34,7 @@ export class ProcedureImplementer< this['~orpc'] = def } - use> | undefined = undefined>( + use>>( middleware: Middleware< MergeContext, U, @@ -49,8 +51,8 @@ export class ProcedureImplementer< > use< - UExtra extends Context & Partial> | undefined = undefined, - UInput = unknown, + UExtra extends Context & ContextGuard>, + UInput, >( middleware: Middleware< MergeContext, @@ -78,7 +80,7 @@ export class ProcedureImplementer< return new ProcedureImplementer({ ...this['~orpc'], - postMiddlewares: [...this['~orpc'].postMiddlewares, mappedMiddleware], + middlewares: [...this['~orpc'].middlewares, mappedMiddleware], }) } @@ -86,9 +88,7 @@ export class ProcedureImplementer< handler: ProcedureHandler, ): DecoratedProcedure { return new DecoratedProcedure({ - postMiddlewares: this['~orpc'].postMiddlewares, - preMiddlewares: this['~orpc'].preMiddlewares, - contract: this['~orpc'].contract, + ...this['~orpc'], handler, }) } diff --git a/packages/server/src/procedure-utils.test.ts b/packages/server/src/procedure-utils.test.ts index 2f82dcc29..27ed8e863 100644 --- a/packages/server/src/procedure-utils.test.ts +++ b/packages/server/src/procedure-utils.test.ts @@ -15,8 +15,9 @@ const procedure = new Procedure({ errorMap: {}, }), handler: () => { }, - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) describe('call', () => { diff --git a/packages/server/src/procedure.test.ts b/packages/server/src/procedure.test.ts index 193c6e03c..0a3f26148 100644 --- a/packages/server/src/procedure.test.ts +++ b/packages/server/src/procedure.test.ts @@ -9,8 +9,9 @@ describe('isProcedure', () => { errorMap: {}, }), handler: () => {}, - postMiddlewares: [], - preMiddlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, + middlewares: [], }) it('works', () => { diff --git a/packages/server/src/procedure.ts b/packages/server/src/procedure.ts index 1c3612d6c..71a2d6cd2 100644 --- a/packages/server/src/procedure.ts +++ b/packages/server/src/procedure.ts @@ -34,7 +34,7 @@ export interface ProcedureHandler< } /** - * Why is `ErrorConstructorMap` passed to `postMiddlewares` as `Record`? + * Why is `ErrorConstructorMap` passed to `middlewares` as `any`? * Why is `ErrorMap` passed to `ProcedureHandler` as `any`? * * Passing `ErrorMap/ErrorConstructorMap` directly to `Middleware/ProcedureHandler` @@ -52,8 +52,9 @@ export interface ProcedureDef< THandlerOutput extends SchemaInput, TErrorMap extends ErrorMap, > { - preMiddlewares: Middleware, Partial | undefined, unknown, any, Record>[] - postMiddlewares: Middleware, Partial | undefined, SchemaOutput, SchemaInput, Record>[] + middlewares: Middleware, Partial | undefined, unknown, unknown, any>[] + inputValidationIndex: number + outputValidationIndex: number contract: ContractProcedure handler: ProcedureHandler } diff --git a/packages/server/src/router-builder.test.ts b/packages/server/src/router-builder.test.ts index 20380c603..b23893151 100644 --- a/packages/server/src/router-builder.test.ts +++ b/packages/server/src/router-builder.test.ts @@ -113,8 +113,9 @@ describe('adapt router', () => { errorMap: {}, }), handler: vi.fn(), - preMiddlewares: [mid1, pMid1, pMid2], - postMiddlewares: [], + middlewares: [mid1, pMid1, pMid2], + inputValidationIndex: 3, + outputValidationIndex: 3, }) const pong = new Procedure({ contract: new ContractProcedure({ @@ -128,8 +129,9 @@ describe('adapt router', () => { errorMap: {}, }), handler: vi.fn(), - preMiddlewares: [], - postMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) const router = { @@ -155,7 +157,7 @@ describe('adapt router', () => { expect(adapted.ping).toSatisfy(isProcedure) expect(adapted.ping['~orpc'].handler).toBe(ping['~orpc'].handler) - expect(adapted.ping['~orpc'].preMiddlewares).toEqual([mid1, mid2, pMid1, pMid2]) + 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']) @@ -163,7 +165,7 @@ describe('adapt router', () => { expect(adapted.pong).toSatisfy(isProcedure) expect(adapted.pong['~orpc'].handler).toBe(pong['~orpc'].handler) - expect(adapted.pong['~orpc'].preMiddlewares).toEqual([mid1, mid2]) + 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']) @@ -171,7 +173,7 @@ describe('adapt router', () => { expect(adapted.nested.ping).toSatisfy(isProcedure) expect(adapted.nested.ping['~orpc'].handler).toBe(ping['~orpc'].handler) - expect(adapted.nested.ping['~orpc'].preMiddlewares).toEqual([mid1, mid2, pMid1, pMid2]) + 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']) @@ -179,7 +181,7 @@ describe('adapt router', () => { expect(adapted.nested.pong).toSatisfy(isProcedure) expect(adapted.nested.pong['~orpc'].handler).toBe(pong['~orpc'].handler) - expect(adapted.nested.pong['~orpc'].preMiddlewares).toEqual([mid1, mid2]) + 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']) @@ -198,7 +200,7 @@ describe('adapt router', () => { expect(adapted.ping).toSatisfy(isLazy) expect((await unlazy(adapted.ping) as any).default).toSatisfy(isProcedure) expect((await unlazy(adapted.ping) as any).default['~orpc'].handler).toBe(ping['~orpc'].handler) - expect((await unlazy(adapted.ping) as any).default['~orpc'].preMiddlewares).toEqual([mid1, mid2, pMid1, pMid2]) + 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']) @@ -206,7 +208,7 @@ describe('adapt router', () => { expect(adapted.pong).toSatisfy(isProcedure) expect(adapted.pong['~orpc'].handler).toBe(pong['~orpc'].handler) - expect(adapted.pong['~orpc'].preMiddlewares).toEqual([mid1, mid2]) + 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']) @@ -215,7 +217,7 @@ describe('adapt router', () => { expect(adapted.nested.ping).toSatisfy(isLazy) expect((await unlazy(adapted.nested.ping) as any).default).toSatisfy(isProcedure) expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].handler).toBe(ping['~orpc'].handler) - expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].preMiddlewares).toEqual([mid1, mid2, pMid1, pMid2]) + 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']) @@ -224,7 +226,7 @@ describe('adapt router', () => { expect(adapted.nested.pong).toSatisfy(isLazy) expect((await unlazy(adapted.nested.pong) as any).default).toSatisfy(isProcedure) expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].handler).toBe(pong['~orpc'].handler) - expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].preMiddlewares).toEqual([mid1, mid2]) + 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']) @@ -243,7 +245,7 @@ describe('adapt router', () => { expect(adapted.ping).toSatisfy(isLazy) expect((await unlazy(adapted.ping) as any).default).toSatisfy(isProcedure) expect((await unlazy(adapted.ping) as any).default['~orpc'].handler).toBe(ping['~orpc'].handler) - expect((await unlazy(adapted.ping) as any).default['~orpc'].preMiddlewares).toEqual([mid1, mid2, pMid1, pMid2]) + 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']) @@ -252,7 +254,7 @@ describe('adapt router', () => { expect(adapted.pong).toSatisfy(isLazy) expect((await unlazy(adapted.pong) as any).default).toSatisfy(isProcedure) expect((await unlazy(adapted.pong) as any).default['~orpc'].handler).toBe(pong['~orpc'].handler) - expect((await unlazy(adapted.pong) as any).default['~orpc'].preMiddlewares).toEqual([mid1, mid2]) + 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']) @@ -261,7 +263,7 @@ describe('adapt router', () => { expect(adapted.nested.ping).toSatisfy(isLazy) expect((await unlazy(adapted.nested.ping) as any).default).toSatisfy(isProcedure) expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].handler).toBe(ping['~orpc'].handler) - expect((await unlazy(adapted.nested.ping) as any).default['~orpc'].preMiddlewares).toEqual([mid1, mid2, pMid1, pMid2]) + 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']) @@ -270,7 +272,7 @@ describe('adapt router', () => { expect(adapted.nested.pong).toSatisfy(isLazy) expect((await unlazy(adapted.nested.pong) as any).default).toSatisfy(isProcedure) expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].handler).toBe(pong['~orpc'].handler) - expect((await unlazy(adapted.nested.pong) as any).default['~orpc'].preMiddlewares).toEqual([mid1, mid2]) + 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']) @@ -282,7 +284,7 @@ describe('adapt router', () => { expect(adapted).toSatisfy(isProcedure) expect(adapted['~orpc'].handler).toBe(ping['~orpc'].handler) - expect(adapted['~orpc'].preMiddlewares).toEqual([mid1, mid2, pMid1, pMid2]) + 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']) @@ -292,7 +294,7 @@ describe('adapt router', () => { expect(adaptedLazy).toSatisfy(isLazy) expect((await unlazy(adaptedLazy) as any).default).toSatisfy(isProcedure) expect((await unlazy(adaptedLazy) as any).default['~orpc'].handler).toBe(ping['~orpc'].handler) - expect((await unlazy(adaptedLazy) as any).default['~orpc'].preMiddlewares).toEqual([mid1, mid2, pMid1, pMid2]) + 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) }) diff --git a/packages/server/src/router-builder.ts b/packages/server/src/router-builder.ts index b5a39b871..43f5fa01d 100644 --- a/packages/server/src/router-builder.ts +++ b/packages/server/src/router-builder.ts @@ -1,4 +1,6 @@ import type { ContractRouter, ErrorMap, ErrorMapGuard, ErrorMapSuggestions, HTTPPath, StrictErrorMap } from '@orpc/contract' +import type { ContextGuard } from './context' +import type { ORPCErrorConstructorMap } from './error' import type { FlattenLazy, Lazy } from './lazy' import type { ANY_MIDDLEWARE, Middleware } from './middleware' import type { ANY_PROCEDURE, Procedure } from './procedure' @@ -25,7 +27,7 @@ export type AdaptedRouter< export type RouterBuilderDef = { prefix?: HTTPPath tags?: readonly string[] - middlewares: Middleware, Partial | undefined, unknown, any, Record>[] + middlewares: Middleware, Partial | undefined, unknown, any, ORPCErrorConstructorMap>[] errorMap: TErrorMap } @@ -72,7 +74,7 @@ export class RouterBuilder< }) } - use> | undefined = undefined>( + use>>( middleware: Middleware< MergeContext, U, diff --git a/packages/server/src/router-client.test.ts b/packages/server/src/router-client.test.ts index 984713e51..d254006d0 100644 --- a/packages/server/src/router-client.test.ts +++ b/packages/server/src/router-client.test.ts @@ -22,8 +22,9 @@ describe('createRouterClient', () => { errorMap: {}, }), handler: vi.fn(() => ({ val: '123' })), - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) const pong = new Procedure({ contract: new ContractProcedure({ @@ -32,8 +33,9 @@ describe('createRouterClient', () => { errorMap: {}, }), handler: vi.fn(() => ('output')), - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) const router = { diff --git a/packages/server/src/router-implementer.test-d.ts b/packages/server/src/router-implementer.test-d.ts index a9e198145..218df9e9d 100644 --- a/packages/server/src/router-implementer.test-d.ts +++ b/packages/server/src/router-implementer.test-d.ts @@ -25,15 +25,17 @@ const contract = oc.router({ const pingImpl = new Procedure({ contract: ping, handler: vi.fn(), - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) const pongImpl = new Procedure({ contract: pong, handler: vi.fn(), - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) const router = { diff --git a/packages/server/src/router-implementer.test.ts b/packages/server/src/router-implementer.test.ts index fa143a07d..a6a959a6a 100644 --- a/packages/server/src/router-implementer.test.ts +++ b/packages/server/src/router-implementer.test.ts @@ -33,15 +33,17 @@ const contract = oc.router({ const pingImpl = new Procedure({ contract: ping, handler: vi.fn(), - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) const pongImpl = new Procedure({ contract: pong, handler: vi.fn(), - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) const router = { diff --git a/packages/server/src/router-implementer.ts b/packages/server/src/router-implementer.ts index d57ff6a5a..27c287c38 100644 --- a/packages/server/src/router-implementer.ts +++ b/packages/server/src/router-implementer.ts @@ -1,4 +1,5 @@ import type { ContractRouter } from '@orpc/contract' +import type { ContextGuard } from './context' import type { FlattenLazy } from './lazy' import type { Middleware } from './middleware' import type { Router } from './router' @@ -28,7 +29,7 @@ export class RouterImplementer< this['~orpc'] = def } - use> | undefined = undefined>( + use>>( middleware: Middleware< MergeContext, U, diff --git a/packages/server/src/router.test.ts b/packages/server/src/router.test.ts index e009ad66f..439e04850 100644 --- a/packages/server/src/router.test.ts +++ b/packages/server/src/router.test.ts @@ -14,8 +14,9 @@ describe('getRouterChild', () => { errorMap: {}, }), handler: vi.fn(() => ({ val: '123' })), - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) const pong = new Procedure({ contract: new ContractProcedure({ @@ -24,8 +25,9 @@ describe('getRouterChild', () => { errorMap: {}, }), handler: vi.fn(() => ('output')), - postMiddlewares: [], - preMiddlewares: [], + middlewares: [], + inputValidationIndex: 0, + outputValidationIndex: 0, }) it('with procedure as router', () => { diff --git a/playgrounds/contract-openapi/src/orpc.ts b/playgrounds/contract-openapi/src/orpc.ts index 7a31ae6ea..85ca296e9 100644 --- a/playgrounds/contract-openapi/src/orpc.ts +++ b/playgrounds/contract-openapi/src/orpc.ts @@ -8,7 +8,9 @@ export interface ORPCContext { db?: any } -const base = os.context().use(async ({ context, path, next }, input) => { +const base = os.context() + +const logMid = base.middleware(async ({ context, path, next }, input) => { const start = Date.now() try { @@ -34,5 +36,5 @@ const authMid = base.middleware(({ context, next, path }, input) => { }) }) -export const pub = base.contract(contract) -export const authed = base.use(authMid).contract(contract) +export const pub = base.use(logMid).contract(contract) +export const authed = base.use(logMid).use(authMid).contract(contract)