@mantleframework/validation
Zod-based request and response validation for Lambda handlers. This package provides defineApiHandler -- the primary way to create API Gateway Lambda handlers in Mantle.
defineApiHandler()
Create a fully-observable API Gateway Lambda handler with optional body/query validation, configurable auth, and OpenAPI metadata.
import { defineApiHandler } from '@mantleframework/validation'Signature:
function defineApiHandler<TBody = void, TQuery = void, TPath = void, TAuth extends 'bearer' | 'authorizer' | 'authorizer-optional' | 'session' | 'none' = 'none'>(
options: DefineApiHandlerOptions<TBody, TQuery, TPath, TAuth>
): (handler: (params) => Promise<APIGatewayProxyResult>) => (event: APIGatewayProxyEvent, context: Context) => Promise<APIGatewayProxyResult>Options:
| Option | Type | Default | Description |
|---|---|---|---|
schema | z.ZodSchema<TBody> | - | Request body validation schema |
querySchema | z.ZodSchema<TQuery> | - | Query string parameter validation schema |
auth | 'bearer' | 'authorizer' | 'authorizer-optional' | 'session' | 'none' | 'none' | Auth mode |
pathSchema | z.ZodSchema<TPath> | - | Path parameter validation schema |
responseSchema | z.ZodSchema | - | Response schema (stored in OpenAPI registry; not enforced at runtime) |
operationName | string | handler function name | Metrics/tracing name |
logging | LoggingConfig | - | Logging configuration forwarded to withObservability |
openapi | OpenApiMetadata | - | OpenAPI metadata for doc generation |
timeout | number | - | Lambda timeout in seconds |
memorySize | number | - | Lambda memory in MB |
reservedConcurrency | number | - | Reserved concurrent executions |
ephemeralStorage | number | - | Ephemeral storage in MB (/tmp) |
deadLetterQueue | boolean | { targetArn?: string } | - | Dead letter queue configuration |
retryAttempts | number | - | Max retry attempts on async failure (0--2) |
The params type adapts based on schema, querySchema, and auth:
| Configuration | Params type |
|---|---|
| No schema, no query | ApiHandlerParams -- { event, context, metadata } |
schema only | ValidatedApiParams<TBody> -- { event, context, metadata, body } |
querySchema only | ValidatedQueryParams<TQuery> -- { event, context, metadata, query } |
| Both schemas | ValidatedBodyAndQueryParams<TBody, TQuery> -- { event, context, metadata, body, query } |
auth: 'authorizer' | AuthorizedApiParams<TBody, TQuery, TPath> -- adds authorizer, userId: string, userStatus |
auth: 'authorizer-optional' | AuthorizedOptionalApiParams<TBody, TQuery, TPath> -- adds authorizer, userId: string | undefined, userStatus |
auth: 'session' | SessionApiParams<TBody, TQuery, TPath> -- adds userId, session |
Features
- Full observability (metrics, tracing, logging) via
withObservability - Optional Zod body and query parameter validation with structured error responses
- Configurable auth: bearer token, Lambda authorizer, Better Auth session, or none
- Automatic error-to-HTTP mapping via
buildErrorResponse - Correlation ID extraction and propagation
- OpenAPI metadata registration for spec generation
GET handler (no body)
import { buildValidatedResponse } from '@mantleframework/core'
import { defineApiHandler, z } from '@mantleframework/validation'
const ResponseSchema = z.object({
items: z.array(z.object({ id: z.string(), name: z.string() })),
})
const api = defineApiHandler({ auth: 'bearer', operationName: 'ListItems' })
export const handler = api(async ({ event, context }) => {
const items = await ItemQueries.getAll()
return buildValidatedResponse(context, 200, { items }, ResponseSchema)
})POST handler (with body validation)
import { buildValidatedResponse } from '@mantleframework/core'
import { defineApiHandler, z } from '@mantleframework/validation'
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
age: z.number().int().positive().optional(),
})
const UserResponseSchema = z.object({
id: z.string(),
email: z.string(),
name: z.string(),
age: z.number().optional(),
})
const api = defineApiHandler({ schema: CreateUserSchema, auth: 'bearer', operationName: 'CreateUser' })
export const handler = api(async ({ event, context, body }) => {
// body is typed as { email: string; name: string; age?: number }
const user = await UserQueries.create(body)
return buildValidatedResponse(context, 201, user, UserResponseSchema)
})GET handler with query parameters
import { buildValidatedResponse } from '@mantleframework/core'
import { defineApiHandler, z } from '@mantleframework/validation'
const QuerySchema = z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(20),
})
const api = defineApiHandler({ querySchema: QuerySchema, auth: 'bearer' })
export const handler = api(async ({ context, query }) => {
// query is typed as { page: number; limit: number }
const items = await ItemQueries.paginated(query.page, query.limit)
return buildValidatedResponse(context, 200, { items }, ResponseSchema)
})Handler with OpenAPI metadata
const api = defineApiHandler({
schema: CreateUserSchema,
auth: 'bearer',
openapi: {
summary: 'Create a user',
tags: ['users'],
operationId: 'createUser',
},
})Validation error response
When body or query validation fails, defineApiHandler returns a 400 response with structured errors:
{
"message": "Bad Request",
"errors": {
"email": ["Invalid email"],
"name": ["Required"]
}
}Public handler (no auth)
const api = defineApiHandler({ operationName: 'HealthCheck' })
export const handler = api(async ({ context }) => {
return buildValidatedResponse(context, 200, { status: 'ok' })
})validateSchema(schema, data)
Validate data against a Zod schema. Returns a ValidationResult<T>.
Always check result.success explicitly -- the result is an object, so bare truthiness checks are always truthy.
import { validateSchema } from '@mantleframework/validation'
const result = validateSchema(MySchema, requestBody)
if (!result.success) {
// result.errors: { email: ['Invalid email'], _form: ['...'] }
}
// result.data is the parsed and coerced payloadvalidateResponse(schema, data)
Validate response data against a schema. Throws a generic Error on failure (Zod details are not exposed). Use for development-time response validation.
import { validateResponse } from '@mantleframework/validation'
const validated = validateResponse(ResponseSchema, responseData)toJsonSchema(schema, options?)
Convert a Zod schema to JSON Schema using Zod 4's native conversion. Replaces the broken zod-to-json-schema third-party library.
import { toJsonSchema } from '@mantleframework/validation'
const jsonSchema = toJsonSchema(MyZodSchema, { target: 'draft-2020-12' })Options:
| Option | Type | Description |
|---|---|---|
name | string | Optional schema name |
target | 'draft-2020-12' | 'draft-07' | 'draft-04' | JSON Schema draft version |
Types
ValidationResult<T>
interface ValidationResult<T = unknown> {
success: boolean
data?: T // parsed payload (present when success is true)
errors?: Record<string, string[]> // field-keyed errors (present when success is false)
}OpenApiMetadata
interface OpenApiMetadata {
summary?: string
description?: string
tags?: string[]
deprecated?: boolean
operationId?: string
}OpenApiRegistryEntry
interface OpenApiRegistryEntry {
handlerId: string
metadata: OpenApiMetadata
requestSchema?: z.ZodSchema
querySchema?: z.ZodSchema
responseSchema?: z.ZodSchema
auth?: 'bearer' | 'authorizer' | 'authorizer-optional' | 'session' | 'none'
}Auth modes
| Mode | userId type | Behavior on anonymous request |
|---|---|---|
'authorizer' | string | Throws UnauthorizedError -- handler never runs |
'authorizer-optional' | string | undefined | Passes through -- handler receives userStatus: 'Anonymous' and must branch |
'bearer' | n/a | Validates static bearer token from API_BEARER_TOKEN env var |
'session' | string | Validates Better Auth session token -- throws on invalid/expired |
'none' | n/a | No auth (default) |
Handler with authorizer-optional (anonymous + authenticated)
Use auth: 'authorizer-optional' when an endpoint serves both authenticated and anonymous traffic. The handler must check userStatus before using userId:
import { buildValidatedResponse, UserStatus } from '@mantleframework/core'
import { defineApiHandler, z } from '@mantleframework/validation'
const QuerySchema = z.object({ status: z.string().optional().default('downloaded') })
const api = defineApiHandler({ auth: 'authorizer-optional', querySchema: QuerySchema, operationName: 'ListFiles' })
export const handler = api(async ({ context, userId, userStatus, query }) => {
if (userStatus === UserStatus.Anonymous) {
// Return demo content for anonymous users
return buildValidatedResponse(context, 200, { contents: [getDefaultFile()], keyCount: 1 }, schema)
}
// userId is string here (but TS can't narrow it from userStatus check)
const files = await getFilesByUser(userId as string)
return buildValidatedResponse(context, 200, { contents: files, keyCount: files.length }, schema)
})OpenAPI Registry
getOpenApiRegistry()
Returns a read-only snapshot of all OpenAPI metadata registered by defineApiHandler calls. The CLI mantle generate openapi command consumes this registry.
import { getOpenApiRegistry } from '@mantleframework/validation'
const registry = getOpenApiRegistry() // ReadonlyMap<string, OpenApiRegistryEntry>Re-exports
The package re-exports z and related types from Zod for convenience:
import { z } from '@mantleframework/validation'
import type { ZodSchema, ZodError, ZodType } from '@mantleframework/validation'