import type { RouteConfig } from '@asteasolutions/zod-to-openapi' import type { Hono, Env, ToSchema, Context } from 'hono' import { hc } from 'hono/client' import { describe, it, expect, expectTypeOf } from 'vitest' import { OpenAPIHono, createRoute, z } from '../src/index' describe('Constructor', () => { it('Should not require init object', () => { expect(() => new OpenAPIHono()).not.toThrow() }) it('Should accept init object', () => { const getPath = () => '' const app = new OpenAPIHono({ getPath }) expect(app.getPath).toBe(getPath) }) it('Should accept a defaultHook', () => { type FakeEnv = { Variables: { fake: string }; Bindings: { other: number } } const app = new OpenAPIHono({ defaultHook: (_result, c) => { // Make sure we're passing context types through expectTypeOf(c).toMatchTypeOf>() }, }) expect(app.defaultHook).toBeDefined() }) }) describe('Basic - params', () => { const ParamsSchema = z.object({ id: z .string() .min(4) .openapi({ param: { name: 'id', in: 'path', }, example: '12345', }), }) const UserSchema = z .object({ id: z.string().openapi({ example: '12345', }), name: z.string().openapi({ example: 'John Doe', }), age: z.number().openapi({ example: 42, }), }) .openapi('User') const HeadersSchema = z.object({ // Header keys must be in lowercase authorization: z.string().openapi({ example: 'Bearer SECRET', }), }) const ErrorSchema = z .object({ ok: z.boolean().openapi({ example: false, }), }) .openapi('Error') const route = createRoute({ method: 'get', path: '/users/{id}', request: { params: ParamsSchema, headers: HeadersSchema, }, 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, 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/12345', { headers: { Authorization: 'Bearer TOKEN', }, }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ id: '12345', age: 20, name: 'Ultra-man', }) }) it('Should return 400 response with correct contents', async () => { const res = await app.request('/users/123') 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: 'string', example: '12345' }, 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', example: '12345', minLength: 4 }, required: true, name: 'id', in: 'path', }, { schema: { type: 'string', example: 'Bearer SECRET' }, required: true, name: 'authorization', in: 'header', }, ], 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 BooksSchema = z .object({ titles: z.array(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: BooksSchema, }, }, description: 'Get books', }, }, }) const app = new OpenAPIHono() app.openapi(route, (c) => { const { page } = c.req.valid('query') return c.jsonT({ titles: ['Good title'], 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({ titles: ['Good title'], page: 123, }) }) it('Should return 400 response with correct contents', async () => { const res = await app.request('/books') expect(res.status).toBe(400) }) }) describe('Header', () => { const HeaderSchema = z.object({ authorization: z.string(), 'x-request-id': z.string().uuid(), }) const PongSchema = z .object({ 'x-request-id': z.string().uuid(), authorization: z.string(), }) .openapi('Post') const route = createRoute({ method: 'get', path: '/pong', request: { headers: HeaderSchema, }, responses: { 200: { content: { 'application/json': { schema: PongSchema, }, }, description: 'Pong', }, }, }) const app = new OpenAPIHono() app.openapi(route, (c) => { const headerData = c.req.valid('header') return c.jsonT(headerData) }) it('Should return 200 response with correct contents', async () => { const res = await app.request('/pong', { headers: { 'x-request-id': '6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b', Authorization: 'Bearer helloworld', }, }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ 'x-request-id': '6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b', authorization: 'Bearer helloworld', }) }) it('Should return 400 response with correct contents', async () => { const res = await app.request('/pong', { headers: { 'x-request-id': 'invalid-strings', Authorization: 'Bearer helloworld', }, }) expect(res.status).toBe(400) }) }) describe('Cookie', () => { const CookieSchema = z.object({ debug: z.enum(['0', '1']), }) const UserSchema = z .object({ name: z.string(), debug: z.enum(['0', '1']), }) .openapi('User') const route = createRoute({ method: 'get', path: '/api/user', request: { cookies: CookieSchema, }, responses: { 200: { content: { 'application/json': { schema: UserSchema, }, }, description: 'Get a user', }, }, }) const app = new OpenAPIHono() app.openapi(route, (c) => { const { debug } = c.req.valid('cookie') return c.jsonT({ name: 'foo', debug, }) }) it('Should return 200 response with correct contents', async () => { const res = await app.request('/api/user', { headers: { Cookie: 'debug=1', }, }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ name: 'foo', debug: '1', }) }) it('Should return 400 response with correct contents', async () => { const res = await app.request('/api/user', { headers: { Cookie: 'debug=2', }, }) expect(res.status).toBe(400) }) }) describe('JSON', () => { const RequestSchema = z.object({ id: z.number().openapi({}), title: z.string().openapi({}), }) const PostSchema = 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: PostSchema, }, }, description: 'Post a post', }, }, }) 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', }, body: JSON.stringify({}), }) 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 PostSchema = 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: PostSchema, }, }, description: 'Post a post', }, }, }) 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) }) }) describe('Input types', () => { const ParamsSchema = z.object({ id: z.string().transform(Number).openapi({ param: { name: 'id', in: 'path', }, example: 123, }), }) const QuerySchema = z.object({ age: z.string().transform(Number).openapi({ param: { name: 'age', in: 'query', }, example: 42 }), }) const BodySchema = z.object({ sex: z.enum(['male', 'female']).openapi({}) }).openapi('User') const UserSchema = z .object({ id: z.number().openapi({ example: 123, }), name: z.string().openapi({ example: 'John Doe', }), age: z.number().openapi({ example: 42, }), sex: z.enum(['male', 'female']).openapi({ example: 'male', }) }) .openapi('User') const route = createRoute({ method: 'patch', path: '/users/{id}', request: { params: ParamsSchema, query: QuerySchema, body: { content: { 'application/json': { schema: BodySchema, }, }, }, }, responses: { 200: { content: { 'application/json': { schema: UserSchema, }, }, description: 'Update a user', }, }, }) const app = new OpenAPIHono() app.openapi(route, (c) => { const { id } = c.req.valid('param') const { age } = c.req.valid('query') const { sex } = c.req.valid('json') return c.jsonT({ id, age, sex, name: 'Ultra-man', }) }) it('Should return 200 response with correct typed contents', async () => { const res = await app.request('/users/123?age=42', { method: 'PATCH', body: JSON.stringify({ sex: 'male' }), headers: { 'Content-Type': 'application/json', }, }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ id: 123, age: 42, sex: 'male', name: 'Ultra-man' }) }) // @ts-expect-error it should throw an error if the types are wrong app.openapi(route, (c) => { return c.jsonT({ id: '123', // should be number message: 'Success', }) }) }) describe('Routers', () => { const RequestSchema = z.object({ id: z.number().openapi({}), }) const PostSchema = z .object({ id: z.number().openapi({}), }) .openapi('Post') const route = createRoute({ method: 'post', path: '/posts', request: { body: { content: { 'application/json': { schema: RequestSchema, }, }, }, }, responses: { 200: { content: { 'application/json': { schema: PostSchema, }, }, description: 'Post a post', }, }, }) it('Should include definitions from nested routers', () => { const router = new OpenAPIHono().openapi(route, (ctx) => { return ctx.jsonT({ id: 123 }) }) router.openAPIRegistry.register('Id', z.number()) router.openAPIRegistry.registerParameter( 'Key', z.number().openapi({ param: { in: 'path' }, }) ) router.openAPIRegistry.registerWebhook({ method: 'post', path: '/postback', responses: { 200: { description: 'Receives a post back', }, }, }) const app = new OpenAPIHono().route('/api', router) const json = app.getOpenAPI31Document({ openapi: '3.1.0', info: { title: 'My API', version: '1.0.0', }, }) expect(json.components?.schemas).toHaveProperty('Id') expect(json.components?.schemas).toHaveProperty('Post') expect(json.components?.parameters).toHaveProperty('Key') expect(json.paths).toHaveProperty('/api/posts') expect(json.webhooks).toHaveProperty('/api/postback') }) }) describe('Multi params', () => { const ParamsSchema = z.object({ id: z.string(), tagName: z.string(), }) const route = createRoute({ method: 'get', path: '/users/{id}/tags/{tagName}', request: { params: ParamsSchema, }, responses: { 200: { // eslint-disable-next-line quotes description: "Get the user's tag", }, }, }) const app = new OpenAPIHono() app.openapi(route, (c) => { const { id, tagName } = c.req.valid('param') return c.jsonT({ id, tagName, }) }) it('Should return 200 response with correct contents', async () => { const res = await app.request('/users/123/tags/baseball') expect(res.status).toBe(200) expect(await res.json()).toEqual({ id: '123', tagName: 'baseball', }) }) }) describe('basePath()', () => { const route = createRoute({ method: 'get', path: '/message', responses: { 200: { description: 'Get message', }, }, }) const app = new OpenAPIHono().basePath('/api') app.openapi(route, (c) => c.jsonT({ message: 'Hello' })) app.doc('/doc', { openapi: '3.0.0', info: { version: '1.0.0', title: 'My API', }, }) it('Should return 200 response without type errors - /api/message', async () => { const res = await app.request('/api/message') expect(res.status).toBe(200) expect(await res.json()).toEqual({ message: 'Hello' }) }) it('Should return 200 response - /api/doc', async () => { const res = await app.request('/api/doc') expect(res.status).toBe(200) }) }) describe('With hc', () => { describe('Multiple routes', () => { const app = new OpenAPIHono() const createPostRoute = createRoute({ method: 'post', path: '/posts', operationId: 'createPost', responses: { 200: { description: 'A post', }, }, }) const createBookRoute = createRoute({ method: 'post', path: '/books', operationId: 'createBook', responses: { 200: { description: 'A book', }, }, }) const routes = app .openapi(createPostRoute, (c) => { return c.jsonT(0) }) .openapi(createBookRoute, (c) => { return c.jsonT(0) }) const client = hc('http://localhost/') it('Should return correct URL without type errors', () => { expect(client.posts.$url().pathname).toBe('/posts') expect(client.books.$url().pathname).toBe('/books') }) }) describe('defaultHook', () => { const app = new OpenAPIHono({ defaultHook: (result, c) => { if (!result.success) { const res = c.jsonT( { ok: false, source: 'defaultHook', }, 400 ) return res } }, }) const TitleSchema = z.object({ title: z.string().openapi({}), }) function errorResponse() { return { 400: { content: { 'application/json': { schema: z.object({ ok: z.boolean().openapi({}), source: z.enum(['routeHook', 'defaultHook']).openapi({}), }), }, }, description: 'A validation error', }, } satisfies RouteConfig['responses'] } const createPostRoute = createRoute({ method: 'post', path: '/posts', operationId: 'createPost', request: { body: { content: { 'application/json': { schema: TitleSchema, }, }, }, }, responses: { 200: { content: { 'application/json': { schema: TitleSchema, }, }, description: 'A post', }, ...errorResponse(), }, }) const createBookRoute = createRoute({ method: 'post', path: '/books', operationId: 'createBook', request: { body: { content: { 'application/json': { schema: TitleSchema, }, }, }, }, responses: { 200: { content: { 'application/json': { schema: TitleSchema, }, }, description: 'A book', }, ...errorResponse(), }, }) // use the defaultHook app.openapi(createPostRoute, (c) => { const { title } = c.req.valid('json') return c.jsonT({ title }) }) // use a routeHook app.openapi( createBookRoute, (c) => { const { title } = c.req.valid('json') return c.jsonT({ title }) }, (result, c) => { if (!result.success) { const res = c.jsonT( { ok: false, source: 'routeHook' as const, }, 400 ) return res } } ) it('uses the defaultHook', async () => { const res = await app.request('/posts', { method: 'POST', body: JSON.stringify({ bad: 'property' }), headers: { 'Content-Type': 'application/json', }, }) expect(res.status).toBe(400) expect(await res.json()).toEqual({ ok: false, source: 'defaultHook', }) }) it('it uses the route hook instead of the defaultHook', async () => { const res = await app.request('/books', { method: 'POST', body: JSON.stringify({ bad: 'property' }), headers: { 'Content-Type': 'application/json', }, }) expect(res.status).toBe(400) expect(await res.json()).toEqual({ ok: false, source: 'routeHook', }) }) }) }) describe('It allows the response type to be Response', () => { const app = new OpenAPIHono() app.openapi( createRoute({ method: 'get', path: '/no-content', responses: { 204: { description: 'No Content', }, }, }), (c) => { return c.body(null, 204) } ) it('should return a 204 response without a type error', async () => { const res = await app.request('/no-content') expect(res.status).toBe(204) expect(res.body).toBe(null) }) }) describe('Path normalization', () => { const createRootApp = () => { const app = new OpenAPIHono() app.doc('/doc', { openapi: '3.0.0', info: { version: '1.0.0', title: 'My API', }, }) return app } const generateRoute = (path: string) => { return createRoute({ path, method: 'get', responses: { 204: { description: 'No Content', }, }, }) } const handler = (c: Context) => c.body(null, 204) describe('Duplicate slashes in the root path', () => { const app = createRootApp() const childApp = new OpenAPIHono() childApp.openapi(generateRoute('/child'), handler) app.route('/', childApp) it('Should remove duplicate slashes', 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: {}, parameters: {}, }, paths: { '/child': { get: { responses: { 204: { description: 'No Content', }, }, }, }, }, }) }) }) describe('Duplicate slashes in the child path', () => { const app = createRootApp() const childApp = new OpenAPIHono() const grandchildApp = new OpenAPIHono() grandchildApp.openapi(generateRoute('/granchild'), handler) childApp.route('/', grandchildApp) app.route('/api', childApp) it('Should remove duplicate slashes', 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: {}, parameters: {}, }, paths: { '/api/granchild': { get: { responses: { 204: { description: 'No Content', }, }, }, }, }, }) }) }) describe('Duplicate slashes in the trailing path', () => { const app = createRootApp() const childApp = new OpenAPIHono() const grandchildApp = new OpenAPIHono() grandchildApp.openapi(generateRoute('/'), handler) childApp.route('/', grandchildApp) app.route('/api', childApp) it('Should remove duplicate slashes', 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: {}, parameters: {}, }, paths: { '/api': { get: { responses: { 204: { description: 'No Content', }, }, }, }, }, }) }) }) }) describe('Context can be accessible in the doc route', () => { const app = new OpenAPIHono<{ Bindings: { TITLE: string } }>() app.openapi( createRoute({ method: 'get', path: '/no-content', responses: { 204: { description: 'No Content', }, }, }), (c) => { return c.body(null, 204) } ) app.doc('/doc', (context) => ({ openapi: '3.0.0', info: { version: '1.0.0', title: context.env.TITLE, }, })) it('Should return with the title set as specified in env', async () => { const res = await app.request('/doc', undefined, { TITLE: 'My API' }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ openapi: '3.0.0', info: { version: '1.0.0', title: 'My API', }, components: { schemas: {}, parameters: {}, }, paths: { '/no-content': { get: { responses: { 204: { description: 'No Content', }, }, }, }, }, }) }) })