Open24

Hono に入門したい

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

このスクラップについて

Hono に入門したい衝動を抑えきれないので Getting Started を読んで入門する過程を記録していく。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

プロジェクト作成

コマンド
npm create hono@latest hello-hono
コンソール出力
create-hono version 0.12.0
✔ Using target directory … hello-hono
? Which template do you want to use? cloudflare-workers
✔ Cloning the template
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd hello-hono
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

JSON 出力

src/index.ts
import { Hono } from "hono";

const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/api/hello", (c) => {
  return c.json({
    ok: true,
    message: "Hello Hono!",
  });
});

export default app;
動作確認
curl http://localhost:8787/api/hello
コンソール出力
{"ok":true,"message":"Hello Hono!"}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Request and Response

https://hono.dev/docs/getting-started/basic#request-and-response

src/index.ts
import { Hono } from "hono";

const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/api/hello", (c) => {
  return c.json({
    ok: true,
    message: "Hello Hono!",
  });
});

app.get("/posts/:id", (c) => {
  const page = c.req.query("page");
  const id = c.req.param("id");
  c.header("X-Message", "Hi!");
  return c.text(`You want see ${page} of ${id}`);
});

app.post("/posts", (c) => c.text("Created!", 201));
app.delete("/posts/:id", (c) => c.text(`${c.req.param("id")} is deleted!`));

export default app;
動作確認
curl -v http://localhost:8787/posts/POST_ID
コンソール出力
*   Trying 127.0.0.1:8787...
* Connected to localhost (127.0.0.1) port 8787 (#0)
> GET /posts/POST_ID HTTP/1.1
> Host: localhost:8787
> User-Agent: curl/8.1.2
> Accept: */*
> 
< HTTP/1.1 200 OK
< Content-Length: 33
< Content-Type: text/plain; charset=UTF-8
< x-message: Hi!
< 
* Connection #0 to host localhost left intact
You want see undefined of POST_ID
動作確認
curl -X POST -v http://localhost:8787/posts
コンソール出力
*   Trying 127.0.0.1:8787...
* Connected to localhost (127.0.0.1) port 8787 (#0)
> POST /posts HTTP/1.1
> Host: localhost:8787
> User-Agent: curl/8.1.2
> Accept: */*
> 
< HTTP/1.1 201 Created
< Content-Length: 8
< Content-Type: text/plain; charset=UTF-8
< 
* Connection #0 to host localhost left intact
Created!
動作確認
curl -X DELETE -v http://localhost:8787/posts/POST_ID
コンソール出力
*   Trying 127.0.0.1:8787...
* Connected to localhost (127.0.0.1) port 8787 (#0)
> DELETE /posts/POST_ID HTTP/1.1
> Host: localhost:8787
> User-Agent: curl/8.1.2
> Accept: */*
> 
< HTTP/1.1 200 OK
< Content-Length: 19
< Content-Type: text/plain;charset=UTF-8
< 
* Connection #0 to host localhost left intact
POST_ID is deleted!
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

プロジェクト作成

コマンド
npm create hono@latest hono-openapi
コンソール出力
create-hono version 0.12.0
✔ Using target directory … hono-openapi
? Which template do you want to use? nodejs
✔ Cloning the template
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd hono-openapi
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

コーディング

src/index.ts
import { serve } from "@hono/node-server";
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";

const ParamsSchema = z.object({
  id: z
    .string()
    .min(3)
    .openapi({
      param: {
        name: "id",
        in: "path",
      },
      example: "121212",
    }),
});

const UserSchema = z
  .object({
    id: z.string().openapi({
      example: "123",
    }),
    name: z.string().openapi({
      example: "John Doe",
    }),
    age: z.number().openapi({
      example: 42,
    }),
  })
  .openapi("User");

const ErrorSchema = z.object({
  code: z.number().openapi({
    example: 400,
  }),
  message: z.string().openapi({
    example: "Bad Request",
  }),
});

const route = createRoute({
  method: "get",
  path: "/users/{id}",
  request: {
    params: ParamsSchema,
  },
  responses: {
    200: {
      content: {
        "application/json": {
          schema: UserSchema,
        },
      },
      description: "Retrieve the user",
    },
    400: {
      content: {
        "application/json": {
          schema: ErrorSchema,
        },
      },
      description: "Returns an error",
    },
  },
});

const app = new OpenAPIHono();

app.openapi(
  route,
  (c) => {
    const { id } = c.req.valid("param");

    return c.json(
      {
        id,
        age: 20,
        name: "Ultra-man",
      },
      200
    );
  },
  (result, c) => {
    if (!result.success) {
      return c.json(
        {
          code: 400,
          message: "Validation Error",
        },
        400
      );
    }
  }
);

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

const port = 3000;
console.log(`Server is running on port ${port}`);

serve({
  fetch: app.fetch,
  port,
});
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

API ドキュメント

コマンド
curl http://localhost:3000/doc | jq
コンソール出力
{
  "openapi": "3.0.0",
  "info": {
    "version": "1.0.0",
    "title": "My API"
  },
  "components": {
    "schemas": {
      "User": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "123"
          },
          "name": {
            "type": "string",
            "example": "John Doe"
          },
          "age": {
            "type": "number",
            "example": 42
          }
        },
        "required": [
          "id",
          "name",
          "age"
        ]
      }
    },
    "parameters": {}
  },
  "paths": {
    "/users/{id}": {
      "get": {
        "parameters": [
          {
            "schema": {
              "type": "string",
              "minLength": 3,
              "example": "121212"
            },
            "required": true,
            "name": "id",
            "in": "path"
          }
        ],
        "responses": {
          "200": {
            "description": "Retrieve the user",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "400": {
            "description": "Returns an error",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "code": {
                      "type": "number",
                      "example": 400
                    },
                    "message": {
                      "type": "string",
                      "example": "Bad Request"
                    }
                  },
                  "required": [
                    "code",
                    "message"
                  ]
                }
              }
            }
          }
        }
      }
    }
  }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Remix プロジェクト作成

https://remix.run/docs/en/main/start/quickstart

コマンド
npx create-remix@latest hello-openapi-ts
コンソール出力
 remix   v2.11.2 💿 Let's build a better website...
      ◼  Directory: Using hello-openapi-ts as project directory

      ◼  Using basic template See https://remix.run/guides/templates for more
      ✔  Template copied

   git   Initialize a new git repository?
         Yes

  deps   Install dependencies with npm?
         Yes

      ✔  Dependencies installed

      ✔  Git initialized

  done   That's it!

         Enter your project directory using cd ./hello-openapi-ts
         Check out README.md for development and deploy instructions.

         Join the community at https://rmx.as/discord
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

OpenAPI TypeScript セットアップ

コマンド
npm i -D openapi-typescript typescript

tsconfig.json の内容を確認する。

tsconfig.json
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "noUncheckedIndexedAccess": true
  }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

型ファイル生成

コマンド
npx openapi-typescript http://localhost:3000/doc -o app/schema.d.ts
app/schema.d.ts
/**
 * This file was auto-generated by openapi-typescript.
 * Do not make direct changes to the file.
 */

export interface paths {
    "/users/{id}": {
        parameters: {
            query?: never;
            header?: never;
            path?: never;
            cookie?: never;
        };
        get: {
            parameters: {
                query?: never;
                header?: never;
                path: {
                    id: string;
                };
                cookie?: never;
            };
            requestBody?: never;
            responses: {
                /** @description Retrieve the user */
                200: {
                    headers: {
                        [name: string]: unknown;
                    };
                    content: {
                        "application/json": components["schemas"]["User"];
                    };
                };
                /** @description Returns an error */
                400: {
                    headers: {
                        [name: string]: unknown;
                    };
                    content: {
                        "application/json": {
                            /** @example 400 */
                            code: number;
                            /** @example Bad Request */
                            message: string;
                        };
                    };
                };
            };
        };
        put?: never;
        post?: never;
        delete?: never;
        options?: never;
        head?: never;
        patch?: never;
        trace?: never;
    };
}
export type webhooks = Record<string, never>;
export interface components {
    schemas: {
        User: {
            /** @example 123 */
            id: string;
            /** @example John Doe */
            name: string;
            /** @example 42 */
            age: number;
        };
    };
    responses: never;
    parameters: never;
    requestBodies: never;
    headers: never;
    pathItems: never;
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;

いいね。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

コーディング

app/routes/_index.tsx
import type { MetaFunction } from "@remix-run/node";
import createClient from "openapi-fetch";
import { useEffect } from "react";
import { paths } from "~/schema";

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

const client = createClient<paths>({ baseUrl: "http://localhost:3000/" });

export default function Index() {
  useEffect(() => {
    client
      .GET("/users/{id}", {
        params: {
          path: {
            id: "123",
          },
        },
      })
      .then(({ data, error }) => {
        if (data) {
          console.info({ data });
        } else if (error) {
          console.info({ error });
        }
      });
  }, []);

  return (
    <main className="container mx-auto">
      <h1 className="my-4 text-2xl">Hello OpenAPI TypeScript</h1>
    </main>
  );
}


エディタの補完がバチバチに効くのが嬉しい。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

結局 openapi-react-query を使う

app/root.tsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";
import "./tailwind.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <QueryClientProvider client={queryClient}>
          {children}
        </QueryClientProvider>
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}
app/routes/_index.tsx
import type { MetaFunction } from "@remix-run/node";
import createFetchClient from "openapi-fetch";
import createClient from "openapi-react-query";
import { paths } from "~/schema";

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

const fetchClient = createFetchClient<paths>({
  baseUrl: "http://localhost:3000/",
});

const $api = createClient(fetchClient);

export default function Index() {
  const { data, error, isLoading } = $api.useQuery("get", "/users/{id}", {
    params: {
      path: {
        id: "123",
      },
    },
  });

  return (
    <main className="container mx-auto">
      <h1 className="my-4 text-2xl">Hello OpenAPI TypeScript</h1>
      {isLoading && <p className="mb-4">Loading...</p>}
      {data && (
        <dl className="mb-4">
          <dt>id</dt>
          <dd>{data.id}</dd>
          <dt>age</dt>
          <dd>{data.age}</dd>
          <dt>name</dt>
          <dd>{data.name}</dd>
        </dl>
      )}
      {error && <p role="alert">{error.message}</p>}
    </main>
  );
}


良い塩梅です。