turtle-express
Version:
`turtle-express` is kinda a framework or a library based on `express.js` with an opinionated express router with type safety and schema validation with zod. Also many [more features](https://github.com/mm-ninja-turtles/turtle-express/discussions/7) planni
352 lines (307 loc) • 10.9 kB
text/typescript
import { Request, RequestHandler, Response } from 'express'
import { z, ZodType } from 'zod'
import { OpenAPIV3_1 } from 'openapi-types'
import { LifeCycleStates, Method, ResolverTypes } from '../types/types'
import { HttpStatusCodes } from '../types/http-status-codes'
import {
requestValidation,
RequestValidationReturnType,
} from './request-validation'
import { resolverValidation } from './resolver-validation'
export type HandlerRequest<
Params extends ZodType | unknown,
Query extends ZodType | unknown,
Body extends ZodType | unknown,
> = {
params?: Params extends ZodType ? Params : never
query?: Query extends ZodType ? Query : never
body?: Body extends ZodType ? Body : never
}
export type HandlerResponse = {
// eslint-disable-next-line no-unused-vars
[key in keyof HttpStatusCodes]?: ZodType
}
export interface RequestSchemasContext<
Params extends ZodType | unknown,
Query extends ZodType | unknown,
Body extends ZodType | unknown,
> {
params: Params extends ZodType ? z.infer<Params> : never
query: Query extends ZodType ? z.infer<Query> : never
body: Body extends ZodType ? z.infer<Body> : never
}
export type ResolverArgs<
Params extends ZodType | unknown,
Query extends ZodType | unknown,
Body extends ZodType | unknown,
ResolverType extends ResolverTypes,
> = ResolverType extends 'controlled'
? {
ctx: RequestSchemasContext<Params, Query, Body>
req: Request
}
: {
ctx: RequestSchemasContext<Params, Query, Body>
req: Request
res: Response
}
export type ResolverReturnType<
ResolverType extends ResolverTypes,
ResponseSchema extends HandlerResponse,
> = {
[key in keyof HandlerResponse]?: ResolverType extends 'controlled'
? ResponseSchema[key] extends ZodType
? z.infer<ResponseSchema[key]>
: never
: any
}
export interface HandlerOptions<
Params extends ZodType | unknown,
Query extends ZodType | unknown,
Body extends ZodType | unknown,
ResolverType extends ResolverTypes,
ResponseSchema extends HandlerResponse = HandlerResponse,
> {
id: string
method: Method
request?: HandlerRequest<Params, Query, Body>
response: ResponseSchema
middlewares?: RequestHandler[]
resolverType?: ResolverType
resolver: (
args: ResolverArgs<Params, Query, Body, ResolverType>,
) => ResolverType extends 'controlled'
?
| ResolverReturnType<ResolverType, ResponseSchema>
| Promise<ResolverReturnType<ResolverType, ResponseSchema>>
: any
}
interface CreateHandlerReturnType {
_id: string
_getOpenApiSchemas: () => OpenAPIV3_1.SchemaObject
_getOpenApiPathItem: () => OpenAPIV3_1.PathItemObject
_middlewares: RequestHandler[]
_handlerFunc: RequestHandler
}
export function createHandler<
Params extends ZodType | unknown,
Query extends ZodType | unknown,
Body extends ZodType | unknown,
ResolverType extends ResolverTypes = 'controlled',
ResponseSchema extends HandlerResponse = HandlerResponse,
>(
options: HandlerOptions<Params, Query, Body, ResolverType, ResponseSchema>,
): CreateHandlerReturnType {
const resolverType = options.resolverType ?? 'controlled'
const _handlerFunc: RequestHandler = async (req, res, next) => {
const handlerContext = {
requestValidation: {
params: null,
query: null,
body: null,
} as RequestValidationReturnType<Params, Query, Body>['context'],
resolverValidation: null as z.SafeParseReturnType<any, any> | null,
resolver: null as any,
response: {
_failedReason: '' as '' | Exclude<LifeCycleStates, 'idle' | 'final'>,
success: true as boolean,
status: 200 as keyof HttpStatusCodes,
message: 'Success.' as string,
data: null as any,
error: null as any,
},
}
const state = {
previous: 'idle' as LifeCycleStates,
current: 'idle' as LifeCycleStates,
}
Promise.resolve()
.then(async () => {
// STATE: 1 - idle
if (state.current === 'idle') {
state.previous = state.current // idle
state.current = 'request_validation'
}
// STATE: 2 - request_validation
if (state.current === 'request_validation') {
if (state.previous !== 'idle')
throw new Error('[case `request_validation`]: Invalid state')
// if request schema is provided
if (options.request) {
// run request validation
const validation = requestValidation(req, options.request)
// store validation result in context
handlerContext.requestValidation = validation.context as any
// if at least one validation failed
if (validation.ifOneValidationFailed()) {
// prepare response and response back with validation error
state.previous = state.current // request_validation
state.current = 'before_response'
}
// if no validation failed
else {
// continue to resolver function
state.previous = state.current // request_validation
state.current = 'resolver'
}
}
// if request schema is not provided
else {
// skip to resolver function
state.previous = state.current // request_validation
state.current = 'resolver'
}
}
// STATE: 3 - resolver
if (state.current === 'resolver') {
if (state.previous !== 'request_validation')
throw new Error('[case `resolver`]: Invalid state')
// get request validations from context
const { params, query, body } = handlerContext.requestValidation
// construct resolver function context object
const ctx = {
params: params?.success ? params.data : null,
query: query?.success ? query.data : null,
body: body?.success ? body.data : null,
}
// execute the resolver function and store the result in context
handlerContext.resolver = await options.resolver(
resolverType === 'controlled'
? ({ ctx, req } as any) // only provide context and request if resolver is controlled
: ({ ctx, req, res } as any), // provide context, request and response if resolver is not controlled
)
state.previous = state.current // resolver
// go to resolver_validation state if resolver is controlled
if (resolverType === 'controlled')
state.current = 'resolver_validation'
// skip to final if resolver is not controlled
else state.current = 'final'
}
// STATE: 4 - resolver_validation
if (state.current === 'resolver_validation') {
if (state.previous !== 'resolver')
throw new Error('[case `resolver_validation`]: Invalid state')
// validate resolver result with response schema and store in context
handlerContext.resolverValidation = resolverValidation(
handlerContext.resolver,
options.response,
)
// go to before_response to prepare response object
state.previous = state.current // resolver_validation
state.current = 'before_response'
}
// STATE: 5 - before_response
if (state.current === 'before_response') {
// STATE: 5.1 - if previous state is request_validation
if (state.previous === 'request_validation') {
// request validation might have failed
// get request validation from context
const { params, query, body } = handlerContext.requestValidation
// construct error object
const error = {
params: params?.success === true ? null : params?.error.format(),
query: query?.success === true ? null : query?.error.format(),
body: body?.success === true ? null : body?.error.format(),
}
// set error response to context
handlerContext.response = {
_failedReason: 'request_validation',
status: 400,
success: false,
message: 'Validation Failed.',
data: null,
error,
}
}
// STATE: 5.2 - if previous state is resolver_validation
else if (state.previous === 'resolver_validation') {
// get resolver result
const resolverResult = handlerContext.resolver
// get resolver validation result
const resolverValidation = handlerContext.resolverValidation
// get the http status code to response
// by getting the first result from the resolver result
// which is the result of the resolver execution
let status = Object.keys(
resolverResult,
)[0] as unknown as keyof HttpStatusCodes
// set the response message default to Success.
let message = 'Success.'
// if http status is not in error range
// and resolver validation failed,
// validation failed
if (status < 400 && resolverValidation?.success === false) {
// set status to 400 with validation failed message.
status = 400
message = 'Response Validation Failed.'
}
// if http status is in error range
// and resolver validation is success,
// resolver failed response
else if (status >= 400 && resolverValidation?.success === true) {
// change message to Failed.
message = 'Failed.'
}
// construct response object and store in context
handlerContext.response = {
_failedReason: 'resolver_validation',
status,
success: resolverValidation?.success ?? false,
message,
data:
resolverValidation?.success === true
? resolverValidation.data
: null,
error:
resolverValidation?.success === true
? null
: resolverValidation?.error.format(),
}
}
// STATE: 5.3 - otherwise, throw error, something might have gone wrong
else throw new Error('[case `before_response`]: Invalid state')
// go to final state
state.previous = state.current // before_response
state.current = 'final'
}
// STATE: 6 - final
if (state.current === 'final') {
// STATE: 6.1 - if previous state is from resolver
if (state.previous === 'resolver') {
// resolver is uncontrolled type
// just return the resolver result
return handlerContext.resolver
}
// STATE: 6.2 - if previous state is from before_response
else if (state.previous === 'before_response') {
// get response from context
const response = handlerContext.response
const status = Number(response.status)
// construct response body with context
const body = {
success: response.success,
message: response.message,
data: response.data,
error: response.error,
}
// response with status and body
return res.status(status).send(body)
}
// STATE: 6.3 - otherwise, throw error something might have wrong
else throw new Error('[case `final`]: Invalid state')
}
})
.catch(next)
}
return {
_id: options.id,
_middlewares: options.middlewares ?? [],
_getOpenApiSchemas() {
return options as any
},
_getOpenApiPathItem() {
return options as any
},
_handlerFunc,
}
}