parent
c18cedb1b2
commit
7b898034a5
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/zod-openapi': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
introduce Zod OpenAPI
|
|
@ -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
|
14
package.json
14
package.json
|
@ -2,9 +2,11 @@
|
||||||
"name": "hono-middleware",
|
"name": "hono-middleware",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"description": "Third-party middleware for Hono",
|
"description": "Third-party middleware for Hono",
|
||||||
"workspaces": [
|
"workspaces": {
|
||||||
"packages/*"
|
"packages": [
|
||||||
],
|
"packages/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:hello": "yarn workspace @hono/hello build",
|
"build:hello": "yarn workspace @hono/hello build",
|
||||||
"build:zod-validator": "yarn workspace @hono/zod-validator build",
|
"build:zod-validator": "yarn workspace @hono/zod-validator build",
|
||||||
|
@ -16,6 +18,7 @@
|
||||||
"build:typebox-validator": "yarn workspace @hono/typebox-validator build",
|
"build:typebox-validator": "yarn workspace @hono/typebox-validator build",
|
||||||
"build:medley-router": "yarn workspace @hono/medley-router build",
|
"build:medley-router": "yarn workspace @hono/medley-router build",
|
||||||
"build:valibot-validator": "yarn workspace @hono/valibot-validator build",
|
"build:valibot-validator": "yarn workspace @hono/valibot-validator build",
|
||||||
|
"build:zod-openapi": "yarn workspace @hono/zod-openapi build",
|
||||||
"build": "run-p build:*"
|
"build": "run-p build:*"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
@ -43,8 +46,11 @@
|
||||||
"jest-environment-miniflare": "^2.10.0",
|
"jest-environment-miniflare": "^2.10.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^2.7.1",
|
"prettier": "^2.7.1",
|
||||||
|
"publint": "^0.2.0",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"ts-jest": "^29.0.5",
|
"ts-jest": "^29.0.5",
|
||||||
"typescript": "^4.7.4"
|
"tsup": "^7.2.0",
|
||||||
|
"typescript": "^4.7.4",
|
||||||
|
"vitest": "^0.34.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 }
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
}
|
Loading…
Reference in New Issue