Skip to content

Commit 3237c94

Browse files
feat(api): add OpenAPI documentation with hono-openapi (#1954)
* feat(api): add OpenAPI documentation with hono-openapi - Add @hono/zod-openapi package to apps/api - Refactor API routes to use OpenAPIHono with proper schemas and tags - Add API tags: internal (health checks), app (authenticated endpoints), webhook (external callbacks) - Add /openapi.json endpoint to serve OpenAPI spec - Create OpenAPIDocs component for rendering API docs in apps/web - Add API Reference documentation page in developers section Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * feat(api): add build script to generate openapi.yaml - Create shared openapi-config.ts module for OpenAPI metadata - Add generate-openapi.ts script that extracts spec from route definitions - Add yaml dependency for YAML output - Add build script that runs generate-openapi - Add openapi.yaml to .gitignore (treated as build artifact) Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * fix(api): add proper type bindings for stripeEvent context variable - Create hono-bindings.ts with AppBindings type - Use OpenAPIHono<AppBindings> for proper type inference - Remove 'as never' cast on c.get('stripeEvent') Addresses CodeRabbit feedback on type safety Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * kind-of-rewrite --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 02d5673 commit 3237c94

File tree

9 files changed

+1366
-96
lines changed

9 files changed

+1366
-96
lines changed

apps/api/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
.env
22
.env.prod
3+
4+
# Generated OpenAPI spec (build artifact)
5+
openapi.yaml

apps/api/package.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77
"typecheck": "tsc --noEmit"
88
},
99
"dependencies": {
10-
"@sentry/bun": "^10.26.0",
11-
"@supabase/supabase-js": "^2.84.0",
10+
"@hono/zod-validator": "^0.7.5",
11+
"@scalar/hono-api-reference": "^0.5.184",
12+
"@sentry/bun": "^10.27.0",
13+
"@supabase/supabase-js": "^2.86.0",
1214
"@t3-oss/env-core": "^0.13.8",
13-
"hono": "^4.10.6",
15+
"hono": "^4.10.7",
16+
"hono-openapi": "^0.4.8",
1417
"stripe": "^19.3.1",
15-
"zod": "^4.1.13"
18+
"zod": "^4.1.13",
19+
"zod-openapi": "^5.4.5"
1620
},
1721
"devDependencies": {
1822
"@types/bun": "^1.3.3",

apps/api/src/hono-bindings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type Stripe from "stripe";
2+
3+
export type AppBindings = {
4+
Variables: {
5+
stripeEvent: Stripe.Event;
6+
};
7+
};

apps/api/src/index.ts

Lines changed: 67 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import "./instrument";
22

3+
import { apiReference } from "@scalar/hono-api-reference";
34
import { Hono } from "hono";
5+
import { openAPISpecs } from "hono-openapi";
46
import { bodyLimit } from "hono/body-limit";
57
import { websocket } from "hono/bun";
68
import { cors } from "hono/cors";
79
import { logger } from "hono/logger";
810

9-
import { syncBillingForStripeEvent } from "./billing";
1011
import { env } from "./env";
11-
import { listenSocketHandler } from "./listen";
12+
import type { AppBindings } from "./hono-bindings";
13+
import { API_TAGS, routes } from "./routes";
1214
import { verifyStripeWebhook } from "./stripe";
1315
import { requireSupabaseAuth } from "./supabase";
1416

15-
const app = new Hono();
17+
const app = new Hono<AppBindings>();
1618

1719
app.use(logger());
1820
app.use(bodyLimit({ maxSize: 1024 * 1024 * 5 }));
@@ -36,76 +38,75 @@ app.use("*", (c, next) => {
3638
return corsMiddleware(c, next);
3739
});
3840

39-
app.get("/health", (c) => c.json({ status: "ok" }));
40-
app.notFound((c) => c.text("not_found", 404));
41-
42-
app.post("/chat/completions", requireSupabaseAuth, async (c) => {
43-
const requestBody = await c.req.json<
44-
{
45-
model?: unknown;
46-
tools?: unknown;
47-
tool_choice?: unknown;
48-
} & Record<string, unknown>
49-
>();
41+
app.use("/chat/completions", requireSupabaseAuth);
42+
app.use("/webhook/stripe", verifyStripeWebhook);
5043

51-
const toolChoice = requestBody.tool_choice;
52-
const needsToolCalling =
53-
Array.isArray(requestBody.tools) &&
54-
!(typeof toolChoice === "string" && toolChoice === "none");
44+
if (env.NODE_ENV !== "development") {
45+
app.use("/listen", requireSupabaseAuth);
46+
}
5547

56-
// https://openrouter.ai/docs/features/exacto-variant
57-
const modelsToUse = needsToolCalling
58-
? [
59-
"moonshotai/kimi-k2-0905:exacto",
60-
"anthropic/claude-haiku-4.5",
61-
"openai/gpt-oss-120b:exacto",
62-
]
63-
: ["openai/chatgpt-4o-latest", "moonshotai/kimi-k2-0905"];
48+
app.route("/", routes);
6449

65-
const { model: _ignoredModel, ...bodyWithoutModel } = requestBody;
50+
app.notFound((c) => c.text("not_found", 404));
6651

67-
// https://openrouter.ai/docs/features/provider-routing#provider-sorting
68-
const response = await fetch(
69-
"https://openrouter.ai/api/v1/chat/completions",
70-
{
71-
method: "POST",
72-
headers: {
73-
"Content-Type": "application/json",
74-
Authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
52+
app.get(
53+
"/openapi.json",
54+
openAPISpecs(routes, {
55+
documentation: {
56+
openapi: "3.1.0",
57+
info: {
58+
title: "Hyprnote API",
59+
version: "1.0.0",
60+
description:
61+
"API for Hyprnote - AI-powered meeting notes application. APIs are categorized by tags: 'internal' for health checks and internal use, 'app' for endpoints used by the Hyprnote application (requires authentication), and 'webhook' for external service callbacks.",
7562
},
76-
body: JSON.stringify({
77-
...bodyWithoutModel,
78-
models: modelsToUse,
79-
provider: { sort: "latency" },
80-
}),
63+
tags: [
64+
{
65+
name: API_TAGS.INTERNAL,
66+
description: "Internal endpoints for health checks and monitoring",
67+
},
68+
{
69+
name: API_TAGS.APP,
70+
description:
71+
"Endpoints used by the Hyprnote application. Requires Supabase authentication.",
72+
},
73+
{
74+
name: API_TAGS.WEBHOOK,
75+
description: "Webhook endpoints for external service callbacks",
76+
},
77+
],
78+
components: {
79+
securitySchemes: {
80+
Bearer: {
81+
type: "http",
82+
scheme: "bearer",
83+
description: "Supabase JWT token",
84+
},
85+
},
86+
},
87+
servers: [
88+
{
89+
url: "https://api.hyprnote.com",
90+
description: "Production server",
91+
},
92+
{
93+
url: "http://localhost:4000",
94+
description: "Local development server",
95+
},
96+
],
8197
},
82-
);
83-
84-
return new Response(response.body, {
85-
status: response.status,
86-
headers: {
87-
"Content-Type":
88-
response.headers.get("Content-Type") ?? "application/json",
98+
}),
99+
);
100+
101+
app.get(
102+
"/docs",
103+
apiReference({
104+
theme: "saturn",
105+
spec: {
106+
url: "/openapi.json",
89107
},
90-
});
91-
});
92-
93-
app.post("/webhook/stripe", verifyStripeWebhook, async (c) => {
94-
try {
95-
await syncBillingForStripeEvent(c.var.stripeEvent);
96-
} catch (error) {
97-
console.error(error);
98-
return c.json({ error: "stripe_billing_sync_failed" }, 500);
99-
}
100-
101-
return c.json({ ok: true });
102-
});
103-
104-
if (env.NODE_ENV === "development") {
105-
app.get("/listen", listenSocketHandler);
106-
} else {
107-
app.get("/listen", requireSupabaseAuth, listenSocketHandler);
108-
}
108+
}),
109+
);
109110

110111
export default {
111112
port: env.PORT,

0 commit comments

Comments
 (0)