diff --git a/src/index.ts b/src/index.ts index 13b6a6d..2acc239 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -import { Hono } from "hono"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { Scalar } from "@scalar/hono-api-reference"; import { logger } from "hono/logger"; import { prettyJSON } from "hono/pretty-json"; import { config } from "./config"; @@ -7,7 +8,31 @@ import { authRoutes } from "./routes/auth"; import { configRoutes } from "./routes/config"; import { dashboardRoutes } from "./routes/dashboard"; -const app = new Hono({ strict: false }); +const app = new OpenAPIHono({ strict: false }); + +// OpenAPI specs +app.doc("/doc", { + openapi: "3.0.0", + info: { + version: "1.0.0", + title: "toknd — Auth Broker API", + description: "Centralized token management and OAuth2 broker service.", + }, +}); + +app.openAPIRegistry.registerComponent("securitySchemes", "API_KEY", { + type: "http", + scheme: "bearer", +}); + +// Scalar API +app.get( + "/ui", + Scalar({ + theme: "solarized", + url: "/doc", + }), +); app.use("*", logger()); app.use("*", prettyJSON()); diff --git a/src/routes/api.ts b/src/routes/api.ts index 698f91b..3aa28dd 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -1,31 +1,122 @@ -import { Hono } from "hono"; +import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; import { ConfigManager } from "../core/ConfigManager"; import { redis } from "../core/RedisClient"; import { TokenManager } from "../core/TokenManager"; import { authMiddleware } from "../middleware/auth"; import { GenericProvider } from "../providers/GenericProvider"; -const apiRoutes = new Hono({ strict: false }); +const apiRoutes = new OpenAPIHono(); +// Schemas +const StatusResponseSchema = z + .record( + z.string(), + z.object({ + accessToken: z.string().nullable(), + refreshToken: z.string().nullable(), + lastUpdated: z.string().nullable(), + }), + ) + .openapi("StatusResponse"); + +const TokenResponseSchema = z + .object({ + access_token: z.string(), + }) + .openapi("TokenResponse"); + +const RefreshResponseSchema = z + .object({ + success: z.boolean(), + status: z.object({ + accessToken: z.string().nullable(), + refreshToken: z.string().nullable(), + lastUpdated: z.string().nullable(), + }), + }) + .openapi("RefreshResponse"); + +const ErrorSchema = z + .object({ + error: z.string(), + }) + .openapi("Error"); + +// Routes +const statusRoute = createRoute({ + method: "get", + path: "/status", + security: [{ API_KEY: [] }], + responses: { + 200: { + content: { "application/json": { schema: StatusResponseSchema } }, + description: "Retrieve the status of all configured providers", + }, + }, +}); + +const tokenRoute = createRoute({ + method: "get", + path: "/token/{provider}", + security: [{ API_KEY: [] }], + request: { + params: z.object({ + provider: z.string().openapi({ example: "trakt" }), + }), + }, + responses: { + 200: { + content: { "application/json": { schema: TokenResponseSchema } }, + description: "Retrieve a valid access token for a specific provider", + }, + 404: { + content: { "application/json": { schema: ErrorSchema } }, + description: "Provider not configured or tokens not found", + }, + }, +}); + +const refreshRoute = createRoute({ + method: "post", + path: "/refresh/{provider}", + security: [{ API_KEY: [] }], + request: { + params: z.object({ + provider: z.string().openapi({ example: "trakt" }), + }), + }, + responses: { + 200: { + content: { "application/json": { schema: RefreshResponseSchema } }, + description: "Manually force a token refresh for a specific provider", + }, + 404: { + content: { "application/json": { schema: ErrorSchema } }, + description: "Provider not configured", + }, + }, +}); + +// Implementations apiRoutes.use("*", authMiddleware); -apiRoutes.get("/status", async (c) => { +apiRoutes.openapi(statusRoute, async (c) => { const configManager = new ConfigManager(redis); const providers = await configManager.getAllProviders(); - const status: Record = {}; + const status: z.infer = {}; for (const provider of Object.keys(providers)) { const accessToken = await redis.get(`provider:${provider}:access_token`); const refreshToken = await redis.get(`provider:${provider}:refresh_token`); const lastUpdated = await redis.get(`provider:${provider}:last_updated`); - status[provider] = { accessToken, refreshToken, lastUpdated } as any; + status[provider] = { accessToken, refreshToken, lastUpdated }; } - return c.json(status); + return c.json(status, 200); }); -apiRoutes.get("/token/:provider", async (c) => { - const providerName = c.req.param("provider"); +apiRoutes.openapi(tokenRoute, async (c) => { + const providerName = c.req.valid("param").provider; const configManager = new ConfigManager(redis); const providerConfig = await configManager.getProviderConfig(providerName); @@ -40,11 +131,11 @@ apiRoutes.get("/token/:provider", async (c) => { if (!accessToken) { return c.json({ error: "No tokens found for provider" }, 404); } - return c.json({ access_token: accessToken }); + return c.json({ access_token: accessToken }, 200); }); -apiRoutes.post("/refresh/:provider", async (c) => { - const providerName = c.req.param("provider"); +apiRoutes.openapi(refreshRoute, async (c) => { + const providerName = c.req.valid("param").provider; const configManager = new ConfigManager(redis); const providerConfig = await configManager.getProviderConfig(providerName); @@ -60,10 +151,13 @@ apiRoutes.post("/refresh/:provider", async (c) => { const refreshToken = await redis.get(`provider:${providerName}:refresh_token`); const lastUpdated = await redis.get(`provider:${providerName}:last_updated`); - return c.json({ - success: true, - status: { accessToken, refreshToken, lastUpdated }, - }); + return c.json( + { + success: true, + status: { accessToken, refreshToken, lastUpdated }, + }, + 200, + ); }); export { apiRoutes };