Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/blue-seals-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kivotos/core": minor
---

[[DRIZZ-78] Request validation layer](https://app.plane.so/softnetics/browse/DRIZZ-78/)
47 changes: 34 additions & 13 deletions packages/core/src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import type { ConditionalExcept, Simplify, UnionToIntersection, ValueOf } from '
import z from 'zod'

import type { MinimalContext } from './config'
import type {
ApiRoute,
ApiRouteHandler,
ApiRouter,
ApiRouteSchema,
ClientApiRouter,
import {
type ApiRoute,
type ApiRouteHandler,
type ApiRouter,
type ApiRouteSchema,
type ClientApiRouter,
createEndpoint,
} from './endpoint'
import {
type Field,
Expand All @@ -23,7 +24,7 @@ import {
fieldsToZodObject,
type FieldsWithFieldName,
} from './field'
import type { GetTableByTableTsName, ToZodObject } from './utils'
import { type GetTableByTableTsName, type ToZodObject } from './utils'

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

Expand Down Expand Up @@ -724,7 +725,10 @@ export function getAllCollectionEndpoints<
return { status: 200, body: response }
}

return [endpointName, { schema, handler } satisfies ApiRoute<any, typeof schema>]
return [
endpointName,
createEndpoint(schema, handler) satisfies ApiRoute<any, typeof schema>,
]
}
case ApiDefaultMethod.FIND_ONE: {
const response = fieldsToZodObject(fields)
Expand Down Expand Up @@ -752,10 +756,18 @@ export function getAllCollectionEndpoints<
return { status: 200, body: response }
}

return [endpointName, { schema, handler } satisfies ApiRoute<any, typeof schema>]
return [
endpointName,
createEndpoint(schema, handler) satisfies ApiRoute<any, typeof schema>,
]
}
case ApiDefaultMethod.FIND_MANY: {
const response = fieldsToZodObject(fields)
const body = fieldsToZodObject(fields)
const response = z.object({
data: z.array(body),
total: z.number(),
page: z.number(),
})

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

return [endpointName, { schema, handler } satisfies ApiRoute<any, typeof schema>]
return [
endpointName,
createEndpoint(schema, handler) satisfies ApiRoute<any, typeof schema>,
]
}
case ApiDefaultMethod.UPDATE: {
const body = fieldsToZodObject(fields)
Expand Down Expand Up @@ -820,7 +835,10 @@ export function getAllCollectionEndpoints<
return { status: 200, body: response }
}

return [endpointName, { schema, handler } satisfies ApiRoute<any, typeof schema>]
return [
endpointName,
createEndpoint(schema, handler) satisfies ApiRoute<any, typeof schema>,
]
}
case ApiDefaultMethod.DELETE: {
const schema = {
Expand All @@ -846,7 +864,10 @@ export function getAllCollectionEndpoints<
return { status: 200, body: { message: 'ok' } }
}

return [endpointName, { schema, handler } satisfies ApiRoute<any, typeof schema>]
return [
endpointName,
createEndpoint(schema, handler) satisfies ApiRoute<any, typeof schema>,
]
}
default:
throw new Error(`Unknown method: ${method}`)
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { z, ZodType } from 'zod'
import zodToJsonSchema from 'zod-to-json-schema'

import type { MaybePromise } from './collection'
import { withValidator } from './utils'

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

Expand Down Expand Up @@ -166,7 +167,7 @@ export function createEndpoint<
>(schema: TApiEndpointSchema, handler: ApiRouteHandler<TContext, TApiEndpointSchema>) {
return {
schema,
handler,
handler: withValidator(schema, handler),
}
}

Expand Down
47 changes: 39 additions & 8 deletions packages/core/src/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type TableRelationalConfig,
} from 'drizzle-orm'
import type { Simplify } from 'type-fest'
import type { ZodObject } from 'zod'
import type { ZodObject, ZodOptional } from 'zod'
import z from 'zod'

import {
Expand Down Expand Up @@ -444,21 +444,31 @@ export class FieldBuilder<
}
}

// TODO: Add support for relation input fields
type CastOptionalFieldToZodSchema<
TField extends Field<any>,
TSchema extends z.ZodTypeAny,
> = TField['_'] extends { source: 'column' }
? TField['_']['column']['notNull'] extends true
? ZodOptional<TSchema>
: TSchema
: TSchema

type FieldToZodScheama<TField extends Field<any>> =
TField extends FieldColumnStringCollectionOptions<any>[keyof FieldColumnStringCollectionOptions<any>]
? z.ZodString
? CastOptionalFieldToZodSchema<TField, z.ZodString>
: TField extends FieldColumnStringArrayCollectionOptions<any>[keyof FieldColumnStringArrayCollectionOptions<any>]
? z.ZodArray<z.ZodString>
? CastOptionalFieldToZodSchema<TField, z.ZodArray<z.ZodString>>
: TField extends FieldColumnNumberCollectionOptions<any>[keyof FieldColumnNumberCollectionOptions<any>]
? z.ZodNumber
? CastOptionalFieldToZodSchema<TField, z.ZodNumber>
: TField extends FieldColumnNumberArrayCollectionOptions<any>[keyof FieldColumnNumberArrayCollectionOptions<any>]
? z.ZodArray<z.ZodNumber>
? CastOptionalFieldToZodSchema<TField, z.ZodArray<z.ZodNumber>>
: TField extends FieldColumnBooleanCollectionOptions[keyof FieldColumnBooleanCollectionOptions]
? z.ZodBoolean
? CastOptionalFieldToZodSchema<TField, z.ZodBoolean>
: TField extends FieldColumnBooleanArrayCollectionOptions[keyof FieldColumnBooleanArrayCollectionOptions]
? z.ZodArray<z.ZodBoolean>
? CastOptionalFieldToZodSchema<TField, z.ZodArray<z.ZodBoolean>>
: TField extends FieldColumnDateCollectionOptions[keyof FieldColumnDateCollectionOptions]
? z.ZodDate
? CastOptionalFieldToZodSchema<TField, z.ZodDate>
: never
// TODO: Relation input
// TODO: Optioanl and default values
Expand All @@ -473,26 +483,47 @@ export function fieldToZodScheama<TField extends Field<any>>(
case 'selectText':
case 'time':
case 'media':
if (!field._.column.notNull) {
return z.string().optional() as FieldToZodScheama<TField>
}
return z.string() as FieldToZodScheama<TField>
// string[] input
case 'comboboxText':
if (!field._.column.notNull) {
return z.array(z.string()).optional() as FieldToZodScheama<TField>
}
return z.array(z.string()) as FieldToZodScheama<TField>
// number input
case 'number':
case 'selectNumber':
if (!field._.column.notNull) {
return z.number().optional() as FieldToZodScheama<TField>
}
return z.number() as FieldToZodScheama<TField>
// number[] input
case 'comboboxNumber':
if (!field._.column.notNull) {
return z.array(z.number()).optional() as FieldToZodScheama<TField>
}
return z.array(z.number()) as FieldToZodScheama<TField>
// boolean input
case 'checkbox':
case 'switch':
if (!field._.column.notNull) {
return z.boolean().optional() as FieldToZodScheama<TField>
}
return z.boolean() as FieldToZodScheama<TField>
// boolean[] input
case 'comboboxBoolean':
if (!field._.column.notNull) {
return z.array(z.boolean()).optional() as FieldToZodScheama<TField>
}
return z.array(z.boolean()) as FieldToZodScheama<TField>
// date input
case 'date':
if (!field._.column.notNull) {
return z.date().optional() as FieldToZodScheama<TField>
}
return z.date() as FieldToZodScheama<TField>
// TODO: relation input
case 'connect':
Expand Down
105 changes: 104 additions & 1 deletion packages/core/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Column, TableRelationalConfig } from 'drizzle-orm'
import { is, Table } from 'drizzle-orm'
import type { IsNever, Simplify, ValueOf } from 'type-fest'
import type { ZodObject, ZodOptional, ZodType } from 'zod'
import type { ZodError, ZodObject, ZodOptional, ZodType } from 'zod'

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

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

export async function validateRequestBody<
TApiRouteSchema extends ApiRouteSchema = any,
TContext extends Record<string, unknown> = Record<string, unknown>,
>(schema: TApiRouteSchema, payload: ApiRouteHandlerPayloadWithContext<TApiRouteSchema, TContext>) {
let zodErrors: Partial<Record<'query' | 'pathParams' | 'headers' | 'body', ZodError>> | undefined

if (schema.query) {
const err = await schema.query.safeParseAsync((payload as any).query)
if (!err.success) {
zodErrors = {
...zodErrors,
query: err.error,
}
}
}

if (schema.pathParams) {
const err = await schema.pathParams.safeParseAsync((payload as any).pathParams)
if (!err.success) {
zodErrors = {
...zodErrors,
pathParams: err.error,
}
}
}

if (schema.headers) {
const err = await schema.headers.safeParseAsync(payload.headers)
if (!err.success) {
zodErrors = {
...zodErrors,
headers: err.error,
}
}
}

if (schema.method !== 'GET' && schema.body) {
const err = await schema.body.safeParseAsync((payload as any).body)
if (!err.success) {
zodErrors = {
...zodErrors,
body: err.error,
}
}
}

return zodErrors
}

export function validateResponseBody<TApiRouteSchema extends ApiRouteSchema = any>(
schema: TApiRouteSchema,
statusCode: ApiHttpStatus,
response: any
) {
if (!schema.responses[statusCode]) {
throw new Error(`No response schema defined for status code ${statusCode}`)
}

const result = schema.responses[statusCode].safeParse(response)
return result.error
}

export function withValidator<
TApiRouteSchema extends ApiRouteSchema,
TContext extends Record<string, unknown>,
>(
schema: TApiRouteSchema,
handler: (
payload: ApiRouteHandlerPayloadWithContext<TApiRouteSchema, TContext>
) => MaybePromise<any>
): (payload: ApiRouteHandlerPayloadWithContext<TApiRouteSchema, TContext>) => MaybePromise<any> {
return async (payload: ApiRouteHandlerPayloadWithContext<TApiRouteSchema, TContext>) => {
const zodErrors = await validateRequestBody(schema, payload)
if (zodErrors) {
return {
status: 400,
body: {
error: 'Validation failed',
details: zodErrors,
},
}
}

const response = await handler(payload)

const validationError = validateResponseBody(schema, response.status, response.body)

if (validationError) {
return {
status: 500,
body: {
error: 'Response validation failed',
details: validationError,
},
}
}

return response
}
}

export type JoinArrays<T extends any[]> = Simplify<
T extends [infer A]
? IsNever<A> extends true
Expand Down