Skip to content

Commit

Permalink
coercer
Browse files Browse the repository at this point in the history
  • Loading branch information
unnoq committed Dec 26, 2024
1 parent fedf33d commit 131068a
Show file tree
Hide file tree
Showing 11 changed files with 1,020 additions and 245 deletions.
1 change: 1 addition & 0 deletions packages/openapi/src/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './openapi-handler-server'
export * from './openapi-handler-serverless'
export * from './openapi-payload-codec'
export * from './openapi-procedure-matcher'
export * from './schema-coercer'
25 changes: 25 additions & 0 deletions packages/openapi/src/fetch/openapi-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,29 @@ describe.each(hono)('openAPIHandler: %s', (_, hono) => {
headers: new Headers({ [ORPC_HANDLER_HEADER]: ORPC_HANDLER_VALUE }),
}))).toBe(false)
})

it('schema coercer', async () => {
const coerce = vi.fn().mockReturnValue('__mocked__')

const handler = new OpenAPIHandler(hono, router, {
schemaCoercers: [
{
coerce,
},
],
})

const mockRequest = new Request('https://example.com/ping?value=123', {
headers: new Headers({}),
})

const response = await handler.fetch(mockRequest)

expect(response?.status).toBe(200)

expect(coerce).toBeCalledTimes(1)
expect(coerce).toBeCalledWith(undefined, { value: '123' })
expect(vi.mocked(createProcedureClient).mock.results[0]?.value).toBeCalledTimes(1)
expect(vi.mocked(createProcedureClient).mock.results[0]?.value).toBeCalledWith('__mocked__', { signal: undefined })
})
})
8 changes: 7 additions & 1 deletion packages/openapi/src/fetch/openapi-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { InputBuilderFull, type PublicInputBuilderFull } from './input-builder-f
import { InputBuilderSimple } from './input-builder-simple'
import { OpenAPIPayloadCodec, type PublicOpenAPIPayloadCodec } from './openapi-payload-codec'
import { type Hono, OpenAPIProcedureMatcher, type PublicOpenAPIProcedureMatcher } from './openapi-procedure-matcher'
import { CompositeSchemaCoercer, type SchemaCoercer } from './schema-coercer'

export type OpenAPIHandlerOptions<T extends Context> =
& Hooks<Request, Response, T, WithSignal>
Expand All @@ -14,13 +15,15 @@ export type OpenAPIHandlerOptions<T extends Context> =
payloadCodec?: PublicOpenAPIPayloadCodec
inputBuilderSimple?: PublicInputBuilderSimple
inputBuilderFull?: PublicInputBuilderFull
schemaCoercers?: SchemaCoercer[]
}

export class OpenAPIHandler<T extends Context> implements ConditionalFetchHandler<T> {
private readonly procedureMatcher: PublicOpenAPIProcedureMatcher
private readonly payloadCodec: PublicOpenAPIPayloadCodec
private readonly inputBuilderSimple: PublicInputBuilderSimple
private readonly inputBuilderFull: PublicInputBuilderFull
private readonly compositeSchemaCoercer: SchemaCoercer

constructor(
hono: Hono,
Expand All @@ -31,6 +34,7 @@ export class OpenAPIHandler<T extends Context> implements ConditionalFetchHandle
this.payloadCodec = options?.payloadCodec ?? new OpenAPIPayloadCodec()
this.inputBuilderSimple = options?.inputBuilderSimple ?? new InputBuilderSimple()
this.inputBuilderFull = options?.inputBuilderFull ?? new InputBuilderFull()
this.compositeSchemaCoercer = new CompositeSchemaCoercer(options?.schemaCoercers ?? [])
}

condition(request: Request): boolean {
Expand Down Expand Up @@ -67,13 +71,15 @@ export class OpenAPIHandler<T extends Context> implements ConditionalFetchHandle

const input = this.inputBuilderSimple.build(match.params, decodedPayload)

const coercedInput = this.compositeSchemaCoercer.coerce(match.procedure['~orpc'].contract['~orpc'].InputSchema, input)

const client = createProcedureClient({
context,
procedure: match.procedure,
path: match.path,
})

const output = await client(input, { signal: options?.signal })
const output = await client(coercedInput, { signal: options?.signal })

const { body, headers } = this.payloadCodec.encode(output)

Expand Down
169 changes: 169 additions & 0 deletions packages/openapi/src/fetch/schema-coercer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import type { Schema } from '@orpc/contract'
import { z } from 'zod'
import { CompositeSchemaCoercer, type SchemaCoercer } from './schema-coercer'

// Mock implementation of SchemaCoercer for testing
class MockSchemaCoercer implements SchemaCoercer {
constructor(private readonly transform: (value: unknown) => unknown) { }

coerce(schema: Schema, value: unknown): unknown {
return this.transform(value)
}
}

describe('compositeSchemaCoercer', () => {
describe('coerce', () => {
it('should apply coercers in sequence with number schema', () => {
const addOneCoercer = new MockSchemaCoercer(value => (typeof value === 'number' ? value + 1 : value))
const multiplyByTwoCoercer = new MockSchemaCoercer(value => (typeof value === 'number' ? value * 2 : value))

const composite = new CompositeSchemaCoercer([addOneCoercer, multiplyByTwoCoercer])
const schema = z.number()

const result = composite.coerce(schema, 5)

// First coercer adds 1 (5 -> 6), then second coercer multiplies by 2 (6 -> 12)
expect(result).toBe(12)
})

it('should handle string to number coercion', () => {
const stringToNumberCoercer = new MockSchemaCoercer(value =>
typeof value === 'string' ? Number.parseInt(value, 10) : value,
)

const composite = new CompositeSchemaCoercer([stringToNumberCoercer])
const schema = z.number()

const result = composite.coerce(schema, '123')

expect(result).toBe(123)
expect(typeof result).toBe('number')
})

it('should handle empty coercer array', () => {
const composite = new CompositeSchemaCoercer([])
const schema = z.string()
const value = 'test'

const result = composite.coerce(schema, value)

expect(result).toBe(value)
})

it('should pass schema to each coercer', () => {
const schema = z.string().regex(/^test/)
const mockCoercer = {
coerce: vi.fn().mockImplementation((_, value) => value),
}

const composite = new CompositeSchemaCoercer([mockCoercer])
composite.coerce(schema, 'test')

expect(mockCoercer.coerce).toHaveBeenCalledWith(schema, 'test')
})

it('should handle complex object schemas', () => {
const schema = z.object({
name: z.string(),
age: z.number(),
isActive: z.boolean(),
})

const objectCoercer = new MockSchemaCoercer((value: any) => {
if (typeof value !== 'object' || value === null)
return value
return {
...value,
age: typeof value.age === 'string' ? Number.parseInt(value.age, 10) : value.age,
}
})

const composite = new CompositeSchemaCoercer([objectCoercer])

const result = composite.coerce(schema, {
name: 'John',
age: '30',
isActive: true,
})

expect(result).toEqual({
name: 'John',
age: 30,
isActive: true,
})
})

it('should handle array schemas', () => {
const schema = z.array(z.number())
const arrayCoercer = new MockSchemaCoercer((value) => {
if (!Array.isArray(value))
return value
return value.map(item => typeof item === 'string' ? Number.parseInt(item, 10) : item)
})

const composite = new CompositeSchemaCoercer([arrayCoercer])

const result = composite.coerce(schema, ['1', '2', '3'])

expect(result).toEqual([1, 2, 3])
})

it('should maintain coercer order with complex transformations', () => {
const transforms: unknown[] = []
const schema = z.any()

const firstCoercer = new MockSchemaCoercer((value) => {
transforms.push(1)
return value
})

const secondCoercer = new MockSchemaCoercer((value) => {
transforms.push(2)
return value
})

const thirdCoercer = new MockSchemaCoercer((value) => {
transforms.push(3)
return value
})

const composite = new CompositeSchemaCoercer([firstCoercer, secondCoercer, thirdCoercer])

composite.coerce(schema, 'test')

expect(transforms).toEqual([1, 2, 3])
})

it('should handle optional fields in object schemas', () => {
const schema = z.object({
required: z.string(),
optional: z.number().optional(),
})

const objectCoercer = new MockSchemaCoercer((value: any) => {
if (typeof value !== 'object' || value === null)
return value
return {
...value,
optional: value.optional !== undefined
? typeof value.optional === 'string'
? Number.parseInt(value.optional, 10)
: value.optional
: undefined,
}
})

const composite = new CompositeSchemaCoercer([objectCoercer])

const result = composite.coerce(schema, {
required: 'test',
optional: '42',
})

expect(result).toEqual({
required: 'test',
optional: 42,
})
})
})
})
20 changes: 20 additions & 0 deletions packages/openapi/src/fetch/schema-coercer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Schema } from '@orpc/contract'

export interface SchemaCoercer {
coerce: (schema: Schema, value: unknown) => unknown
}

export class CompositeSchemaCoercer implements SchemaCoercer {
constructor(
private readonly coercers: SchemaCoercer[],
) {}

coerce(schema: Schema, value: unknown): unknown {
let current = value
for (const coercer of this.coercers) {
current = coercer.coerce(schema, current)
}

return current
}
}
3 changes: 3 additions & 0 deletions packages/zod/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
"build:watch": "pnpm run build --watch",
"type:check": "tsc -b"
},
"peerDependencies": {
"@orpc/openapi": "workspace:*"
},
"dependencies": {
"json-schema-typed": "^8.0.1",
"wildcard-match": "^5.1.3",
Expand Down
Loading

0 comments on commit 131068a

Please sign in to comment.