Skip to content

Commit dd26f26

Browse files
authored
Merge pull request #21 from softnetics/miello/feat/request-response-validator
[DRIZZ-78] Request and response validation layer
2 parents 587c9d7 + e217465 commit dd26f26

5 files changed

Lines changed: 184 additions & 23 deletions

File tree

.changeset/blue-seals-care.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@kivotos/core": minor
3+
---
4+
5+
[[DRIZZ-78] Request validation layer](https://app.plane.so/softnetics/browse/DRIZZ-78/)

packages/core/src/collection.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import type { ConditionalExcept, Simplify, UnionToIntersection, ValueOf } from '
33
import z from 'zod'
44

55
import type { MinimalContext } from './config'
6-
import type {
7-
ApiRoute,
8-
ApiRouteHandler,
9-
ApiRouter,
10-
ApiRouteSchema,
11-
ClientApiRouter,
6+
import {
7+
type ApiRoute,
8+
type ApiRouteHandler,
9+
type ApiRouter,
10+
type ApiRouteSchema,
11+
type ClientApiRouter,
12+
createEndpoint,
1213
} from './endpoint'
1314
import {
1415
type Field,
@@ -23,7 +24,7 @@ import {
2324
fieldsToZodObject,
2425
type FieldsWithFieldName,
2526
} from './field'
26-
import type { GetTableByTableTsName, ToZodObject } from './utils'
27+
import { type GetTableByTableTsName, type ToZodObject } from './utils'
2728

2829
type SimplifyConditionalExcept<Base, Condition> = Simplify<ConditionalExcept<Base, Condition>>
2930

@@ -724,7 +725,10 @@ export function getAllCollectionEndpoints<
724725
return { status: 200, body: response }
725726
}
726727

727-
return [endpointName, { schema, handler } satisfies ApiRoute<any, typeof schema>]
728+
return [
729+
endpointName,
730+
createEndpoint(schema, handler) satisfies ApiRoute<any, typeof schema>,
731+
]
728732
}
729733
case ApiDefaultMethod.FIND_ONE: {
730734
const response = fieldsToZodObject(fields)
@@ -752,10 +756,18 @@ export function getAllCollectionEndpoints<
752756
return { status: 200, body: response }
753757
}
754758

755-
return [endpointName, { schema, handler } satisfies ApiRoute<any, typeof schema>]
759+
return [
760+
endpointName,
761+
createEndpoint(schema, handler) satisfies ApiRoute<any, typeof schema>,
762+
]
756763
}
757764
case ApiDefaultMethod.FIND_MANY: {
758-
const response = fieldsToZodObject(fields)
765+
const body = fieldsToZodObject(fields)
766+
const response = z.object({
767+
data: z.array(body),
768+
total: z.number(),
769+
page: z.number(),
770+
})
759771

760772
const schema = {
761773
path: `/api/${collection.slug}/${method}`,
@@ -786,7 +798,10 @@ export function getAllCollectionEndpoints<
786798
return { status: 200, body: response }
787799
}
788800

789-
return [endpointName, { schema, handler } satisfies ApiRoute<any, typeof schema>]
801+
return [
802+
endpointName,
803+
createEndpoint(schema, handler) satisfies ApiRoute<any, typeof schema>,
804+
]
790805
}
791806
case ApiDefaultMethod.UPDATE: {
792807
const body = fieldsToZodObject(fields)
@@ -820,7 +835,10 @@ export function getAllCollectionEndpoints<
820835
return { status: 200, body: response }
821836
}
822837

823-
return [endpointName, { schema, handler } satisfies ApiRoute<any, typeof schema>]
838+
return [
839+
endpointName,
840+
createEndpoint(schema, handler) satisfies ApiRoute<any, typeof schema>,
841+
]
824842
}
825843
case ApiDefaultMethod.DELETE: {
826844
const schema = {
@@ -846,7 +864,10 @@ export function getAllCollectionEndpoints<
846864
return { status: 200, body: { message: 'ok' } }
847865
}
848866

849-
return [endpointName, { schema, handler } satisfies ApiRoute<any, typeof schema>]
867+
return [
868+
endpointName,
869+
createEndpoint(schema, handler) satisfies ApiRoute<any, typeof schema>,
870+
]
850871
}
851872
default:
852873
throw new Error(`Unknown method: ${method}`)

packages/core/src/endpoint.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { z, ZodType } from 'zod'
44
import zodToJsonSchema from 'zod-to-json-schema'
55

66
import type { MaybePromise } from './collection'
7+
import { withValidator } from './utils'
78

89
export type ApiHttpStatus = 200 | 201 | 204 | 301 | 302 | 400 | 401 | 403 | 404 | 409 | 422 | 500
910

@@ -166,7 +167,7 @@ export function createEndpoint<
166167
>(schema: TApiEndpointSchema, handler: ApiRouteHandler<TContext, TApiEndpointSchema>) {
167168
return {
168169
schema,
169-
handler,
170+
handler: withValidator(schema, handler),
170171
}
171172
}
172173

packages/core/src/field.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type TableRelationalConfig,
88
} from 'drizzle-orm'
99
import type { Simplify } from 'type-fest'
10-
import type { ZodObject } from 'zod'
10+
import type { ZodObject, ZodOptional } from 'zod'
1111
import z from 'zod'
1212

1313
import {
@@ -444,21 +444,31 @@ export class FieldBuilder<
444444
}
445445
}
446446

447+
// TODO: Add support for relation input fields
448+
type CastOptionalFieldToZodSchema<
449+
TField extends Field<any>,
450+
TSchema extends z.ZodTypeAny,
451+
> = TField['_'] extends { source: 'column' }
452+
? TField['_']['column']['notNull'] extends true
453+
? ZodOptional<TSchema>
454+
: TSchema
455+
: TSchema
456+
447457
type FieldToZodScheama<TField extends Field<any>> =
448458
TField extends FieldColumnStringCollectionOptions<any>[keyof FieldColumnStringCollectionOptions<any>]
449-
? z.ZodString
459+
? CastOptionalFieldToZodSchema<TField, z.ZodString>
450460
: TField extends FieldColumnStringArrayCollectionOptions<any>[keyof FieldColumnStringArrayCollectionOptions<any>]
451-
? z.ZodArray<z.ZodString>
461+
? CastOptionalFieldToZodSchema<TField, z.ZodArray<z.ZodString>>
452462
: TField extends FieldColumnNumberCollectionOptions<any>[keyof FieldColumnNumberCollectionOptions<any>]
453-
? z.ZodNumber
463+
? CastOptionalFieldToZodSchema<TField, z.ZodNumber>
454464
: TField extends FieldColumnNumberArrayCollectionOptions<any>[keyof FieldColumnNumberArrayCollectionOptions<any>]
455-
? z.ZodArray<z.ZodNumber>
465+
? CastOptionalFieldToZodSchema<TField, z.ZodArray<z.ZodNumber>>
456466
: TField extends FieldColumnBooleanCollectionOptions[keyof FieldColumnBooleanCollectionOptions]
457-
? z.ZodBoolean
467+
? CastOptionalFieldToZodSchema<TField, z.ZodBoolean>
458468
: TField extends FieldColumnBooleanArrayCollectionOptions[keyof FieldColumnBooleanArrayCollectionOptions]
459-
? z.ZodArray<z.ZodBoolean>
469+
? CastOptionalFieldToZodSchema<TField, z.ZodArray<z.ZodBoolean>>
460470
: TField extends FieldColumnDateCollectionOptions[keyof FieldColumnDateCollectionOptions]
461-
? z.ZodDate
471+
? CastOptionalFieldToZodSchema<TField, z.ZodDate>
462472
: never
463473
// TODO: Relation input
464474
// TODO: Optioanl and default values
@@ -473,26 +483,47 @@ export function fieldToZodScheama<TField extends Field<any>>(
473483
case 'selectText':
474484
case 'time':
475485
case 'media':
486+
if (!field._.column.notNull) {
487+
return z.string().optional() as FieldToZodScheama<TField>
488+
}
476489
return z.string() as FieldToZodScheama<TField>
477490
// string[] input
478491
case 'comboboxText':
492+
if (!field._.column.notNull) {
493+
return z.array(z.string()).optional() as FieldToZodScheama<TField>
494+
}
479495
return z.array(z.string()) as FieldToZodScheama<TField>
480496
// number input
481497
case 'number':
482498
case 'selectNumber':
499+
if (!field._.column.notNull) {
500+
return z.number().optional() as FieldToZodScheama<TField>
501+
}
483502
return z.number() as FieldToZodScheama<TField>
484503
// number[] input
485504
case 'comboboxNumber':
505+
if (!field._.column.notNull) {
506+
return z.array(z.number()).optional() as FieldToZodScheama<TField>
507+
}
486508
return z.array(z.number()) as FieldToZodScheama<TField>
487509
// boolean input
488510
case 'checkbox':
489511
case 'switch':
512+
if (!field._.column.notNull) {
513+
return z.boolean().optional() as FieldToZodScheama<TField>
514+
}
490515
return z.boolean() as FieldToZodScheama<TField>
491516
// boolean[] input
492517
case 'comboboxBoolean':
518+
if (!field._.column.notNull) {
519+
return z.array(z.boolean()).optional() as FieldToZodScheama<TField>
520+
}
493521
return z.array(z.boolean()) as FieldToZodScheama<TField>
494522
// date input
495523
case 'date':
524+
if (!field._.column.notNull) {
525+
return z.date().optional() as FieldToZodScheama<TField>
526+
}
496527
return z.date() as FieldToZodScheama<TField>
497528
// TODO: relation input
498529
case 'connect':

packages/core/src/utils.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { Column, TableRelationalConfig } from 'drizzle-orm'
22
import { is, Table } from 'drizzle-orm'
33
import type { IsNever, Simplify, ValueOf } from 'type-fest'
4-
import type { ZodObject, ZodOptional, ZodType } from 'zod'
4+
import type { ZodError, ZodObject, ZodOptional, ZodType } from 'zod'
55

6+
import type { MaybePromise } from './collection'
7+
import type { ApiHttpStatus, ApiRouteHandlerPayloadWithContext, ApiRouteSchema } from './endpoint'
68
import type { Field, FieldRelation, Fields, FieldsInitial, FieldsWithFieldName } from './field'
79

810
export function isRelationField(field: Field): field is FieldRelation {
@@ -115,6 +117,107 @@ export function mapValueToTsValue(
115117
return Object.fromEntries(mappedEntries.filter((r) => r.length > 0))
116118
}
117119

120+
export async function validateRequestBody<
121+
TApiRouteSchema extends ApiRouteSchema = any,
122+
TContext extends Record<string, unknown> = Record<string, unknown>,
123+
>(schema: TApiRouteSchema, payload: ApiRouteHandlerPayloadWithContext<TApiRouteSchema, TContext>) {
124+
let zodErrors: Partial<Record<'query' | 'pathParams' | 'headers' | 'body', ZodError>> | undefined
125+
126+
if (schema.query) {
127+
const err = await schema.query.safeParseAsync((payload as any).query)
128+
if (!err.success) {
129+
zodErrors = {
130+
...zodErrors,
131+
query: err.error,
132+
}
133+
}
134+
}
135+
136+
if (schema.pathParams) {
137+
const err = await schema.pathParams.safeParseAsync((payload as any).pathParams)
138+
if (!err.success) {
139+
zodErrors = {
140+
...zodErrors,
141+
pathParams: err.error,
142+
}
143+
}
144+
}
145+
146+
if (schema.headers) {
147+
const err = await schema.headers.safeParseAsync(payload.headers)
148+
if (!err.success) {
149+
zodErrors = {
150+
...zodErrors,
151+
headers: err.error,
152+
}
153+
}
154+
}
155+
156+
if (schema.method !== 'GET' && schema.body) {
157+
const err = await schema.body.safeParseAsync((payload as any).body)
158+
if (!err.success) {
159+
zodErrors = {
160+
...zodErrors,
161+
body: err.error,
162+
}
163+
}
164+
}
165+
166+
return zodErrors
167+
}
168+
169+
export function validateResponseBody<TApiRouteSchema extends ApiRouteSchema = any>(
170+
schema: TApiRouteSchema,
171+
statusCode: ApiHttpStatus,
172+
response: any
173+
) {
174+
if (!schema.responses[statusCode]) {
175+
throw new Error(`No response schema defined for status code ${statusCode}`)
176+
}
177+
178+
const result = schema.responses[statusCode].safeParse(response)
179+
return result.error
180+
}
181+
182+
export function withValidator<
183+
TApiRouteSchema extends ApiRouteSchema,
184+
TContext extends Record<string, unknown>,
185+
>(
186+
schema: TApiRouteSchema,
187+
handler: (
188+
payload: ApiRouteHandlerPayloadWithContext<TApiRouteSchema, TContext>
189+
) => MaybePromise<any>
190+
): (payload: ApiRouteHandlerPayloadWithContext<TApiRouteSchema, TContext>) => MaybePromise<any> {
191+
return async (payload: ApiRouteHandlerPayloadWithContext<TApiRouteSchema, TContext>) => {
192+
const zodErrors = await validateRequestBody(schema, payload)
193+
if (zodErrors) {
194+
return {
195+
status: 400,
196+
body: {
197+
error: 'Validation failed',
198+
details: zodErrors,
199+
},
200+
}
201+
}
202+
203+
const response = await handler(payload)
204+
205+
const validationError = validateResponseBody(schema, response.status, response.body)
206+
207+
if (validationError) {
208+
return {
209+
status: 500,
210+
body: {
211+
error: 'Response validation failed',
212+
details: validationError,
213+
},
214+
}
215+
}
216+
217+
return response
218+
}
219+
}
220+
118221
export type JoinArrays<T extends any[]> = Simplify<
119222
T extends [infer A]
120223
? IsNever<A> extends true

0 commit comments

Comments
 (0)