honojs / hono

Web framework built on Web Standards
https://hono.dev
MIT License
17.08k stars 475 forks source link

Can we generate an openapi schema from any existing hono app? #3069

Closed thenbe closed 1 week ago

thenbe commented 2 weeks ago

What is the feature you are proposing?

I'm using zod validator, just like the technique described here. Is there a way I can generate an openapi schema from this app?

Regarding Swagger UI and Zod OpenApi, is my assumption correct that they cannot generate an openapi schema for a hono app, unless that hono app uses Zod OpenApi to explicitly declare the shape of each endpoint using createRoute()?

Hono client does a really great job of inferring the request types, response types, status codes, etc when using zod-validator. However, I couldn't figure out how to make it generate an openapi schema or a swagger ui, despite all of the strong typing afforded to us by zod-validator. I'd just like to make sure that this not currently possible, and not due to a misunderstanding on my part.


The closest I got was this (using chanfana):

import { fromHono } from "chanfana";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { Hono } from "hono";
import { serve } from "@hono/node-server";

const _app = new Hono();
const app = fromHono(_app);

const route = app.get(
    "/hello",
    zValidator("query", z.object({ name: z.string() })),
    (c) => {
        const { name } = c.req.valid("query");
        if (name === "") {
            return c.json({ error_title: "Invalid name" }, 400);
        }
        return c.json({ message: `Hello! ${name}` }, 202);
    },
);

serve({ fetch: app.fetch, port: 3000 });

But it wasn't very intelligent as it did not make use of all the available type information. Here is what we get when we run it then call curl http://localhost:3000/openapi.json:

{
    "openapi": "3.1.0",
    "info": {
        "version": "1.0.0",
        "title": "OpenAPI",
    },
    "components": {
        "schemas": {},
        "parameters": {},
    },
    "paths": {
        "/hello": {
            "get": {
                "operationId": "get__hello",
                "responses": {
                    "200": {
                        "description": "Successful response.",
                    },
                },
            },
        },
    },
    "webhooks": {},
}

It incorrectly assumed our endpoint returned status of 200 (instead of 202 or 400). It also did not capture the response shape.

Now compare that with how Hono client make use of the rich static typing (focus on the final 3 lines):

typescript playground: link

2024-07-01-05-36-56

import { hc } from "hono/client";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { Hono } from "hono";

const app = new Hono();

// Server
const route = app.get(
    "/hello",
    zValidator("query", z.object({ name: z.string() })),
    (c) => {
        const { name } = c.req.valid("query");
        if (name === "") {
            return c.json({ error_title: "Invalid name" }, 400);
        }
        return c.json({ message: `Hello! ${name}` }, 202);
    },
);

// Client
const api = hc<typeof route>("");
const res = await api.hello.$get({ query: { name: "" } });
if (!res.ok) {
    const data = await res.json();
    console.log(data.message); // this line has a useful typescript error because hono client knows that `message` property can never exist here!
    console.log(data.error_title); // this is OK
}

I'm aware this is operating on the type-level (not runtime), but I was wondering if there are some type reflection tools that would make this feature request possible? Especially given that all the required type information is available.

yusukebe commented 2 weeks ago

Hi @thenbe

As far as I know, we can't reflect the type information of Hono RPC to a runtime function. As you know, Hono RPC is just for TypeScript typings. But to enable handling actual values like Swagger docs, I've made the Zod OpenAPI.

I'm not sure, but we may emit type definitions to a file and use it later, but it may not be possible.

thenbe commented 2 weeks ago

Hi @yusukebe

To confirm my understanding of how @hono/zod-openapi is intended to be used, I took the previous example from above, which was using @hono/zod-validator, and converted it to use @hono/zod-openapi instead.

import { serve } from '@hono/node-server';
-import { zValidator } from '@hono/zod-validator';
+import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';

-const app = new Hono();
+const app = new OpenAPIHono();

+const route_definition = createRoute({
+ method: 'get',
+ path: '/hello',
+ request: {
+   query: z.object({
+     name: z
+       .string()
+       .min(1)
+       .openapi({
+         param: {
+           name: 'name',
+           in: 'query',
+         },
+         example: 'Alice',
+       }),
+   }),
+ },
+ responses: {
+   202: {
+     description: 'Success',
+     content: {
+       'application/json': {
+         schema: z.object({
+           message: z.string().openapi({
+             example: 'Hello Name!',
+           }),
+         }),
+       },
+     },
+   },
+   400: {
+     description: 'Failure',
+     content: {
+       'application/json': {
+         schema: z.object({
+           error_title: z.string().openapi({
+             example: 'Bad input',
+           }),
+         }),
+       },
+     },
+   },
+ },
+});

const route = app
- .get(
- '/hello',
- zValidator('query', z.object({ name: z.string() })),
+ .openapi(
+   route_definition,
    (c) => {
      const { name } = c.req.valid('query');
      if (name === '') {
        return c.json({ error_title: 'Invalid name' }, 400);
      }
      return c.json({ message: `Hello! ${name}` }, 202);
    },
);

app.doc('/doc', {
  openapi: '3.0.0',
  info: {
    version: '1.0.0',
    title: 'My API',
  },
});

serve({ fetch: app.fetch, port: 3000 });

Then we can run curl http://localhost:3000/doc to get our openapi schema:

Schema

```json { "openapi": "3.0.0", "info": { "version": "1.0.0", "title": "My API", }, "components": { "schemas": {}, "parameters": {}, }, "paths": { "/hello": { "get": { "parameters": [ { "schema": { "type": "string", "minLength": 1, "example": "Alice", }, "required": true, "name": "name", "in": "query", }, ], "responses": { "202": { "description": "Success", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string", "example": "Hello Name!", }, }, "required": ["message"], }, }, }, }, "400": { "description": "Failure", "content": { "application/json": { "schema": { "type": "object", "properties": { "error_title": { "type": "string", "example": "Bad input", }, }, "required": ["error_title"], }, }, }, }, }, }, }, }, } ```

This is how @hono/zod-openapi is intended to be used, correct?

One nice thing about this is that we get a typescript error if the endpoint implementation does not match the createRoute() definition. This can't be seen in the documentation because it requires intellisense, but if we try returning a status code of 200 instead of 202 in the typescript playground we will encounter a typescript error.


It would be really nice if we can also have it infer the rest of the createRoute() definition, instead of having to write that definition by hand as it is very verbose and repetitive.

Nestia does something similar, where it constructs the openapi schema definition directly from typescript types. In this example, notice how you only need to declare x-monetary inside a typescript type. That is enough to have it show up in the generated schema. I'm not sure what sort of type reflection nestia uses, or whether a similar functionality would be feasible for hono. But since hono is already built on very powerful static typing, unlocking that functionality would yield significant results.

yusukebe commented 2 weeks ago

This is how @hono/zod-openapi is intended to be used, correct?

Yes, exactly!

This @hono/zod-openapi is based on the library https://github.com/asteasolutions/zod-to-openapi, which is great. I have to check the Nestia, but I think the API of @hono/zod-openapi is almost complete.

Meess commented 1 week ago

This seems to be a duplicate ticket of: https://github.com/honojs/hono/issues/2970

Looking at nestia/core SwaggerCustomizer, it seems to use typia. To setup typia you've to add a transformer to your tsconfig which allows to use validate the type against an object in runtime

// tsconfig.json
....
    "plugins": [
      {
        "transform": "typia/lib/transform"
      }
    ],
...

Then you can do this 🪄

import { validate } from 'typia'

type User =  {
    id: number
    name: string
    email: string
}

const userData = {
    id: 1,
    name: 'John Doe',
    email: 'john.doe@example.com',
}

const isValid = validate<User>(userData)
if (isValid) {
    console.log('Data is valid:', userData)
} else {
    console.log('Data is invalid')
}
// This will log: "Data is valid: { id: 1, name: 'John Doe', email: 'john.doe@example.com' }"

Which atm seems like black magic to me, but they definitely intercept the type and transform it to code which can be checked against. Haven't looked further into it, but they seem to be able to reflect the typing to be used in runtime functions (at least in some form in the typia 'validate' function).

Just for demonstration, if I compile the above code to javascript without the transformer it produces the following code:

import { validate } from 'typia';
// Sample data to validate and transform
const userData = {
    id: 1,
    name: 'John Doe',
    email: 'john.doe@example.com',
};
// Validate the data
const isValid = validate(userData);
if (isValid) {
    console.log('Data is valid:', userData);
}
else {
    console.log('Data is invalid');
}

And with the transformer it outputs the following javascript code:

import { validate } from 'typia';
// Sample data to validate and transform
const userData = {
    id: 1,
    name: 'John Doe',
    email: 'john.doe@example.com',
};
// Validate the data
const isValid = (input => {
    const errors = [];
    const __is = input => {
        return "object" === typeof input && null !== input && ("number" === typeof input.id && "string" === typeof input.name && "string" === typeof input.email);
    };
    if (false === __is(input)) {
        const $report = validate.report(errors);
        ((input, _path, _exceptionable = true) => {
            const $vo0 = (input, _path, _exceptionable = true) => ["number" === typeof input.id || $report(_exceptionable, {
                    path: _path + ".id",
                    expected: "number",
                    value: input.id
                }), "string" === typeof input.name || $report(_exceptionable, {
                    path: _path + ".name",
                    expected: "string",
                    value: input.name
                }), "string" === typeof input.email || $report(_exceptionable, {
                    path: _path + ".email",
                    expected: "string",
                    value: input.email
                })].every(flag => flag);
            return ("object" === typeof input && null !== input || $report(true, {
                path: _path + "",
                expected: "User",
                value: input
            })) && $vo0(input, _path + "", true) || $report(true, {
                path: _path + "",
                expected: "User",
                value: input
            });
        })(input, "$input", true);
    }
    const success = 0 === errors.length;
    return {
        success,
        errors,
        data: success ? input : undefined
    };
})(userData);
if (isValid) {
    console.log('Data is valid:', userData);
}
else {
    console.log('Data is invalid');
}
console.log('Data:', userData);

Definitely interesting to to look into, no idea how this works with more complex typing, as it definitely reflects the typings to runtime code. Although it's an optimisation to @hono/zod-openapi, would be fun for someone of the community to pick up as a side project for instance (I expect it's quite some work), but promising.

thenbe commented 1 week ago

Ahh I didn't see #2970, my bad. Closing as duplicate.