feat: zod-openapi (#118)

* wip

* wip

* update readme

* changeset

* nohoist

* fixed CI
pull/121/head
Yusuke Wada 2023-08-19 02:43:36 +09:00 committed by GitHub
parent c18cedb1b2
commit 7b898034a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1459 additions and 14 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/zod-openapi': patch
---
introduce Zod OpenAPI

View File

@ -0,0 +1,28 @@
name: ci-zod-openapi
on:
push:
branches: [main]
paths:
- 'packages/zod-openapi/**'
pull_request:
branches: ['*']
paths:
- 'packages/zod-openapi/**'
jobs:
ci:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/zod-openapi
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 18.x
- run: yarn install --frozen-lockfile
- name: Build zod-validator in root directory
run: yarn build:zod-validator
working-directory: .
- run: yarn build
- run: yarn test

View File

@ -2,9 +2,11 @@
"name": "hono-middleware",
"version": "0.0.0",
"description": "Third-party middleware for Hono",
"workspaces": [
"workspaces": {
"packages": [
"packages/*"
],
]
},
"scripts": {
"build:hello": "yarn workspace @hono/hello build",
"build:zod-validator": "yarn workspace @hono/zod-validator build",
@ -16,6 +18,7 @@
"build:typebox-validator": "yarn workspace @hono/typebox-validator build",
"build:medley-router": "yarn workspace @hono/medley-router build",
"build:valibot-validator": "yarn workspace @hono/valibot-validator build",
"build:zod-openapi": "yarn workspace @hono/zod-openapi build",
"build": "run-p build:*"
},
"license": "MIT",
@ -43,8 +46,11 @@
"jest-environment-miniflare": "^2.10.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.7.1",
"publint": "^0.2.0",
"rimraf": "^3.0.2",
"ts-jest": "^29.0.5",
"typescript": "^4.7.4"
"tsup": "^7.2.0",
"typescript": "^4.7.4",
"vitest": "^0.34.2"
}
}

View File

@ -0,0 +1,127 @@
# Zod OpenAPI Hono
A wrapper class for Hono that supports OpenAPI. With it, you can validate values and types using [Zod](https://zod.dev/) and generate OpenAPI Swagger documentation.
This is based on [Zod to OpenAPI](https://github.com/asteasolutions/zod-to-openapi).
For details on creating schemas and defining routes, please refer to this resource.
_This is not a middleware but hosted on this monorepo_
## Usage
### Install
```
npm i hono zod @hono/zod-openapi
```
### Write your application
Define schemas:
```ts
import { z } from '@hono/zod-openapi'
const ParamsSchema = z.object({
id: z
.string()
.min(3)
.openapi({
param: {
name: 'id',
in: 'path',
},
example: '1212121',
}),
})
const UserSchema = z
.object({
id: z.number().openapi({
example: 123,
}),
name: z.string().openapi({
example: 'John Doe',
}),
age: z.number().openapi({
example: 42,
}),
})
.openapi('User')
```
Create routes:
```ts
import { createRoute } from '@hono/zod-openapi'
const route = createRoute({
method: 'get',
path: '/users/:id',
request: {
params: ParamsSchema,
},
responses: {
200: {
content: {
'application/json': {
schema: UserSchema,
},
},
description: 'Get the user',
},
400: {
content: {
'application/json': {
schema: ErrorSchema,
},
},
description: 'Error!',
},
},
})
```
Create the App:
```ts
const app = new OpenAPIHono()
app.openapi(
route,
(c) => {
const { id } = c.req.valid('param')
return c.jsonT({
id: Number(id),
age: 20,
name: 'Ultra-man',
})
},
(result, c) => {
if (!result.success) {
const res = c.jsonT(
{
ok: false,
},
400
)
return res
}
}
)
app.doc('/doc', {
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'My API',
},
})
```
## Author
Yusuke Wada <https://github.com/yusukebe>
## License
MIT

View File

@ -0,0 +1,44 @@
{
"name": "@hono/zod-openapi",
"version": "0.0.0",
"description": "A wrapper class of Hono which supports OpenAPI.",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"test": "vitest run",
"build": "tsup ./src/index.ts --format esm,cjs --dts",
"publint": "publint",
"release": "yarn build && yarn test && yarn publint && yarn publish"
},
"license": "MIT",
"private": false,
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/honojs/middleware.git"
},
"homepage": "https://github.com/honojs/middleware",
"peerDependencies": {
"hono": "*",
"zod": "3.*"
},
"devDependencies": {
"hono": "^3.4.3",
"zod": "^3.22.1"
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "^5.5.0",
"@hono/zod-validator": "^0.1.7"
},
"engines": {
"node": ">=16.0.0"
}
}

View File

@ -0,0 +1,202 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import type {
ResponseConfig,
RouteConfig,
ZodContentObject,
ZodRequestBody,
} from '@asteasolutions/zod-to-openapi'
import { OpenApiGeneratorV3, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'
import type { OpenAPIObjectConfig } from '@asteasolutions/zod-to-openapi/dist/v3.0/openapi-generator'
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import type { Context, Input, TypedResponse } from 'hono'
import type { Env, Handler, MiddlewareHandler } from 'hono'
import type { AnyZodObject, ZodSchema, ZodError } from 'zod'
import { z, ZodType } from 'zod'
type RequestTypes = {
body?: ZodRequestBody
params?: AnyZodObject
query?: AnyZodObject
cookies?: AnyZodObject // not support
headers?: AnyZodObject | ZodType<unknown>[] // not support
}
type IsJson<T> = T extends string
? T extends `application/json${infer _Rest}`
? 'json'
: never
: never
type IsForm<T> = T extends string
? T extends
| `multipart/form-data${infer _Rest}`
| `application/x-www-form-urlencoded${infer _Rest}`
? 'form'
: never
: never
type RequestPart<R extends RouteConfig, Part extends string> = Part extends keyof R['request']
? R['request'][Part]
: never
type InputTypeBase<
R extends RouteConfig,
Part extends string,
Type extends string
> = R['request'] extends RequestTypes
? RequestPart<R, Part> extends AnyZodObject
? {
out: { [K in Type]: z.input<RequestPart<R, Part>> }
}
: {}
: {}
type InputTypeJson<R extends RouteConfig> = R['request'] extends RequestTypes
? R['request']['body'] extends ZodRequestBody
? R['request']['body']['content'] extends ZodContentObject
? IsJson<keyof R['request']['body']['content']> extends never
? {}
: R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] extends ZodSchema<any>
? {
out: {
json: z.input<
R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
>
}
}
: {}
: {}
: {}
: {}
type InputTypeForm<R extends RouteConfig> = R['request'] extends RequestTypes
? R['request']['body'] extends ZodRequestBody
? R['request']['body']['content'] extends ZodContentObject
? IsForm<keyof R['request']['body']['content']> extends never
? {}
: R['request']['body']['content'][keyof R['request']['body']['content']]['schema'] extends ZodSchema<any>
? {
out: {
form: z.input<
R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
>
}
}
: {}
: {}
: {}
: {}
type InputTypeParam<R extends RouteConfig> = InputTypeBase<R, 'params', 'param'>
type InputTypeQuery<R extends RouteConfig> = InputTypeBase<R, 'query', 'query'>
type OutputType<R extends RouteConfig> = R['responses'] extends Record<infer _, infer C>
? C extends ResponseConfig
? C['content'] extends ZodContentObject
? IsJson<keyof C['content']> extends never
? {}
: C['content'][keyof C['content']]['schema'] extends ZodSchema
? z.infer<C['content'][keyof C['content']]['schema']>
: {}
: {}
: {}
: {}
type Hook<T, E extends Env, P extends string, O> = (
result:
| {
success: true
data: T
}
| {
success: false
error: ZodError
},
c: Context<E, P>
) => TypedResponse<O> | Promise<TypedResponse<T>> | void
export class OpenAPIHono<E extends Env = Env, S = {}, BasePath extends string = '/'> extends Hono<
E,
S,
BasePath
> {
#registry: OpenAPIRegistry
constructor() {
super()
this.#registry = new OpenAPIRegistry()
}
openapi = <
R extends RouteConfig,
I extends Input = InputTypeParam<R> & InputTypeQuery<R> & InputTypeForm<R> & InputTypeJson<R>
>(
route: R,
handler: Handler<E, R['path'], I, OutputType<R>>,
hook?: Hook<I, E, R['path'], OutputType<R>>
) => {
this.#registry.registerPath(route)
const validators: MiddlewareHandler[] = []
if (route.request?.query) {
const validator = zValidator('query', route.request.query as any, hook as any)
validators.push(validator as any)
}
if (route.request?.params) {
const validator = zValidator('param', route.request.params as any, hook as any)
validators.push(validator as any)
}
const bodyContent = route.request?.body?.content
if (bodyContent) {
for (const mediaType of Object.keys(bodyContent)) {
if (mediaType.startsWith('application/json')) {
const schema = bodyContent[mediaType]['schema']
if (schema instanceof ZodType) {
const validator = zValidator('json', schema as any, hook as any)
validators.push(validator as any)
}
}
if (
mediaType.startsWith('multipart/form-data') ||
mediaType.startsWith('application/x-www-form-urlencoded')
) {
const schema = bodyContent[mediaType]['schema']
if (schema instanceof ZodType) {
const validator = zValidator('form', schema as any, hook as any)
validators.push(validator as any)
}
}
}
}
this.on([route.method], route.path, ...validators, handler)
return this
}
getOpenAPIDocument = (config: OpenAPIObjectConfig) => {
const generator = new OpenApiGeneratorV3(this.#registry.definitions)
const document = generator.generateDocument(config)
return document
}
doc = (path: string, config: OpenAPIObjectConfig) => {
this.get(path, (c) => {
const document = this.getOpenAPIDocument(config)
return c.json(document)
})
}
}
export const createRoute = <P extends string, R extends Omit<RouteConfig, 'path'> & { path: P }>(
routeConfig: R
) => routeConfig
extendZodWithOpenApi(z)
export { z }

View File

@ -0,0 +1,384 @@
// eslint-disable-next-line node/no-extraneous-import
import { describe, it, expect } from 'vitest'
import { OpenAPIHono, createRoute, z } from '../src'
describe('Basic - params', () => {
const ParamsSchema = z.object({
id: z
.string()
.min(3)
.openapi({
param: {
name: 'id',
in: 'path',
},
example: '1212121',
}),
})
const UserSchema = z
.object({
id: z.number().openapi({
example: 123,
}),
name: z.string().openapi({
example: 'John Doe',
}),
age: z.number().openapi({
example: 42,
}),
})
.openapi('User')
const ErrorSchema = z
.object({
ok: z.boolean().openapi({
example: false,
}),
})
.openapi('Error')
const route = createRoute({
method: 'get',
path: '/users/:id',
request: {
params: ParamsSchema,
},
responses: {
200: {
content: {
'application/json': {
schema: UserSchema,
},
},
description: 'Get the user',
},
400: {
content: {
'application/json': {
schema: ErrorSchema,
},
},
description: 'Error!',
},
},
})
const app = new OpenAPIHono()
app.openapi(
route,
(c) => {
const { id } = c.req.valid('param')
return c.jsonT({
id: Number(id),
age: 20,
name: 'Ultra-man',
})
},
(result, c) => {
if (!result.success) {
const res = c.jsonT(
{
ok: false,
},
400
)
return res
}
}
)
app.doc('/doc', {
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'My API',
},
})
it('Should return 200 response with correct contents', async () => {
const res = await app.request('/users/123')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
id: 123,
age: 20,
name: 'Ultra-man',
})
})
it('Should return 400 response with correct contents', async () => {
const res = await app.request('/users/1')
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ ok: false })
})
it('Should return OpenAPI documents', async () => {
const res = await app.request('/doc')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
openapi: '3.0.0',
info: { version: '1.0.0', title: 'My API' },
components: {
schemas: {
User: {
type: 'object',
properties: {
id: { type: 'number', example: 123 },
name: { type: 'string', example: 'John Doe' },
age: { type: 'number', example: 42 },
},
required: ['id', 'name', 'age'],
},
Error: {
type: 'object',
properties: { ok: { type: 'boolean', example: false } },
required: ['ok'],
},
},
parameters: {},
},
paths: {
'/users/:id': {
get: {
parameters: [
{
schema: { type: 'string', minLength: 3, example: '1212121' },
required: true,
name: 'id',
in: 'path',
},
],
responses: {
'200': {
description: 'Get the user',
content: { 'application/json': { schema: { $ref: '#/components/schemas/User' } } },
},
'400': {
description: 'Error!',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
},
})
})
})
describe('Query', () => {
const QuerySchema = z.object({
page: z.string().openapi({
example: '123',
}),
})
const PostsSchema = z
.object({
title: z.string().openapi({}),
content: z.string().openapi({}),
page: z.number().openapi({}),
})
.openapi('Post')
const route = createRoute({
method: 'get',
path: '/books',
request: {
query: QuerySchema,
},
responses: {
200: {
content: {
'application/json': {
schema: PostsSchema,
},
},
description: 'Get the posts',
},
},
})
const app = new OpenAPIHono()
app.openapi(route, (c) => {
const { page } = c.req.valid('query')
return c.jsonT({
title: 'Good title',
content: 'Good content',
page: Number(page),
})
})
it('Should return 200 response with correct contents', async () => {
const res = await app.request('/books?page=123')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
title: 'Good title',
content: 'Good content',
page: 123,
})
})
it('Should return 400 response with correct contents', async () => {
const res = await app.request('/books')
expect(res.status).toBe(400)
})
})
describe('JSON', () => {
const RequestSchema = z.object({
id: z.number().openapi({}),
title: z.string().openapi({}),
})
const PostsSchema = z
.object({
id: z.number().openapi({}),
title: z.string().openapi({}),
})
.openapi('Post')
const route = createRoute({
method: 'post',
path: '/posts',
request: {
body: {
content: {
'application/json': {
schema: RequestSchema,
},
},
},
},
responses: {
200: {
content: {
'application/json': {
schema: PostsSchema,
},
},
description: 'Get the posts',
},
},
})
const app = new OpenAPIHono()
app.openapi(route, (c) => {
const { id, title } = c.req.valid('json')
return c.jsonT({
id,
title,
})
})
it('Should return 200 response with correct contents', async () => {
const req = new Request('http://localhost/posts', {
method: 'POST',
body: JSON.stringify({
id: 123,
title: 'Good title',
}),
headers: {
'Content-Type': 'application/json',
},
})
const res = await app.request(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
id: 123,
title: 'Good title',
})
})
it('Should return 400 response with correct contents', async () => {
const req = new Request('http://localhost/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
const res = await app.request(req)
expect(res.status).toBe(400)
})
})
describe('Form', () => {
const RequestSchema = z.object({
id: z.string().openapi({}),
title: z.string().openapi({}),
})
const PostsSchema = z
.object({
id: z.number().openapi({}),
title: z.string().openapi({}),
})
.openapi('Post')
const route = createRoute({
method: 'post',
path: '/posts',
request: {
body: {
content: {
'application/x-www-form-urlencoded': {
schema: RequestSchema,
},
},
},
},
responses: {
200: {
content: {
'application/json': {
schema: PostsSchema,
},
},
description: 'Get the posts',
},
},
})
const app = new OpenAPIHono()
app.openapi(route, (c) => {
const { id, title } = c.req.valid('form')
return c.jsonT({
id: Number(id),
title,
})
})
it('Should return 200 response with correct contents', async () => {
const searchParams = new URLSearchParams()
searchParams.append('id', '123')
searchParams.append('title', 'Good title')
const req = new Request('http://localhost/posts', {
method: 'POST',
body: searchParams,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
const res = await app.request(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
id: 123,
title: 'Good title',
})
})
it('Should return 400 response with correct contents', async () => {
const req = new Request('http://localhost/posts', {
method: 'POST',
})
const res = await app.request(req)
expect(res.status).toBe(400)
})
})

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
},
"include": [
"src/**/*.ts"
],
}

660
yarn.lock

File diff suppressed because it is too large Load Diff