From 4555a1763e72999c125f3ec0ae2d43eb2b40c0a2 Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 13 Dec 2024 19:54:29 +0700 Subject: [PATCH] feat(contract)!: rewrite and more tests (#51) BREAKING CHANGE: `.tags` -> `.tag` --- packages/client/src/procedure.ts | 21 +- packages/contract/package.json | 5 + packages/contract/src/builder.test-d.ts | 110 +++++++++ packages/contract/src/builder.test.ts | 216 +++++------------- packages/contract/src/builder.ts | 21 +- packages/contract/src/constants.ts | 2 - packages/contract/src/index.ts | 4 +- .../src/procedure-decorated.test-d.ts | 131 +++++++++++ .../contract/src/procedure-decorated.test.ts | 113 +++++++++ packages/contract/src/procedure-decorated.ts | 68 ++++++ packages/contract/src/procedure.test-d.ts | 12 + packages/contract/src/procedure.test.ts | 107 +-------- packages/contract/src/procedure.ts | 118 ++-------- .../contract/src/router-builder.test-d.ts | 110 +++++++++ packages/contract/src/router-builder.test.ts | 98 ++++---- packages/contract/src/router-builder.ts | 66 ++++-- packages/contract/src/router.test-d.ts | 113 ++++----- packages/contract/src/router.test.ts | 24 -- packages/contract/src/router.ts | 37 +-- packages/contract/src/types.test-d.ts | 46 +++- packages/contract/src/types.ts | 1 - packages/contract/src/utils.test.ts | 18 -- packages/contract/src/utils.ts | 17 -- packages/contract/tests/e2e.test-d.ts | 20 ++ packages/contract/tests/helpers.ts | 76 ++++++ packages/next/src/action-form.ts | 2 +- packages/openapi/src/fetch/base-handler.ts | 21 +- packages/openapi/src/generator.ts | 25 +- packages/openapi/src/utils.ts | 8 +- packages/server/src/builder.test.ts | 20 +- packages/server/src/builder.ts | 21 +- packages/server/src/fetch/handle.test.ts | 6 +- packages/server/src/fetch/handler.test.ts | 20 +- packages/server/src/fetch/handler.ts | 5 +- packages/server/src/procedure-builder.test.ts | 20 +- packages/server/src/procedure-caller.ts | 4 +- .../server/src/procedure-implementer.test.ts | 12 +- packages/server/src/procedure.test.ts | 18 +- packages/server/src/router-builder.test.ts | 18 +- packages/server/src/router-builder.ts | 8 +- packages/server/src/router.test.ts | 43 +--- packages/server/src/router.ts | 35 +-- packages/shared/src/constants.ts | 2 + packages/shared/src/index.ts | 4 +- pnpm-lock.yaml | 42 ++++ 45 files changed, 1107 insertions(+), 781 deletions(-) create mode 100644 packages/contract/src/builder.test-d.ts delete mode 100644 packages/contract/src/constants.ts create mode 100644 packages/contract/src/procedure-decorated.test-d.ts create mode 100644 packages/contract/src/procedure-decorated.test.ts create mode 100644 packages/contract/src/procedure-decorated.ts create mode 100644 packages/contract/src/procedure.test-d.ts create mode 100644 packages/contract/src/router-builder.test-d.ts delete mode 100644 packages/contract/src/router.test.ts delete mode 100644 packages/contract/src/utils.test.ts delete mode 100644 packages/contract/src/utils.ts create mode 100644 packages/contract/tests/e2e.test-d.ts create mode 100644 packages/contract/tests/helpers.ts create mode 100644 packages/shared/src/constants.ts diff --git a/packages/client/src/procedure.ts b/packages/client/src/procedure.ts index d0c94ac8..010aebb6 100644 --- a/packages/client/src/procedure.ts +++ b/packages/client/src/procedure.ts @@ -3,8 +3,7 @@ import type { Caller } from '@orpc/server' import type { Promisable } from '@orpc/shared' -import { ORPC_HEADER, ORPC_HEADER_VALUE } from '@orpc/contract' -import { trim } from '@orpc/shared' +import { ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_VALUE, trim } from '@orpc/shared' import { ORPCError } from '@orpc/shared/error' import { ORPCDeserializer, ORPCSerializer } from '@orpc/transformer' @@ -43,21 +42,27 @@ export function createProcedureClient( const fetch_ = options.fetch ?? fetch const url = `${trim(options.baseURL, '/')}/${options.path.map(encodeURIComponent).join('/')}` - let headers = await options.headers?.(input) - headers = headers instanceof Headers ? headers : new Headers(headers) - const { body, headers: headers_ } = serializer.serialize(input) + const headers = new Headers({ + [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, + }) - for (const [key, value] of headers_.entries()) { + let customHeaders = await options.headers?.(input) + customHeaders = customHeaders instanceof Headers ? customHeaders : new Headers(customHeaders) + for (const [key, value] of customHeaders.entries()) { headers.append(key, value) } - headers.set(ORPC_HEADER, ORPC_HEADER_VALUE) + const serialized = serializer.serialize(input) + + for (const [key, value] of serialized.headers.entries()) { + headers.append(key, value) + } const response = await fetch_(url, { method: 'POST', headers, - body, + body: serialized.body, signal: callerOptions?.signal, }) diff --git a/packages/contract/package.json b/packages/contract/package.json index d4c3f3db..1d203c9f 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -44,5 +44,10 @@ "dependencies": { "@orpc/shared": "workspace:*", "@standard-schema/spec": "1.0.0-beta.4" + }, + "devDependencies": { + "arktype": "2.0.0-rc.26", + "valibot": "1.0.0-beta.9", + "zod": "3.24.1" } } diff --git a/packages/contract/src/builder.test-d.ts b/packages/contract/src/builder.test-d.ts new file mode 100644 index 00000000..ba7b1c5f --- /dev/null +++ b/packages/contract/src/builder.test-d.ts @@ -0,0 +1,110 @@ +import type { DecoratedContractProcedure } from './procedure-decorated' +import type { ContractRouterBuilder } from './router-builder' +import { z } from 'zod' +import { ContractBuilder } from './builder' +import { ContractProcedure } from './procedure' + +const builder = new ContractBuilder() + +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('tags', () => { + expectTypeOf(builder.tag('tag1', 'tag2')).toEqualTypeOf< + ContractRouterBuilder + >() + + // @ts-expect-error - invalid tag + builder.tag(1) + // @ts-expect-error - invalid tag + builder.tag({}) + }) +}) + +describe('to DecoratedContractProcedure', () => { + it('route', () => { + expectTypeOf(builder.route({ method: 'GET', path: '/path' })).toEqualTypeOf< + DecoratedContractProcedure + >() + + expectTypeOf(builder.route({ })).toEqualTypeOf< + DecoratedContractProcedure + >() + + // @ts-expect-error - invalid method + builder.route({ method: 'HE' }) + // @ts-expect-error - invalid path + builder.route({ method: 'GET', path: '' }) + }) + + const schema = z.object({ + value: z.string(), + }) + + it('input', () => { + expectTypeOf(builder.input(schema)).toEqualTypeOf< + DecoratedContractProcedure + >() + + expectTypeOf(builder.input(schema, { value: 'example' })).toEqualTypeOf< + DecoratedContractProcedure + >() + + // @ts-expect-error - invalid schema + builder.input({}) + + // @ts-expect-error - invalid example + builder.input(schema, { }) + }) + + it('output', () => { + expectTypeOf(builder.output(schema)).toEqualTypeOf< + DecoratedContractProcedure + >() + + expectTypeOf(builder.output(schema, { value: 'example' })).toEqualTypeOf< + DecoratedContractProcedure + >() + + // @ts-expect-error - invalid schema + builder.output({}) + + // @ts-expect-error - invalid example + builder.output(schema, {}) + }) +}) + +describe('to router', () => { + const router = { + a: { + b: { + c: new ContractProcedure({ InputSchema: undefined, OutputSchema: undefined }), + }, + }, + } + + const emptyRouter = { + + } + + const invalidRouter = { + a: 1, + } + + it('router', () => { + expectTypeOf(builder.router(router)).toEqualTypeOf() + expectTypeOf(builder.router(emptyRouter)).toEqualTypeOf() + + // @ts-expect-error - invalid router + builder.router(invalidRouter) + }) +}) diff --git a/packages/contract/src/builder.test.ts b/packages/contract/src/builder.test.ts index 5223e3e2..e6b642ca 100644 --- a/packages/contract/src/builder.test.ts +++ b/packages/contract/src/builder.test.ts @@ -1,179 +1,89 @@ import { z } from 'zod' -import { type DecoratedContractProcedure, oc } from '.' - -describe('define a procedure', () => { - it('use route method', () => { - const procedure = oc.route({ - method: 'GET', - path: '/users/{id}', - deprecated: true, - summary: 'Get user', - description: 'Get user by id', - tags: ['bbbb'], - }) +import { ContractBuilder } from './builder' +import { ContractProcedure } from './procedure' +import { DecoratedContractProcedure } from './procedure-decorated' +import { ContractRouterBuilder } from './router-builder' - expectTypeOf(procedure).toEqualTypeOf< - DecoratedContractProcedure - >() - - expect(procedure.zz$cp).toMatchObject({ - method: 'GET', - path: '/users/{id}', - deprecated: true, - summary: 'Get user', - description: 'Get user by id', - tags: ['bbbb'], - }) - }) +vi.mock('./procedure-decorated', () => ({ + DecoratedContractProcedure: vi.fn(), +})) - it('use input method', () => { - const schema = z.object({ - id: z.string(), - }) +vi.mock('./router-builder', () => ({ + ContractRouterBuilder: vi.fn(), +})) - const procedure = oc.input(schema, { id: '123' }) +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('to ContractRouterBuilder', () => { + const builder = new ContractBuilder() - expectTypeOf(procedure).toEqualTypeOf< - DecoratedContractProcedure - >() + it('prefix', () => { + expect(builder.prefix('/prefix')).toBeInstanceOf(ContractRouterBuilder) - expect(procedure.zz$cp).toMatchObject({ - InputSchema: schema, - inputExample: { id: '123' }, + expect(ContractRouterBuilder).toHaveBeenCalledWith({ + prefix: '/prefix', }) }) - it('use output method', () => { - const schema = z.object({ id: z.string() }) - - const procedure = oc.output(schema, { id: '123' }) - - expectTypeOf(procedure).toEqualTypeOf< - DecoratedContractProcedure - >() + it('tag', () => { + expect(builder.tag('tag1', 'tag2')).toBeInstanceOf(ContractRouterBuilder) - expect(procedure.zz$cp).toMatchObject({ - OutputSchema: schema, - outputExample: { id: '123' }, + expect(ContractRouterBuilder).toHaveBeenCalledWith({ + tags: ['tag1', 'tag2'], }) }) }) -describe('define a router', () => { - it('simple', () => { - const schema1 = z.string() - const schema2 = z.object({ id: z.string() }) - const ping = oc.output(schema1) - const find = oc.output(schema2) - - const router = oc.router({ - ping, - user: { - find, - }, - user2: oc.router({ - find, - }), - }) +describe('to DecoratedContractProcedure', () => { + const builder = new ContractBuilder() - expectTypeOf(router).toMatchTypeOf<{ - ping: typeof ping - user: { - find: typeof find - } - user2: { - find: typeof find - } - }>() - - expect(router).toMatchObject({ - ping, - user: { - find, - }, - user2: { - find, - }, - }) + it('route', () => { + const route = { method: 'GET', path: '/path' } as const + const procedure = builder.route(route) + + expect(procedure).toBeInstanceOf(DecoratedContractProcedure) + expect(DecoratedContractProcedure).toHaveBeenCalledWith({ route }) + }) + + const schema = z.object({ + value: z.string(), }) + const example = { value: 'example' } - it('with prefix', () => { - const schema1 = z.string() - const schema2 = z.object({ id: z.string() }) - const ping = oc.output(schema1) - const find = oc.output(schema2) + it('input', () => { + const procedure = builder.input(schema, example) - const router = oc.router({ - ping: ping.prefix('/ping'), - user: { - find, - }, - user2: oc - .prefix('/internal') - .prefix('/user2') - .router({ - find2: find.prefix('/find2'), - }), - }) + expect(procedure).toBeInstanceOf(DecoratedContractProcedure) + expect(DecoratedContractProcedure).toHaveBeenCalledWith({ InputSchema: schema, inputExample: example }) + }) - expectTypeOf(router).toMatchTypeOf<{ - ping: typeof ping - user: { - find: typeof find - } - user2: { - find2: typeof find - } - }>() - - expect(router).toMatchObject({ - ping: ping.prefix('/ping'), - user: { - find, - }, - user2: { - find2: find.prefix('/internal/user2/find2'), - }, - }) + it('output', () => { + const procedure = builder.output(schema, example) + + expect(procedure).toBeInstanceOf(DecoratedContractProcedure) + expect(DecoratedContractProcedure).toHaveBeenCalledWith({ OutputSchema: schema, outputExample: example }) }) +}) - it('with tags', () => { - const schema1 = z.string() - const schema2 = z.object({ id: z.string() }) - const ping = oc.output(schema1) - const find = oc.output(schema2) +describe('to router', () => { + const builder = new ContractBuilder() - const router = oc.router({ - ping: ping.prefix('/ping'), - user: { - find, + const router = { + a: { + b: { + c: new ContractProcedure({ InputSchema: undefined, OutputSchema: undefined }), }, - user2: oc - .tags('user') - .tags('internal') - .router({ - find2: find.prefix('/find2'), - }), - }) + }, + } - expectTypeOf(router).toMatchTypeOf<{ - ping: typeof ping - user: { - find: typeof find - } - user2: { - find2: typeof find - } - }>() - - expect(router).toMatchObject({ - ping: ping.prefix('/ping'), - user: { - find, - }, - user2: { - find2: find.addTags('user', 'internal'), - }, - }) + const emptyRouter = { + + } + + it('router', () => { + expect(builder.router(router)).toBe(router) + expect(builder.router(emptyRouter)).toBe(emptyRouter) }) }) diff --git a/packages/contract/src/builder.ts b/packages/contract/src/builder.ts index 6604ab90..52d317bb 100644 --- a/packages/contract/src/builder.ts +++ b/packages/contract/src/builder.ts @@ -1,6 +1,7 @@ +import type { RouteOptions } from './procedure' import type { ContractRouter } from './router' import type { HTTPPath, Schema, SchemaInput, SchemaOutput } from './types' -import { DecoratedContractProcedure, type RouteOptions } from './procedure' +import { DecoratedContractProcedure } from './procedure-decorated' import { ContractRouterBuilder } from './router-builder' export class ContractBuilder { @@ -10,24 +11,21 @@ export class ContractBuilder { }) } - tags(...tags: string[]): ContractRouterBuilder { + tag(...tags: string[]): ContractRouterBuilder { return new ContractRouterBuilder({ tags, }) } - route(opts: RouteOptions): DecoratedContractProcedure { + route(route: RouteOptions): DecoratedContractProcedure { return new DecoratedContractProcedure({ + route, InputSchema: undefined, OutputSchema: undefined, - ...opts, }) } - input( - schema: USchema, - example?: SchemaInput, - ): DecoratedContractProcedure { + input(schema: U, example?: SchemaInput): DecoratedContractProcedure { return new DecoratedContractProcedure({ InputSchema: schema, inputExample: example, @@ -35,14 +33,11 @@ export class ContractBuilder { }) } - output( - schema: USchema, - example?: SchemaOutput, - ): DecoratedContractProcedure { + output(schema: U, example?: SchemaOutput): DecoratedContractProcedure { return new DecoratedContractProcedure({ - InputSchema: undefined, OutputSchema: schema, outputExample: example, + InputSchema: undefined, }) } diff --git a/packages/contract/src/constants.ts b/packages/contract/src/constants.ts deleted file mode 100644 index 4a90aba5..00000000 --- a/packages/contract/src/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const ORPC_HEADER = 'x-orpc-transformer' -export const ORPC_HEADER_VALUE = 't' diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index 571abe46..d28623f4 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -3,10 +3,10 @@ import { ContractBuilder } from './builder' export * from './builder' -export * from './constants' export * from './procedure' +export * from './procedure-decorated' export * from './router' +export * from './router-builder' export * from './types' -export * from './utils' export const oc = new ContractBuilder() diff --git a/packages/contract/src/procedure-decorated.test-d.ts b/packages/contract/src/procedure-decorated.test-d.ts new file mode 100644 index 00000000..253e72c3 --- /dev/null +++ b/packages/contract/src/procedure-decorated.test-d.ts @@ -0,0 +1,131 @@ +import { z } from 'zod' +import { ContractProcedure } from './procedure' +import { DecoratedContractProcedure } from './procedure-decorated' + +const decorated = new DecoratedContractProcedure({ InputSchema: undefined, OutputSchema: undefined }) + +describe('decorate', () => { + const schema = z.object({ + value: z.string(), + }) + + it('works', () => { + const simpleProcedure = new ContractProcedure({ InputSchema: schema, OutputSchema: undefined }) + + expectTypeOf(DecoratedContractProcedure.decorate(simpleProcedure)).toEqualTypeOf< + DecoratedContractProcedure + >() + + expectTypeOf(DecoratedContractProcedure.decorate(decorated)).toEqualTypeOf< + DecoratedContractProcedure + >() + }) +}) + +describe('route', () => { + it('return ContractProcedure', () => { + const routed = decorated.route({}) + expectTypeOf(routed).toEqualTypeOf>() + }) + + it('throw error on invalid route', () => { + decorated.route({ method: 'POST' }) + // @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('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.pushTag('tag', 'tag2') + expectTypeOf(tagged).toEqualTypeOf>() + }) + + it('throw error on invalid tag', () => { + decorated.pushTag('tag') + decorated.pushTag('tag', 'tag2') + // @ts-expect-error - invalid tag + decorated.pushTag(1) + // @ts-expect-error - invalid tag + decorated.pushTag({}) + }) +}) + +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') + }) +}) diff --git a/packages/contract/src/procedure-decorated.test.ts b/packages/contract/src/procedure-decorated.test.ts new file mode 100644 index 00000000..fa48554a --- /dev/null +++ b/packages/contract/src/procedure-decorated.test.ts @@ -0,0 +1,113 @@ +import { z } from 'zod' +import { ContractProcedure } from './procedure' +import { DecoratedContractProcedure } from './procedure-decorated' + +describe('decorate', () => { + const procedure = new ContractProcedure({ InputSchema: undefined, OutputSchema: undefined }) + + it('works', () => { + const decorated = DecoratedContractProcedure.decorate(procedure) + expect(decorated).toBeInstanceOf(DecoratedContractProcedure) + expect(decorated['~orpc']).toBe(procedure['~orpc']) + }) +}) + +describe('route', () => { + const decorated = new DecoratedContractProcedure({ InputSchema: undefined, OutputSchema: undefined }) + + it('works', () => { + const route = { method: 'GET', path: '/path' } as const + const routed = decorated.route(route) + expect(routed).toBeInstanceOf(DecoratedContractProcedure) + expect(routed['~orpc']).toEqual({ route }) + }) + + it('not reference', () => { + const routed = decorated.route({}) + expect(routed['~orpc']).not.toBe(decorated['~orpc']) + expect(routed).not.toBe(decorated) + }) +}) + +describe('prefix', () => { + const decorated = new DecoratedContractProcedure({ InputSchema: undefined, OutputSchema: undefined, route: { path: '/path' } }) + + it('works', () => { + const prefixed = decorated.prefix('/prefix') + expect(prefixed).toBeInstanceOf(DecoratedContractProcedure) + expect(prefixed['~orpc']).toEqual({ route: { path: '/prefix/path' } }) + }) + + it('do nothing on non-path procedure', () => { + const decorated = new DecoratedContractProcedure({ InputSchema: undefined, OutputSchema: undefined }) + const prefixed = decorated.prefix('/prefix') + expect(prefixed).toBeInstanceOf(DecoratedContractProcedure) + expect(prefixed['~orpc']).toEqual({ }) + }) + + it('not reference', () => { + const prefixed = decorated.prefix('/prefix') + expect(prefixed['~orpc']).not.toBe(decorated['~orpc']) + expect(prefixed).not.toBe(decorated) + }) +}) + +describe('pushTag', () => { + const decorated = new DecoratedContractProcedure({ InputSchema: undefined, OutputSchema: undefined }) + + it('works', () => { + const tagged = decorated.pushTag('tag1', 'tag2') + expect(tagged).toBeInstanceOf(DecoratedContractProcedure) + expect(tagged['~orpc']).toEqual({ route: { tags: ['tag1', 'tag2'] } }) + + const tagged2 = tagged.pushTag('tag3') + expect(tagged2).toBeInstanceOf(DecoratedContractProcedure) + expect(tagged2['~orpc']).toEqual({ route: { tags: ['tag1', 'tag2', 'tag3'] } }) + }) + + it('not reference', () => { + const tagged = decorated.pushTag('tag1', 'tag2') + expect(tagged['~orpc']).not.toBe(decorated['~orpc']) + expect(tagged).not.toBe(decorated) + }) +}) + +describe('input', () => { + const decorated = new DecoratedContractProcedure({ InputSchema: undefined, OutputSchema: undefined }) + const schema = z.object({ + value: z.string(), + }) + const example = { value: 'example' } + + it('works', () => { + const inputted = decorated.input(schema, example) + expect(inputted).toBeInstanceOf(DecoratedContractProcedure) + expect(inputted['~orpc']).toEqual({ InputSchema: schema, inputExample: example }) + }) + + it('not reference', () => { + const inputted = decorated.input(schema, example) + expect(inputted['~orpc']).not.toBe(decorated['~orpc']) + expect(inputted).not.toBe(decorated) + }) +}) + +describe('output', () => { + const decorated = new DecoratedContractProcedure({ InputSchema: undefined, OutputSchema: undefined }) + const schema = z.object({ + value: z.string(), + }) + const example = { value: 'example' } + + it('works', () => { + const outputted = decorated.output(schema, example) + expect(outputted).toBeInstanceOf(DecoratedContractProcedure) + expect(outputted['~orpc']).toEqual({ OutputSchema: schema, outputExample: example }) + }) + + it('not reference', () => { + const outputted = decorated.output(schema, example) + expect(outputted['~orpc']).not.toBe(decorated['~orpc']) + expect(outputted).not.toBe(decorated) + }) +}) diff --git a/packages/contract/src/procedure-decorated.ts b/packages/contract/src/procedure-decorated.ts new file mode 100644 index 00000000..853d2372 --- /dev/null +++ b/packages/contract/src/procedure-decorated.ts @@ -0,0 +1,68 @@ +import type { RouteOptions } from './procedure' +import type { HTTPPath, Schema, SchemaInput, SchemaOutput } from './types' +import { ContractProcedure } from './procedure' + +export class DecoratedContractProcedure< + TInputSchema extends Schema, + TOutputSchema extends Schema, +> extends ContractProcedure { + static decorate< + UInputSchema extends Schema = undefined, + UOutputSchema extends Schema = undefined, + >( + procedure: ContractProcedure, + ): DecoratedContractProcedure { + if (procedure instanceof DecoratedContractProcedure) { + return procedure + } + + return new DecoratedContractProcedure(procedure['~orpc']) + } + + route(route: RouteOptions): DecoratedContractProcedure { + return new DecoratedContractProcedure({ + ...this['~orpc'], + route, + }) + } + + prefix(prefix: HTTPPath): DecoratedContractProcedure { + return new DecoratedContractProcedure({ + ...this['~orpc'], + ...(this['~orpc'].route?.path + ? { + route: { + ...this['~orpc'].route, + path: `${prefix}${this['~orpc'].route.path}`, + }, + } + : undefined), + }) + } + + pushTag(...tags: string[]): DecoratedContractProcedure { + return new DecoratedContractProcedure({ + ...this['~orpc'], + route: { + ...this['~orpc'].route, + tags: [...(this['~orpc'].route?.tags ?? []), ...tags], + }, + }) + } + + input(schema: U, example?: SchemaInput): DecoratedContractProcedure { + return new DecoratedContractProcedure({ + ...this['~orpc'], + InputSchema: schema as any, + inputExample: example as any, + }) + } + + output(schema: U, example?: SchemaOutput): DecoratedContractProcedure { + return new DecoratedContractProcedure({ + ...this['~orpc'], + OutputSchema: schema as any, + outputExample: example as any, + }) + } +} diff --git a/packages/contract/src/procedure.test-d.ts b/packages/contract/src/procedure.test-d.ts new file mode 100644 index 00000000..30dbd1c1 --- /dev/null +++ b/packages/contract/src/procedure.test-d.ts @@ -0,0 +1,12 @@ +import type { ANY_CONTRACT_PROCEDURE } from './procedure' +import { isContractProcedure } from './procedure' + +describe('isContractProcedure', () => { + it('works', () => { + const procedure = {} as unknown + + if (isContractProcedure(procedure)) { + expectTypeOf(procedure).toEqualTypeOf() + } + }) +}) diff --git a/packages/contract/src/procedure.test.ts b/packages/contract/src/procedure.test.ts index d258902f..18f820f7 100644 --- a/packages/contract/src/procedure.test.ts +++ b/packages/contract/src/procedure.test.ts @@ -1,99 +1,14 @@ -import type { DecoratedContractProcedure } from './procedure' import { z } from 'zod' -import { isContractProcedure, oc } from '.' - -it('prefix method', () => { - const os = oc - const p1 = os.route({ - method: 'GET', - path: '/ping', - }) - const p2 = os.input(z.object({})) - - expect(p1.prefix('/prefix').zz$cp.path).toEqual('/prefix/ping') - expect(p2.prefix('/prefix').zz$cp.path).toEqual(undefined) - - expect(p1.prefix('/1').prefix('/2').zz$cp.path).toEqual('/2/1/ping') - expect(p2.prefix('/1').prefix('/2').zz$cp.path).toEqual(undefined) -}) - -it('route method', () => { - const p = oc - .route({ - method: 'POST', - }) - .route({ - method: 'GET', - path: '/abc', - deprecated: true, - description: 'abc', - summary: 'abc', - tags: ['user'], - }) - - expectTypeOf(p).toEqualTypeOf< - DecoratedContractProcedure - >() - - expect(p.zz$cp).toMatchObject({ - method: 'GET', - path: '/abc', - deprecated: true, - description: 'abc', - summary: 'abc', - tags: ['user'], - }) -}) - -it('input method', () => { - const schema = z.string() - const p = oc.route({}).input(schema) - - expectTypeOf(p).toEqualTypeOf< - DecoratedContractProcedure - >() - - expect(p.zz$cp).toMatchObject({ - InputSchema: schema, - inputExample: undefined, - }) -}) - -it('output method', () => { - const schema = z.string() - const p = oc.route({}).output(schema) - - expectTypeOf(p).toEqualTypeOf< - DecoratedContractProcedure - >() - - expect(p.zz$cp).toMatchObject({ - OutputSchema: schema, - outputExample: undefined, +import { ContractProcedure, isContractProcedure } from './procedure' + +describe('isContractProcedure', () => { + it('works', () => { + expect(new ContractProcedure({ InputSchema: undefined, OutputSchema: undefined })).toSatisfy(isContractProcedure) + expect(new ContractProcedure({ InputSchema: z.object({}), OutputSchema: undefined })).toSatisfy(isContractProcedure) + expect(new ContractProcedure({ InputSchema: z.object({}), OutputSchema: undefined, route: {} })).toSatisfy(isContractProcedure) + expect({}).not.toSatisfy(isContractProcedure) + expect(true).not.toSatisfy(isContractProcedure) + expect(1).not.toSatisfy(isContractProcedure) + expect({ '~orpc': {} }).not.toSatisfy(isContractProcedure) }) }) - -it('addTags method', () => { - const schema = z.string() - const p = oc.route({}).output(schema) - - expect(p.zz$cp.tags).toBe(undefined) - - const p2 = p.addTags('foo', 'bar') - - expect(p2.zz$cp.tags).toEqual(['foo', 'bar']) - - const p3 = p2.addTags('baz') - - expect(p3.zz$cp.tags).toEqual(['foo', 'bar', 'baz']) -}) - -it('isContractProcedure function', () => { - expect(isContractProcedure(oc)).toBe(false) - expect(isContractProcedure(oc.router({}))).toBe(false) - expect(isContractProcedure(oc.route({}))).toBe(true) - - expect(isContractProcedure(oc.router({ prefix: oc.route({}) }).prefix)).toBe( - true, - ) -}) diff --git a/packages/contract/src/procedure.ts b/packages/contract/src/procedure.ts index 22211765..ff358a78 100644 --- a/packages/contract/src/procedure.ts +++ b/packages/contract/src/procedure.ts @@ -2,7 +2,6 @@ import type { HTTPMethod, HTTPPath, Schema, - SchemaInput, SchemaOutput, } from './types' @@ -15,113 +14,26 @@ export interface RouteOptions { tags?: string[] } -export class ContractProcedure< - TInputSchema extends Schema, - TOutputSchema extends Schema, -> { - constructor( - public zz$cp: { - path?: HTTPPath - method?: HTTPMethod - summary?: string - description?: string - deprecated?: boolean - tags?: string[] - InputSchema: TInputSchema - inputExample?: SchemaOutput - OutputSchema: TOutputSchema - outputExample?: SchemaOutput - }, - ) {} +export interface ContractProcedureDef { + route?: RouteOptions + InputSchema: TInputSchema + inputExample?: SchemaOutput + OutputSchema: TOutputSchema + outputExample?: SchemaOutput } -export type WELL_CONTRACT_PROCEDURE = ContractProcedure - -export class DecoratedContractProcedure< - TInputSchema extends Schema, - TOutputSchema extends Schema, -> extends ContractProcedure { - static decorate( - cp: ContractProcedure, - ): DecoratedContractProcedure { - if (cp instanceof DecoratedContractProcedure) - return cp - return new DecoratedContractProcedure(cp.zz$cp) - } - - route( - opts: RouteOptions, - ): DecoratedContractProcedure { - return new DecoratedContractProcedure({ - ...this.zz$cp, - ...opts, - method: opts.method, - path: opts.path, - }) - } - - prefix( - prefix: HTTPPath, - ): DecoratedContractProcedure { - if (!this.zz$cp.path) - return this +export class ContractProcedure { + '~type' = 'ContractProcedure' as const + '~orpc': ContractProcedureDef - return new DecoratedContractProcedure({ - ...this.zz$cp, - path: `${prefix}${this.zz$cp.path}`, - }) - } - - addTags( - ...tags: string[] - ): DecoratedContractProcedure { - if (!tags.length) - return this - - return new DecoratedContractProcedure({ - ...this.zz$cp, - tags: [...(this.zz$cp.tags ?? []), ...tags], - }) - } - - input( - schema: USchema, - example?: SchemaInput, - ): DecoratedContractProcedure { - return new DecoratedContractProcedure({ - ...this.zz$cp, - InputSchema: schema, - inputExample: example, - }) - } - - output( - schema: USchema, - example?: SchemaOutput, - ): DecoratedContractProcedure { - return new DecoratedContractProcedure({ - ...this.zz$cp, - OutputSchema: schema, - outputExample: example, - }) + constructor(def: ContractProcedureDef) { + this['~orpc'] = def } } -export type WELL_DEFINED_CONTRACT_PROCEDURE = ContractProcedure - -export function isContractProcedure( - item: unknown, -): item is WELL_DEFINED_CONTRACT_PROCEDURE { - if (item instanceof ContractProcedure) - return true +export type ANY_CONTRACT_PROCEDURE = ContractProcedure +export type WELL_CONTRACT_PROCEDURE = ContractProcedure - return ( - (typeof item === 'object' || typeof item === 'function') - && item !== null - && 'zz$cp' in item - && typeof item.zz$cp === 'object' - && item.zz$cp !== null - && 'InputSchema' in item.zz$cp - && 'OutputSchema' in item.zz$cp - ) +export function isContractProcedure(item: unknown): item is ANY_CONTRACT_PROCEDURE { + return item instanceof ContractProcedure } diff --git a/packages/contract/src/router-builder.test-d.ts b/packages/contract/src/router-builder.test-d.ts new file mode 100644 index 00000000..5cf1b681 --- /dev/null +++ b/packages/contract/src/router-builder.test-d.ts @@ -0,0 +1,110 @@ +import { z } from 'zod' +import { ContractProcedure } from './procedure' +import { DecoratedContractProcedure } from './procedure-decorated' +import { type AdaptedContractRouter, ContractRouterBuilder } from './router-builder' + +const schema = z.object({ + value: z.string().transform(() => 1), +}) + +const ping = new ContractProcedure({ InputSchema: schema, OutputSchema: undefined, route: { path: '/procedure' } }) +const pinged = DecoratedContractProcedure.decorate(ping) + +const pong = new ContractProcedure({ InputSchema: undefined, OutputSchema: schema }) +const ponged = DecoratedContractProcedure.decorate(pong) + +const router = { + ping, + pinged, + pong, + ponged, + nested: { + ping, + pinged, + pong, + ponged, + }, +} + +describe('AdaptedContractRouter', () => { + it('decorate all procedures', () => { + type Adapted = AdaptedContractRouter + + expectTypeOf().toEqualTypeOf<{ + ping: typeof pinged + pinged: typeof pinged + pong: typeof ponged + ponged: typeof ponged + nested: { + ping: typeof pinged + pinged: typeof pinged + pong: typeof ponged + ponged: typeof ponged + } + }>() + }) + + it('throw error on invalid procedure', () => { + const router = { + a: 1, + } + + // @ts-expect-error - invalid procedure + type Adapted = AdaptedContractRouter + }) +}) + +describe('router', () => { + const builder = new ContractRouterBuilder({}) + + it('return adapted router', () => { + const routed = builder.router(router) + + expectTypeOf(routed).toEqualTypeOf>() + }) + + it('throw error on invalid router', () => { + const router = { + a: 1, + } + + // @ts-expect-error - invalid router + const routed = builder.router(router) + }) +}) + +describe('prefix', () => { + const builder = new ContractRouterBuilder({}) + + it('return ContractRouterBuilder', () => { + const routed = builder.prefix('/api') + expectTypeOf(routed).toEqualTypeOf() + }) + + it('require valid prefix', () => { + builder.prefix('/api') + + // @ts-expect-error - invalid prefix + builder.prefix(1) + // @ts-expect-error - invalid prefix + builder.prefix('') + }) +}) + +describe('tag', () => { + const builder = new ContractRouterBuilder({}) + + it('return ContractRouterBuilder', () => { + const routed = builder.tag('tag') + expectTypeOf(routed).toEqualTypeOf() + }) + + it('require valid tag', () => { + builder.tag('tag') + + // @ts-expect-error - invalid tag + builder.tag(1) + // @ts-expect-error - invalid tag + builder.tag({}) + }) +}) diff --git a/packages/contract/src/router-builder.test.ts b/packages/contract/src/router-builder.test.ts index 8ace9892..097c26f3 100644 --- a/packages/contract/src/router-builder.test.ts +++ b/packages/contract/src/router-builder.test.ts @@ -1,70 +1,64 @@ import { z } from 'zod' -import { ContractProcedure, DecoratedContractProcedure, oc } from '.' +import { ContractProcedure } from './procedure' +import { DecoratedContractProcedure } from './procedure-decorated' import { ContractRouterBuilder } from './router-builder' -it('prefix method', () => { - expect(oc.prefix('/1').prefix('/2').zz$crb.prefix).toEqual('/1/2') +const schema = z.object({ + value: z.string(), }) -it('tags method', () => { - expect(oc.tags('1').tags('2').zz$crb.tags).toEqual(['1', '2']) -}) - -it('define a router', () => { - const ping = oc.route({ method: 'GET', path: '/ping' }) - const pong = oc.input(z.object({ id: z.string() })) +const procedure = new ContractProcedure({ InputSchema: schema, OutputSchema: undefined, route: { path: '/procedure' } }) +const decorated = DecoratedContractProcedure.decorate(procedure) - const router = oc.router({ - ping, - pong, +const router = { + procedure, + decorated, + nested: { + procedure, + decorated, + }, +} - internal: oc - .prefix('/internal') - .tags('internal') - .router({ - ping, - pong, +const builder = new ContractRouterBuilder({}) - nested: { - ping, - }, - }), +describe('prefix', () => { + it('works', () => { + expect(builder.prefix('/1').prefix('/2')['~orpc'].prefix).toEqual('/1/2') }) - - expect(router.ping.zz$cp.path).toEqual('/ping') - expect(router.pong.zz$cp.path).toEqual(undefined) - - expect(router.internal.ping.zz$cp.path).toEqual('/internal/ping') - expect(router.internal.pong.zz$cp.path).toEqual(undefined) - expect(router.internal.nested.ping.zz$cp.path).toEqual('/internal/ping') - - expect(router.internal.ping.zz$cp.tags).toEqual(['internal']) - expect(router.internal.pong.zz$cp.tags).toEqual(['internal']) - expect(router.internal.nested.ping.zz$cp.tags).toEqual(['internal']) }) -it('router: decorate items', () => { - const builder = new ContractRouterBuilder({}) - - const ping = new ContractProcedure({ - InputSchema: undefined, - OutputSchema: undefined, +describe('tag', () => { + it('works', () => { + expect(builder.tag('1', '2').tag('3')['~orpc'].tags).toEqual(['1', '2', '3']) }) +}) - const decorated = new DecoratedContractProcedure({ - InputSchema: undefined, - OutputSchema: undefined, - method: 'GET', - path: '/ping', +describe('router', () => { + it('adapt all procedures', () => { + const routed = builder.router(router) + + expect(routed.procedure).instanceOf(DecoratedContractProcedure) + expect(routed.decorated).instanceOf(DecoratedContractProcedure) + expect(routed.nested.procedure).instanceOf(DecoratedContractProcedure) + expect(routed.nested.decorated).instanceOf(DecoratedContractProcedure) }) - const router = builder.router({ ping, nested: { ping } }) + it('adapt with prefix and tags', () => { + const routed = builder + .prefix('/p1') + .prefix('/p2') + .tag('t1', 't2') + .tag('t3') + .router(router) - expectTypeOf(router).toEqualTypeOf<{ - ping: typeof decorated - nested: { ping: typeof decorated } - }>() + expect(routed.procedure).instanceOf(DecoratedContractProcedure) + expect(routed.decorated).instanceOf(DecoratedContractProcedure) + expect(routed.nested.procedure).instanceOf(DecoratedContractProcedure) + expect(routed.nested.decorated).instanceOf(DecoratedContractProcedure) - expect(router.ping).instanceOf(DecoratedContractProcedure) - expect(router.nested.ping).instanceOf(DecoratedContractProcedure) + expect(routed.procedure['~orpc'].route?.path).toEqual('/p1/p2/procedure') + expect(routed.decorated['~orpc'].route?.path).toEqual('/p1/p2/procedure') + expect(routed.nested.procedure['~orpc'].route?.path).toEqual('/p1/p2/procedure') + expect(routed.nested.decorated['~orpc'].route?.path).toEqual('/p1/p2/procedure') + }) }) diff --git a/packages/contract/src/router-builder.ts b/packages/contract/src/router-builder.ts index 30d7fd80..db18f64b 100644 --- a/packages/contract/src/router-builder.ts +++ b/packages/contract/src/router-builder.ts @@ -1,50 +1,68 @@ -import type { ContractRouter, HandledContractRouter } from './router' +import type { ContractProcedure } from './procedure' +import type { ContractRouter } from './router' import type { HTTPPath } from './types' -import { DecoratedContractProcedure, isContractProcedure } from './procedure' +import { isContractProcedure } from './procedure' +import { DecoratedContractProcedure } from './procedure-decorated' + +export type AdaptedContractRouter = { + [K in keyof TContract]: TContract[K] extends ContractProcedure + ? DecoratedContractProcedure + : TContract[K] extends ContractRouter + ? AdaptedContractRouter + : never +} + +export interface ContractRouterBuilderDef { + prefix?: HTTPPath + tags?: string[] +} export class ContractRouterBuilder { - constructor(public zz$crb: { prefix?: HTTPPath, tags?: string[] }) { - if (zz$crb.prefix && zz$crb.prefix.includes('{')) { - throw new Error('Prefix cannot contain "{" for dynamic routing') - } + '~type' = 'ContractProcedure' as const + '~orpc': ContractRouterBuilderDef + + constructor(def: ContractRouterBuilderDef) { + this['~orpc'] = def } prefix(prefix: HTTPPath): ContractRouterBuilder { return new ContractRouterBuilder({ - ...this.zz$crb, - prefix: `${this.zz$crb.prefix ?? ''}${prefix}`, + ...this['~orpc'], + prefix: `${this['~orpc'].prefix ?? ''}${prefix}`, }) } - tags(...tags: string[]): ContractRouterBuilder { - if (!tags.length) - return this - + tag(...tags: string[]): ContractRouterBuilder { return new ContractRouterBuilder({ - ...this.zz$crb, - tags: [...(this.zz$crb.tags ?? []), ...tags], + ...this['~orpc'], + tags: [...(this['~orpc'].tags ?? []), ...tags], }) } - router(router: T): HandledContractRouter { - const handled: ContractRouter = {} + router(router: T): AdaptedContractRouter { + const adapted: ContractRouter = {} for (const key in router) { const item = router[key] + if (isContractProcedure(item)) { - const decorated = DecoratedContractProcedure.decorate(item).addTags( - ...(this.zz$crb.tags ?? []), - ) + let decorated = DecoratedContractProcedure.decorate(item) + + if (this['~orpc'].tags) { + decorated = decorated.pushTag(...this['~orpc'].tags) + } + + if (this['~orpc'].prefix) { + decorated = decorated.prefix(this['~orpc'].prefix) + } - handled[key] = this.zz$crb.prefix - ? decorated.prefix(this.zz$crb.prefix) - : decorated + adapted[key] = decorated } else { - handled[key] = this.router(item as ContractRouter) + adapted[key] = this.router(item as ContractRouter) } } - return handled as HandledContractRouter + return adapted as any } } diff --git a/packages/contract/src/router.test-d.ts b/packages/contract/src/router.test-d.ts index 43dc9a27..74c20a0c 100644 --- a/packages/contract/src/router.test-d.ts +++ b/packages/contract/src/router.test-d.ts @@ -1,63 +1,68 @@ -import type { InferContractRouterInputs, InferContractRouterOutputs } from '.' +import type { ContractRouter, InferContractRouterInputs, InferContractRouterOutputs } from './router' import { z } from 'zod' -import { oc } from '.' +import { ContractProcedure } from './procedure' +import { DecoratedContractProcedure } from './procedure-decorated' + +const schema = z.object({ + value: z.string().transform(() => 1), +}) + + type SchemaIn = { value: string } + type SchemaOut = { value: number } + +const ping = new ContractProcedure({ InputSchema: schema, OutputSchema: undefined, route: { path: '/procedure' } }) +const pinged = DecoratedContractProcedure.decorate(ping) + +const pong = new ContractProcedure({ InputSchema: undefined, OutputSchema: schema }) +const ponged = DecoratedContractProcedure.decorate(pong) const router = { - ping: oc.route({ - method: 'GET', - path: '/ping', - }) - .input(z.object({ - ping: z.string().transform(() => 1), - })) - .output(z.object({ - pong: z.string().transform(() => 1), - })), - user: { - find: oc.route({ - method: 'GET', - path: '/users/{id}', - }) - .input(z.object({ - find: z.number().transform(() => '1'), - })) - .output(z.object({ - user: z.object({ - id: z.number().transform(() => '1'), - }), - })) - , + ping, + pinged, + pong, + ponged, + nested: { + ping, + pinged, + pong, + ponged, }, } -it('InferContractRouterInputs', () => { - type Inputs = InferContractRouterInputs - - expectTypeOf().toEqualTypeOf<{ - ping: { - ping: string - } - user: { - find: { - find: number - } - } - }>() +describe('ContractRouter', () => { + it('just an object and accepts both procedures and decorated procedures', () => { + const _: ContractRouter = router + }) }) -it('InferContractRouterOutputs', () => { - type Outputs = InferContractRouterOutputs - - expectTypeOf().toEqualTypeOf<{ - ping: { - pong: number - } - user: { - find: { - user: { - id: string - } - } - } - }>() +describe('InferContractRouterInputs', () => { + it('works', () => { + type Inputs = InferContractRouterInputs + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) +}) + +describe('InferContractRouterOutputs', () => { + it('works', () => { + type Outputs = InferContractRouterOutputs + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) }) diff --git a/packages/contract/src/router.test.ts b/packages/contract/src/router.test.ts deleted file mode 100644 index e4298c51..00000000 --- a/packages/contract/src/router.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { eachContractRouterLeaf, oc } from '.' - -it('each router leaf', () => { - const router = { - ping: oc.route({ - method: 'GET', - path: '/ping', - }), - user: { - find: oc.route({ - method: 'GET', - path: '/users/{id}', - }), - }, - } - - const calls: string[] = [] - - eachContractRouterLeaf(router, (procedure, path) => { - calls.push(path.join('.')) - }) - - expect(calls).toEqual(['ping', 'user.find']) -}) diff --git a/packages/contract/src/router.ts b/packages/contract/src/router.ts index 466bf8c7..8dbb26fb 100644 --- a/packages/contract/src/router.ts +++ b/packages/contract/src/router.ts @@ -1,41 +1,8 @@ +import type { ANY_CONTRACT_PROCEDURE, ContractProcedure } from './procedure' import type { SchemaInput, SchemaOutput } from './types' -import { - type ContractProcedure, - type DecoratedContractProcedure, - isContractProcedure, - type WELL_DEFINED_CONTRACT_PROCEDURE, -} from './procedure' export interface ContractRouter { - [k: string]: ContractProcedure | ContractRouter -} - -export type HandledContractRouter = { - [K in keyof TContract]: TContract[K] extends ContractProcedure< - infer UInputSchema, - infer UOutputSchema - > - ? DecoratedContractProcedure - : TContract[K] extends ContractRouter - ? HandledContractRouter - : never -} - -export function eachContractRouterLeaf( - router: ContractRouter, - callback: (item: WELL_DEFINED_CONTRACT_PROCEDURE, path: string[]) => void, - prefix: string[] = [], -) { - for (const key in router) { - const item = router[key] - - if (isContractProcedure(item)) { - callback(item, [...prefix, key]) - } - else { - eachContractRouterLeaf(item as ContractRouter, callback, [...prefix, key]) - } - } + [k: string]: ANY_CONTRACT_PROCEDURE | ContractRouter } export type InferContractRouterInputs = { diff --git a/packages/contract/src/types.test-d.ts b/packages/contract/src/types.test-d.ts index db0503e8..1f350726 100644 --- a/packages/contract/src/types.test-d.ts +++ b/packages/contract/src/types.test-d.ts @@ -1,16 +1,44 @@ -import type { SchemaInput, SchemaOutput } from './types' +import type { Schema, SchemaInput, SchemaOutput } from './types' +import { type } from 'arktype' +import * as v from 'valibot' import { z } from 'zod' -test('SchemaInput', () => { - const schema = z.string() +const zod = z.object({ + value: z.string().transform(() => 123), +}) + +const valibot = v.object({ + value: v.pipe(v.string(), v.transform(() => 123)), +}) - expectTypeOf>().toEqualTypeOf() - expectTypeOf>().toEqualTypeOf() +// How convert value into number? +const arktype = type({ + value: 'string', }) -test('SchemaOutput', () => { - const schema = z.string().transform(v => Number.parseFloat(v)) +describe('Schema', () => { + it('assignable', () => { + const _undefined: Schema = undefined + const _zod: Schema = zod + const _valibot: Schema = valibot + const _arktype: Schema = arktype + }) +}) + +describe('SchemaInput', () => { + it('inferable', () => { + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf<{ value: string }>() + expectTypeOf>().toEqualTypeOf<{ value: string }>() + expectTypeOf>().toEqualTypeOf<{ value: string }>() + }) +}) - expectTypeOf>().toEqualTypeOf() - expectTypeOf>().toEqualTypeOf() +describe('SchemaOutput', () => { + it('inferable', () => { + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf<{ value: number }>() + expectTypeOf>().toEqualTypeOf<{ value: number }>() + expectTypeOf>().toEqualTypeOf<{ value: string }>() + }) }) diff --git a/packages/contract/src/types.ts b/packages/contract/src/types.ts index 565655fc..6b1dfd56 100644 --- a/packages/contract/src/types.ts +++ b/packages/contract/src/types.ts @@ -2,7 +2,6 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' export type HTTPPath = `/${string}` export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' -export type HTTPStatus = number export type Schema = StandardSchemaV1 | undefined diff --git a/packages/contract/src/utils.test.ts b/packages/contract/src/utils.test.ts deleted file mode 100644 index 182bd42a..00000000 --- a/packages/contract/src/utils.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { prefixHTTPPath, standardizeHTTPPath } from './utils' - -it('standardizeHTTPPath', () => { - expect(standardizeHTTPPath('/abc')).toBe('/abc') - expect(standardizeHTTPPath('/abc/')).toBe('/abc') - expect(standardizeHTTPPath('/abc//')).toBe('/abc') - expect(standardizeHTTPPath('//abc//')).toBe('/abc') -}) - -it('prefixHTTPPath', () => { - expect(prefixHTTPPath('/', '/abc')).toBe('/abc') - expect(prefixHTTPPath('/', '/abc/')).toBe('/abc') - expect(prefixHTTPPath('/', '/abc//')).toBe('/abc') - expect(prefixHTTPPath('/', '//abc//')).toBe('/abc') - expect(prefixHTTPPath('/abc', '/abc')).toBe('/abc/abc') - expect(prefixHTTPPath('/abc', '/abc/')).toBe('/abc/abc') - expect(prefixHTTPPath('/abc', '/abc//')).toBe('/abc/abc') -}) diff --git a/packages/contract/src/utils.ts b/packages/contract/src/utils.ts deleted file mode 100644 index aa11fd63..00000000 --- a/packages/contract/src/utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HTTPPath } from './types' - -export function standardizeHTTPPath(path: HTTPPath): HTTPPath { - return `/${path.replace(/\/{2,}/g, '/').replace(/^\/|\/$/g, '')}` -} - -export function prefixHTTPPath(prefix: HTTPPath, path: HTTPPath): HTTPPath { - const prefix_ = standardizeHTTPPath(prefix) - const path_ = standardizeHTTPPath(path) - - if (prefix_ === '/') - return path_ - if (path_ === '/') - return prefix_ - - return `${prefix_}${path_}` -} diff --git a/packages/contract/tests/e2e.test-d.ts b/packages/contract/tests/e2e.test-d.ts new file mode 100644 index 00000000..41b7dd94 --- /dev/null +++ b/packages/contract/tests/e2e.test-d.ts @@ -0,0 +1,20 @@ +import type { InferContractRouterInputs, InferContractRouterOutputs } from '../src' +import type { router } from './helpers' + +describe('InferContractRouterInputs', () => { + it('works', () => { + type Inputs = InferContractRouterInputs + + expectTypeOf().toEqualTypeOf<{ limit?: number, cursor?: number }>() + expectTypeOf().toEqualTypeOf<{ name: string, description?: string }>() + }) +}) + +describe('InferContractRouterOutputs', () => { + it('works', () => { + type Outputs = InferContractRouterOutputs + + expectTypeOf().toEqualTypeOf<{ id: number, name: string, description?: string, imageUrl?: string }[]>() + expectTypeOf().toEqualTypeOf() + }) +}) diff --git a/packages/contract/tests/helpers.ts b/packages/contract/tests/helpers.ts new file mode 100644 index 00000000..ab4b8f46 --- /dev/null +++ b/packages/contract/tests/helpers.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' +import { oc } from '../src' + +export const NewPlanetSchema = z.object({ + name: z.string(), + description: z.string().optional(), +}) + +export const UpdatePlanetSchema = z.object({ + id: z.number().int().min(1), + name: z.string(), + description: z.string().optional(), +}) + +export const PlanetSchema = z.object({ + id: z.number().int().min(1), + name: z.string(), + description: z.string().optional(), + imageUrl: z.string().url().optional(), +}) + +export const listPlanets = oc + .input( + z.object({ + limit: z.number().int().min(1).max(100).optional(), + cursor: z.number().int().min(0).default(0), + }), + ) + .output(z.array(PlanetSchema)) + +export const createPlanet = oc + .input(NewPlanetSchema) + .output(PlanetSchema) + +export const findPlanet = oc + .route({ + method: 'GET', + path: '/{id}', + summary: 'Find a planet', + }) + .input( + z.object({ + id: z.number().int().min(1), + }), + ) + .output(PlanetSchema) + +export const updatePlanet = oc + .route({ + method: 'PUT', + path: '/{id}', + summary: 'Update a planet', + }) + .input(UpdatePlanetSchema) + .output(PlanetSchema) + +export const deletePlanet = oc + .route({ + method: 'DELETE', + path: '/{id}', + summary: 'Delete a planet', + deprecated: true, + }) + .input( + z.object({ + id: z.number().int().min(1), + }), + ) + +export const router = oc.tag('Planets').prefix('/planets').router({ + list: listPlanets, + create: createPlanet, + find: findPlanet, + update: updatePlanet, + delete: deletePlanet, +}) diff --git a/packages/next/src/action-form.ts b/packages/next/src/action-form.ts index 4a7005de..8f998852 100644 --- a/packages/next/src/action-form.ts +++ b/packages/next/src/action-form.ts @@ -13,7 +13,7 @@ export function createFormAction(o const procedure = await loadProcedure(opt.procedure) const deserializer = new OpenAPIDeserializer({ - schema: procedure.zz$p.contract.zz$cp.InputSchema, + schema: procedure.zz$p.contract['~orpc'].InputSchema, }) const deserializedInput = deserializer.deserializeAsFormData(input) diff --git a/packages/openapi/src/fetch/base-handler.ts b/packages/openapi/src/fetch/base-handler.ts index e78e6724..c1349509 100644 --- a/packages/openapi/src/fetch/base-handler.ts +++ b/packages/openapi/src/fetch/base-handler.ts @@ -5,11 +5,10 @@ import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE, Router } from '@orpc/server' import type { FetchHandler } from '@orpc/server/fetch' import type { Router as HonoRouter } from 'hono/router' import type { EachContractLeafResultItem, EachLeafOptions } from '../utils' -import { ORPC_HEADER, standardizeHTTPPath } from '@orpc/contract' import { createProcedureCaller, isLazy, isProcedure, LAZY_LOADER_SYMBOL, LAZY_ROUTER_PREFIX_SYMBOL, ORPCError } from '@orpc/server' -import { executeWithHooks, isPlainObject, mapValues, trim, value } from '@orpc/shared' +import { executeWithHooks, isPlainObject, mapValues, ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_VALUE, trim, value } from '@orpc/shared' import { OpenAPIDeserializer, OpenAPISerializer, zodCoerce } from '@orpc/transformer' -import { eachContractProcedureLeaf } from '../utils' +import { eachContractProcedureLeaf, standardizeHTTPPath } from '../utils' export type ResolveRouter = (router: Router, method: string, pathname: string) => Promise<{ path: string[] @@ -23,7 +22,7 @@ export function createOpenAPIHandler(createHonoRouter: () => Routing): FetchHand const resolveRouter = createResolveRouter(createHonoRouter) return async (options) => { - if (options.request.headers.get(ORPC_HEADER) !== null) { + if (options.request.headers.get(ORPC_PROTOCOL_HEADER)?.includes(ORPC_PROTOCOL_VALUE)) { return undefined } @@ -55,9 +54,9 @@ export function createOpenAPIHandler(createHonoRouter: () => Routing): FetchHand }) } - const params = procedure.zz$p.contract.zz$cp.InputSchema + const params = procedure.zz$p.contract['~orpc'].InputSchema ? zodCoerce( - procedure.zz$p.contract.zz$cp.InputSchema, + procedure.zz$p.contract['~orpc'].InputSchema, match.params, { bracketNotation: true }, ) as Record @@ -127,9 +126,9 @@ const pendingCache = new Map, { ref: EachContractLeafResultItem[] }> export function createResolveRouter(createHonoRouter: () => Routing): ResolveRouter { const addRoutes = (routing: Routing, pending: { ref: EachContractLeafResultItem[] }, options: EachLeafOptions) => { const lazies = eachContractProcedureLeaf(options, ({ path, contract }) => { - const method = contract.zz$cp.method ?? 'POST' - const httpPath = contract.zz$cp.path - ? openAPIPathToRouterPath(contract.zz$cp.path) + const method = contract['~orpc'].route?.method ?? 'POST' + const httpPath = contract['~orpc'].route?.path + ? openAPIPathToRouterPath(contract['~orpc'].route?.path) : `/${path.map(encodeURIComponent).join('/')}` routing.add(method, httpPath, path) @@ -236,7 +235,7 @@ function mergeParamsAndInput(coercedParams: Record, input: unkn async function deserializeInput(request: Request, procedure: ANY_PROCEDURE): Promise { const deserializer = new OpenAPIDeserializer({ - schema: procedure.zz$p.contract.zz$cp.InputSchema, + schema: procedure.zz$p.contract['~orpc'].InputSchema, }) try { @@ -261,6 +260,6 @@ function toORPCError(e: unknown): ORPCError { }) } -export function openAPIPathToRouterPath(path: HTTPPath): string { +function openAPIPathToRouterPath(path: HTTPPath): string { return standardizeHTTPPath(path).replace(/\{([^}]+)\}/g, ':$1') } diff --git a/packages/openapi/src/generator.ts b/packages/openapi/src/generator.ts index 123d9744..dafacaca 100644 --- a/packages/openapi/src/generator.ts +++ b/packages/openapi/src/generator.ts @@ -13,7 +13,7 @@ import { type RequestBodyObject, type ResponseObject, } from 'openapi3-ts/oas31' -import { eachContractProcedureLeaf } from './utils' +import { eachContractProcedureLeaf, standardizeHTTPPath } from './utils' import { extractJSONSchema, UNSUPPORTED_JSON_SCHEMA, @@ -70,14 +70,17 @@ export async function generateOpenAPI( return } - const internal = contract.zz$cp + const internal = contract['~orpc'] - if (ignoreUndefinedPathProcedures && internal.path === undefined) { + if (ignoreUndefinedPathProcedures && internal.route?.path === undefined) { return } - const httpPath = internal.path ?? `/${path.map(encodeURIComponent).join('/')}` - const method = internal.method ?? 'POST' + const httpPath = internal.route?.path + ? standardizeHTTPPath(internal.route?.path) + : `/${path.map(encodeURIComponent).join('/')}` + + const method = internal.route?.method ?? 'POST' let inputSchema = internal.InputSchema ? zodToJsonSchema(internal.InputSchema, { mode: 'input' }) @@ -312,8 +315,8 @@ export async function generateOpenAPI( } })() - if (throwOnMissingTagDefinition && internal.tags) { - const missingTag = internal.tags.find(tag => !rootTags.includes(tag)) + if (throwOnMissingTagDefinition && internal.route?.tags) { + const missingTag = internal.route?.tags.find(tag => !rootTags.includes(tag)) if (missingTag !== undefined) { throw new Error( @@ -323,10 +326,10 @@ export async function generateOpenAPI( } const operation: OperationObject = { - summary: internal.summary, - description: internal.description, - deprecated: internal.deprecated, - tags: internal.tags, + summary: internal.route?.summary, + description: internal.route?.description, + deprecated: internal.route?.deprecated, + tags: internal.route?.tags, operationId: path.join('.'), parameters: parameters.length ? parameters : undefined, requestBody, diff --git a/packages/openapi/src/utils.ts b/packages/openapi/src/utils.ts index 4ff362fa..a8d852e8 100644 --- a/packages/openapi/src/utils.ts +++ b/packages/openapi/src/utils.ts @@ -1,10 +1,10 @@ -import type { ContractProcedure, ContractRouter, WELL_CONTRACT_PROCEDURE } from '@orpc/contract' +import type { ANY_CONTRACT_PROCEDURE, ContractRouter, HTTPPath, WELL_CONTRACT_PROCEDURE } from '@orpc/contract' import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE, Lazy, Router } from '@orpc/server' import { isContractProcedure } from '@orpc/contract' import { isLazy, isProcedure, ROUTER_CONTRACT_SYMBOL } from '@orpc/server' export interface EachLeafOptions { - router: ANY_PROCEDURE | Router | ContractRouter | ContractProcedure + router: ANY_PROCEDURE | Router | ContractRouter | ANY_CONTRACT_PROCEDURE path: string[] } @@ -75,3 +75,7 @@ export function eachContractProcedureLeaf( return result } + +export function standardizeHTTPPath(path: HTTPPath): HTTPPath { + return `/${path.replace(/\/{2,}/g, '/').replace(/^\/|\/$/g, '')}` +} diff --git a/packages/server/src/builder.test.ts b/packages/server/src/builder.test.ts index 9679436b..d693215a 100644 --- a/packages/server/src/builder.test.ts +++ b/packages/server/src/builder.test.ts @@ -169,7 +169,7 @@ describe('define procedure builder', () => { expect(builder.zz$pb.middlewares).toBe(undefined) expect(builder.zz$pb).toMatchObject({ contract: { - zz$cp: { + '~orpc': { InputSchema: schema1, inputExample: example1, }, @@ -188,7 +188,7 @@ describe('define procedure builder', () => { expect(builder.zz$pb.middlewares).toBe(undefined) expect(builder.zz$pb).toMatchObject({ contract: { - zz$cp: { + '~orpc': { OutputSchema: schema2, outputExample: example2, }, @@ -214,13 +214,15 @@ describe('define procedure builder', () => { expect(builder.zz$pb.middlewares).toBe(undefined) expect(builder.zz$pb).toMatchObject({ contract: { - zz$cp: { - method: 'GET', - path: '/test', - deprecated: true, - description: 'des', - summary: 'sum', - tags: ['cccc'], + '~orpc': { + route: { + method: 'GET', + path: '/test', + deprecated: true, + description: 'des', + summary: 'sum', + tags: ['cccc'], + }, }, }, }) diff --git a/packages/server/src/builder.ts b/packages/server/src/builder.ts index 8ecac9ca..f8dda80b 100644 --- a/packages/server/src/builder.ts +++ b/packages/server/src/builder.ts @@ -1,3 +1,12 @@ +import type { + ANY_CONTRACT_PROCEDURE, + ContractRouter, + HTTPPath, + RouteOptions, + Schema, + SchemaInput, + SchemaOutput, +} from '@orpc/contract' import type { IsEqual } from '@orpc/shared' import type { DecoratedLazy } from './lazy' import type { DecoratedProcedure, Procedure, ProcedureFunc } from './procedure' @@ -5,13 +14,7 @@ import type { HandledRouter, Router } from './router' import type { Context, MergeContext } from './types' import { ContractProcedure, - type ContractRouter, - type HTTPPath, isContractProcedure, - type RouteOptions, - type Schema, - type SchemaInput, - type SchemaOutput, } from '@orpc/contract' import { type DecoratedMiddleware, @@ -92,12 +95,12 @@ export class Builder { */ route( - opts: RouteOptions, + route: RouteOptions, ): ProcedureBuilder { return new ProcedureBuilder({ middlewares: this.zz$b.middlewares, contract: new ContractProcedure({ - ...opts, + route, InputSchema: undefined, OutputSchema: undefined, }), @@ -166,7 +169,7 @@ export class Builder { * Convert to ProcedureImplementer | RouterBuilder */ - contract | ContractRouter>( + contract( contract: UContract, ): UContract extends ContractProcedure< infer UInputSchema, diff --git a/packages/server/src/fetch/handle.test.ts b/packages/server/src/fetch/handle.test.ts index 80f432d7..0bf292eb 100644 --- a/packages/server/src/fetch/handle.test.ts +++ b/packages/server/src/fetch/handle.test.ts @@ -1,5 +1,5 @@ -import { ORPC_HEADER, ORPC_HEADER_VALUE } from '@orpc/contract' import { createOpenAPIServerHandler, createOpenAPIServerlessHandler } from '@orpc/openapi/fetch' +import { ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_VALUE } from '@orpc/shared' import { oz } from '@orpc/zod' import { describe, expect, it } from 'vitest' import { z } from 'zod' @@ -296,7 +296,7 @@ describe('file upload', () => { method: 'POST', body: rForm, headers: { - [ORPC_HEADER]: ORPC_HEADER_VALUE, + [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, }, }), }) @@ -327,7 +327,7 @@ describe('file upload', () => { method: 'POST', body: form, headers: { - [ORPC_HEADER]: ORPC_HEADER_VALUE, + [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, }, }), }) diff --git a/packages/server/src/fetch/handler.test.ts b/packages/server/src/fetch/handler.test.ts index 39510e72..73226d8d 100644 --- a/packages/server/src/fetch/handler.test.ts +++ b/packages/server/src/fetch/handler.test.ts @@ -1,4 +1,4 @@ -import { ORPC_HEADER, ORPC_HEADER_VALUE } from '@orpc/contract' +import { ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_VALUE } from '@orpc/shared' import { z } from 'zod' import { os } from '..' import { createORPCHandler } from './handler' @@ -31,7 +31,7 @@ describe('oRPCHandler', () => { request: new Request('http://localhost/ping', { method: 'POST', headers: { - [ORPC_HEADER]: ORPC_HEADER_VALUE, + [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, 'Content-Type': 'application/json', }, body: JSON.stringify({ data: { value: '123' }, meta: [] }), @@ -48,7 +48,7 @@ describe('oRPCHandler', () => { request: new Request('http://localhost/lazyRouter/ping', { method: 'POST', headers: { - [ORPC_HEADER]: ORPC_HEADER_VALUE, + [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, 'Content-Type': 'application/json', }, body: JSON.stringify({ data: { value: '123' }, meta: [] }), @@ -65,7 +65,7 @@ describe('oRPCHandler', () => { request: new Request('http://localhost/lazyRouter/lazyRouter/ping', { method: 'POST', headers: { - [ORPC_HEADER]: ORPC_HEADER_VALUE, + [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, 'Content-Type': 'application/json', }, body: JSON.stringify({ data: { value: '123' }, meta: [] }), @@ -82,7 +82,7 @@ describe('oRPCHandler', () => { request: new Request('http://localhost/pingp', { method: 'POST', headers: { - [ORPC_HEADER]: ORPC_HEADER_VALUE, + [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, }, body: JSON.stringify({ data: { value: '123' }, meta: [] }), }), @@ -98,7 +98,7 @@ describe('oRPCHandler', () => { request: new Request('http://localhost/lazyRouter/not_found', { method: 'POST', headers: { - [ORPC_HEADER]: ORPC_HEADER_VALUE, + [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, }, body: JSON.stringify({ data: { value: '123' }, meta: [] }), }), @@ -114,7 +114,7 @@ describe('oRPCHandler', () => { request: new Request('http://localhost/ping', { method: 'POST', headers: { - [ORPC_HEADER]: ORPC_HEADER_VALUE, + [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, }, body: JSON.stringify({ data: { value: 123 }, meta: [] }), }), @@ -148,7 +148,7 @@ describe('oRPCHandler', () => { request: new Request('http://localhost/lazyRouter/ping', { method: 'POST', headers: { - [ORPC_HEADER]: ORPC_HEADER_VALUE, + [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, }, body: JSON.stringify({ data: { value: 123 }, meta: [] }), }), @@ -182,7 +182,7 @@ describe('oRPCHandler', () => { request: new Request('http://localhost/lazyRouter/lazyRouter/ping', { method: 'POST', headers: { - [ORPC_HEADER]: ORPC_HEADER_VALUE, + [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, }, body: JSON.stringify({ data: { value: 123 }, meta: [] }), }), @@ -224,7 +224,7 @@ describe('oRPCHandler', () => { request: new Request('http://localhost/ping', { method: 'POST', headers: { - [ORPC_HEADER]: ORPC_HEADER_VALUE, + [ORPC_PROTOCOL_HEADER]: ORPC_PROTOCOL_VALUE, 'Content-Type': 'application/json', }, body: JSON.stringify({ data: { value: '123' }, meta: [] }), diff --git a/packages/server/src/fetch/handler.ts b/packages/server/src/fetch/handler.ts index ce6cb160..7a564ad6 100644 --- a/packages/server/src/fetch/handler.ts +++ b/packages/server/src/fetch/handler.ts @@ -1,8 +1,7 @@ import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE } from '../procedure' import type { Router } from '../router' import type { FetchHandler } from './types' -import { ORPC_HEADER, ORPC_HEADER_VALUE } from '@orpc/contract' -import { executeWithHooks, trim, value } from '@orpc/shared' +import { executeWithHooks, ORPC_PROTOCOL_HEADER, ORPC_PROTOCOL_VALUE, trim, value } from '@orpc/shared' import { ORPCError } from '@orpc/shared/error' import { ORPCDeserializer, ORPCSerializer } from '@orpc/transformer' import { isLazy } from '../lazy' @@ -14,7 +13,7 @@ const deserializer = new ORPCDeserializer() export function createORPCHandler(): FetchHandler { return async (options) => { - if (options.request.headers.get(ORPC_HEADER) !== ORPC_HEADER_VALUE) { + if (!options.request.headers.get(ORPC_PROTOCOL_HEADER)?.includes(ORPC_PROTOCOL_VALUE)) { return undefined } diff --git a/packages/server/src/procedure-builder.test.ts b/packages/server/src/procedure-builder.test.ts index effe320e..12846a6c 100644 --- a/packages/server/src/procedure-builder.test.ts +++ b/packages/server/src/procedure-builder.test.ts @@ -33,7 +33,7 @@ it('input', () => { expect(builder2.zz$pb).toMatchObject({ contract: { - zz$cp: { + '~orpc': { InputSchema: schema1, inputExample: example1, }, @@ -50,7 +50,7 @@ it('output', () => { expect(builder2.zz$pb).toMatchObject({ contract: { - zz$cp: { + '~orpc': { OutputSchema: schema2, outputExample: example2, }, @@ -74,13 +74,15 @@ it('route', () => { expect(builder2.zz$pb).toMatchObject({ contract: { - zz$cp: { - method: 'GET', - path: '/test', - deprecated: true, - description: 'des', - summary: 'sum', - tags: ['hi'], + '~orpc': { + route: { + method: 'GET', + path: '/test', + deprecated: true, + description: 'des', + summary: 'sum', + tags: ['hi'], + }, }, }, }) diff --git a/packages/server/src/procedure-caller.ts b/packages/server/src/procedure-caller.ts index 73d0b722..140ae693 100644 --- a/packages/server/src/procedure-caller.ts +++ b/packages/server/src/procedure-caller.ts @@ -53,7 +53,7 @@ export function createProcedureCaller< const execute = async () => { const validInput = await (async () => { - const schema = procedure.zz$p.contract.zz$cp.InputSchema + const schema = procedure.zz$p.contract['~orpc'].InputSchema if (!schema) { return input } @@ -104,7 +104,7 @@ export function createProcedureCaller< const output = (await next({})).output const validOutput = await (async () => { - const schema = procedure.zz$p.contract.zz$cp.OutputSchema + const schema = procedure.zz$p.contract['~orpc'].OutputSchema if (!schema) { return output } diff --git a/packages/server/src/procedure-implementer.test.ts b/packages/server/src/procedure-implementer.test.ts index 331b43cd..6a4c985b 100644 --- a/packages/server/src/procedure-implementer.test.ts +++ b/packages/server/src/procedure-implementer.test.ts @@ -7,8 +7,10 @@ import { ProcedureImplementer } from './procedure-implementer' const p1 = new DecoratedContractProcedure({ InputSchema: undefined, OutputSchema: undefined, - method: undefined, - path: undefined, + route: { + method: undefined, + path: undefined, + }, }) const implementer1 = new ProcedureImplementer< { auth: boolean }, @@ -23,8 +25,10 @@ const schema2 = z.object({ name: z.string() }) const p2 = new DecoratedContractProcedure({ InputSchema: schema1, OutputSchema: schema2, - method: 'GET', - path: '/test', + route: { + method: 'GET', + path: '/test', + }, }) const implementer2 = new ProcedureImplementer< diff --git a/packages/server/src/procedure.test.ts b/packages/server/src/procedure.test.ts index 3a22b77d..bdbd8e49 100644 --- a/packages/server/src/procedure.test.ts +++ b/packages/server/src/procedure.test.ts @@ -63,8 +63,8 @@ describe('route method', () => { const p2 = p.route({ path: '/test', method: 'GET' }) - expect(p2.zz$p.contract.zz$cp.path).toBe('/test') - expect(p2.zz$p.contract.zz$cp.method).toBe('GET') + expect(p2.zz$p.contract['~orpc'].route?.path).toBe('/test') + expect(p2.zz$p.contract['~orpc'].route?.method).toBe('GET') }) it('preserves existing context and handler', () => { @@ -94,8 +94,8 @@ describe('route method', () => { const p2 = p.prefix('/v1') - expect(p2.zz$p.contract.zz$cp.path).toBe('/v1/api') - expect(p2.zz$p.contract.zz$cp.method).toBe('POST') + expect(p2.zz$p.contract['~orpc'].route?.path).toBe('/v1/api') + expect(p2.zz$p.contract['~orpc'].route?.method).toBe('POST') }) it('works with middleware', () => { @@ -112,7 +112,7 @@ describe('route method', () => { return 'test' }) - expect(p.zz$p.contract.zz$cp.path).toBe('/test') + expect(p.zz$p.contract['~orpc'].route?.path).toBe('/test') expect(p.zz$p.middlewares).toEqual([mid]) }) @@ -124,8 +124,8 @@ describe('route method', () => { const p2 = p.route({ path: '/test2', method: 'POST' }) - expect(p2.zz$p.contract.zz$cp.path).toBe('/test2') - expect(p2.zz$p.contract.zz$cp.method).toBe('POST') + expect(p2.zz$p.contract['~orpc'].route?.path).toBe('/test2') + expect(p2.zz$p.contract['~orpc'].route?.method).toBe('POST') }) it('preserves input/output schemas', () => { @@ -163,7 +163,7 @@ it('prefix method', () => { const p2 = p.prefix('/test') - expect(p2.zz$p.contract.zz$cp.path).toBe(undefined) + expect(p2.zz$p.contract['~orpc'].route?.path).toBe(undefined) const p3 = os .context<{ auth: boolean }>() @@ -173,7 +173,7 @@ it('prefix method', () => { }) const p4 = p3.prefix('/test') - expect(p4.zz$p.contract.zz$cp.path).toBe('/test/test1') + expect(p4.zz$p.contract['~orpc'].route?.path).toBe('/test/test1') }) describe('use middleware', () => { diff --git a/packages/server/src/router-builder.test.ts b/packages/server/src/router-builder.test.ts index 1a857ab6..13c89417 100644 --- a/packages/server/src/router-builder.test.ts +++ b/packages/server/src/router-builder.test.ts @@ -44,10 +44,10 @@ describe('prefix', () => { lazy: os.route({ method: 'GET', path: '/lazy' }).func(() => 'lazy'), } })) }) - expect(router.ping.zz$p.contract.zz$cp.path).toEqual('/api/users/ping') - expect(router.pong.zz$p.contract.zz$cp.path).toEqual(undefined) - expect((await router.lazy.lazy[LAZY_LOADER_SYMBOL]()).default.zz$p.contract.zz$cp.path).toEqual('/api/users/lazy') - expect((await (router.lazy as any)[LAZY_LOADER_SYMBOL]()).default.lazy.zz$p.contract.zz$cp.path).toEqual('/api/users/lazy') + expect(router.ping.zz$p.contract['~orpc'].route?.path).toEqual('/api/users/ping') + expect(router.pong.zz$p.contract['~orpc'].route?.path).toEqual(undefined) + expect((await router.lazy.lazy[LAZY_LOADER_SYMBOL]()).default.zz$p.contract['~orpc'].route?.path).toEqual('/api/users/lazy') + expect((await (router.lazy as any)[LAZY_LOADER_SYMBOL]()).default.lazy.zz$p.contract['~orpc'].route?.path).toEqual('/api/users/lazy') expect((router.lazy as any)[LAZY_ROUTER_PREFIX_SYMBOL]).toEqual('/api/users') expect((router.lazy.lazy as any)[LAZY_ROUTER_PREFIX_SYMBOL]).toEqual('/api/users') }) @@ -69,24 +69,24 @@ describe('tags', () => { .tags('users') .router({ ping, pong, lazy, lazyRouter }) - expect(router.ping.zz$p.contract.zz$cp.tags).toEqual([ + expect(router.ping.zz$p.contract['~orpc'].route?.tags).toEqual([ 'ping', 'api', 'users', ]) - expect(router.pong.zz$p.contract.zz$cp.tags).toEqual(['api', 'users']) + expect(router.pong.zz$p.contract['~orpc'].route?.tags).toEqual(['api', 'users']) - expect((await (router.lazy[LAZY_LOADER_SYMBOL]())).default.zz$p.contract.zz$cp.tags).toEqual([ + expect((await (router.lazy[LAZY_LOADER_SYMBOL]())).default.zz$p.contract['~orpc'].route?.tags).toEqual([ 'lazy', 'api', 'users', ]) - expect((await (router.lazyRouter.lazy[LAZY_LOADER_SYMBOL]())).default.zz$p.contract.zz$cp.tags).toEqual([ + expect((await (router.lazyRouter.lazy[LAZY_LOADER_SYMBOL]())).default.zz$p.contract['~orpc'].route?.tags).toEqual([ 'lazy', 'api', 'users', ]) - expect((await (router.lazyRouter.lazyRouter.lazy[LAZY_LOADER_SYMBOL]())).default.zz$p.contract.zz$cp.tags).toEqual([ + expect((await (router.lazyRouter.lazyRouter.lazy[LAZY_LOADER_SYMBOL]())).default.zz$p.contract['~orpc'].route?.tags).toEqual([ 'lazy', 'api', 'users', diff --git a/packages/server/src/router-builder.ts b/packages/server/src/router-builder.ts index ea2bd208..d5f9f25c 100644 --- a/packages/server/src/router-builder.ts +++ b/packages/server/src/router-builder.ts @@ -2,7 +2,7 @@ import type { DecoratedLazy, Lazy } from './lazy' import type { ANY_LAZY_PROCEDURE, ANY_PROCEDURE, DecoratedProcedure } from './procedure' import type { HandledRouter, Router } from './router' import type { Context, MergeContext } from './types' -import { DecoratedContractProcedure, type HTTPPath, prefixHTTPPath } from '@orpc/contract' +import { DecoratedContractProcedure, type HTTPPath } from '@orpc/contract' import { createLazy, decorateLazy, isLazy, loadLazy } from './lazy' import { decorateMiddleware, @@ -167,9 +167,7 @@ function adaptLazyRouter(options: { let lazyRouterPrefix = options.prefix if (LAZY_ROUTER_PREFIX_SYMBOL in options.current && typeof options.current[LAZY_ROUTER_PREFIX_SYMBOL] === 'string') { - lazyRouterPrefix = lazyRouterPrefix - ? prefixHTTPPath(options.current[LAZY_ROUTER_PREFIX_SYMBOL] as HTTPPath, lazyRouterPrefix) - : options.current[LAZY_ROUTER_PREFIX_SYMBOL] as HTTPPath + lazyRouterPrefix = `${options.current[LAZY_ROUTER_PREFIX_SYMBOL]}${lazyRouterPrefix ?? ''}` as HTTPPath } const decoratedLazy = Object.assign(decorateLazy(createLazy(loader)), { @@ -213,7 +211,7 @@ function adaptProcedure(options: { let contract = DecoratedContractProcedure.decorate( options.procedure.zz$p.contract, - ).addTags(...(options.tags ?? [])) + ).pushTag(...(options.tags ?? [])) if (options.prefix) { contract = contract.prefix(options.prefix) diff --git a/packages/server/src/router.test.ts b/packages/server/src/router.test.ts index fa14e51b..aa76e57d 100644 --- a/packages/server/src/router.test.ts +++ b/packages/server/src/router.test.ts @@ -1,6 +1,6 @@ import { oc } from '@orpc/contract' import { z } from 'zod' -import { os, type RouterWithContract, toContractRouter } from '.' +import { os, type RouterWithContract } from '.' it('require procedure match context', () => { const osw = os.context<{ auth: boolean, userId: string }>() @@ -99,44 +99,3 @@ it('require match contract', () => { }, } }) - -it('toContractRouter', () => { - const p1 = oc.input(z.string()).output(z.string()) - const p2 = oc.output(z.string()) - const p3 = oc.route({ method: 'GET', path: '/test' }) - - const contract = oc.router({ - p1, - - nested: oc.router({ - p2, - }), - - nested2: { - p3, - }, - }) - - const osw = os.contract(contract) - - const router = osw.router({ - p1: osw.p1.func(() => { - return 'unnoq' - }), - - nested: osw.nested.router({ - p2: osw.nested.p2.func(() => { - return 'unnoq' - }), - }), - - nested2: { - p3: osw.nested2.p3.func(() => { - return 'unnoq' - }), - }, - }) - - expect(toContractRouter(router)).toEqual(contract) - expect(toContractRouter(contract)).toEqual(contract) -}) diff --git a/packages/server/src/router.ts b/packages/server/src/router.ts index cfadc8c8..244571da 100644 --- a/packages/server/src/router.ts +++ b/packages/server/src/router.ts @@ -5,16 +5,13 @@ import type { SchemaOutput, } from '@orpc/contract' import type { ANY_LAZY, DecoratedLazy, Lazy } from './lazy' -import type { Context } from './types' -import { - isContractProcedure, -} from '@orpc/contract' -import { - type DecoratedProcedure, - isProcedure, - type Procedure, +import type { + DecoratedProcedure, + Procedure, } from './procedure' +import type { Context } from './types' + export interface Router { [k: string]: | Procedure @@ -59,28 +56,6 @@ export type RouterWithContract< : never } -export function toContractRouter( - router: ContractRouter | Router, -): ContractRouter { - const contract: ContractRouter = {} - - for (const key in router) { - const item = router[key] - - if (isContractProcedure(item)) { - contract[key] = item - } - else if (isProcedure(item)) { - contract[key] = item.zz$p.contract - } - else { - contract[key] = toContractRouter(item as any) - } - } - - return contract -} - export type InferRouterInputs> = { [K in keyof T]: T[K] extends | Procedure diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts new file mode 100644 index 00000000..d4682e73 --- /dev/null +++ b/packages/shared/src/constants.ts @@ -0,0 +1,2 @@ +export const ORPC_PROTOCOL_HEADER = 'x-orpc-protocol' +export const ORPC_PROTOCOL_VALUE = 'orpc' diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e316a271..cebe4c6d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,9 +1,11 @@ +export * from './constants' export * from './function' export * from './hook' export * from './json' export * from './object' export * from './value' -export { isPlainObject } from 'is-what' +export { isPlainObject } from 'is-what' export { guard, mapEntries, mapValues, omit, trim } from 'radash' + export type * from 'type-fest' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9360d56c..a82e7c32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,16 @@ importers: '@standard-schema/spec': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4 + devDependencies: + arktype: + specifier: 2.0.0-rc.26 + version: 2.0.0-rc.26 + valibot: + specifier: 1.0.0-beta.9 + version: 1.0.0-beta.9(typescript@5.7.2) + zod: + specifier: 3.24.1 + version: 3.24.1 packages/next: dependencies: @@ -469,6 +479,12 @@ packages: '@apidevtools/swagger-methods@3.0.2': resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + '@ark/schema@0.26.0': + resolution: {integrity: sha512-b6hk1+M0U4jgZK7ZOGsWKSXgjhfPAqqSCczViM/gQ0Hu0awKLx9SpZYsFhl0j67j3hwlY2+mVZQPKy6GlYDCbQ==} + + '@ark/util@0.26.0': + resolution: {integrity: sha512-6FSqj6xl3jQ9bD9EU25ThMVcsvaeq6c3gecONgPQ+wDYOUEqBBIAkpjA+LEZMiY0AxVhSF3UF6BlVFspXmef2Q==} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -2290,6 +2306,9 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + arktype@2.0.0-rc.26: + resolution: {integrity: sha512-OdV40SQNY0CFJH+anT0N7Go9Tl+av+hxzMGPccv47sPHdekZuEPd61MfNmwn1J5H2SIrycdwGPD8jYBZSkhKjQ==} + array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} @@ -5058,6 +5077,14 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + valibot@1.0.0-beta.9: + resolution: {integrity: sha512-yEX8gMAZ2R1yI2uwOO4NCtVnJQx36zn3vD0omzzj9FhcoblvPukENIiRZXKZwCnqSeV80bMm8wNiGhQ0S8fiww==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -5352,6 +5379,12 @@ snapshots: '@apidevtools/swagger-methods@3.0.2': {} + '@ark/schema@0.26.0': + dependencies: + '@ark/util': 0.26.0 + + '@ark/util@0.26.0': {} + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -7115,6 +7148,11 @@ snapshots: dependencies: dequal: 2.0.3 + arktype@2.0.0-rc.26: + dependencies: + '@ark/schema': 0.26.0 + '@ark/util': 0.26.0 + array-flatten@1.1.1: {} assertion-error@2.0.1: {} @@ -10510,6 +10548,10 @@ snapshots: utils-merge@1.0.1: {} + valibot@1.0.0-beta.9(typescript@5.7.2): + optionalDependencies: + typescript: 5.7.2 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0