feat: integrate scalar api reference #1

Merged
me merged 3 commits from feature/api-reference-integration into main 2026-05-11 11:18:39 +00:00
6 changed files with 307 additions and 56 deletions
+24
View File
@@ -5,7 +5,9 @@
"": {
"name": "auth-server",
"dependencies": {
"@hono/zod-openapi": "^1.4.0",
"@phosphor-icons/core": "^2.1.1",
"@scalar/hono-api-reference": "^0.10.14",
"hono": "^4.12.18",
"ioredis": "^5.10.1",
"zod": "^4.4.3",
@@ -20,6 +22,8 @@
},
},
"packages": {
"@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@8.5.0", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q=="],
"@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="],
@@ -38,10 +42,22 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="],
"@hono/zod-openapi": ["@hono/zod-openapi@1.4.0", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", "@hono/zod-validator": "^0.8.0", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.10.0", "zod": "^4.0.0" } }, "sha512-AFchqR1N/NxfI4hUOSGI2/g8zLROxA1OE7Oh5JJFlTaGxhrdRyH+93gd0tIBpb0z8s9r8hUoNnaOBfHbdb4NMw=="],
"@hono/zod-validator": ["@hono/zod-validator@0.8.0", "", { "peerDependencies": { "hono": ">=4.10.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-5uS4S1/LKtZQYvD4BtpPUFkOv8d1wNxHHrChm26buMiEYc1FrHWvDUaKVBwkiVtvSExHSpLGDvcnpI2Copyj9w=="],
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
"@phosphor-icons/core": ["@phosphor-icons/core@2.1.1", "", {}, "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ=="],
"@scalar/client-side-rendering": ["@scalar/client-side-rendering@0.1.7", "", { "dependencies": { "@scalar/types": "0.9.6" } }, "sha512-IDzjKF93jrOljlvKBsLHXT1FPWgz56jFrMPC+iLihREp1qH8wF92mG8Zpakw8cURkEuw5WijRk0xNBP2moGyuw=="],
"@scalar/helpers": ["@scalar/helpers@0.6.0", "", {}, "sha512-pfSamAgBxqFeE8IpEG6uGkHlnPhY1CLeOTttV9+vKQbrBk5b7vvyTsUXv0Hz4kNU1TFrxcTTPE+Akn5S+jlTtQ=="],
"@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.10.14", "", { "dependencies": { "@scalar/client-side-rendering": "0.1.7" }, "peerDependencies": { "hono": "^4.12.5" } }, "sha512-LCIT4ul3c4MyD7shhxsWcvvOABt0fEHNQID2n+2TPeItc/MR2qCjjp/QfqD+JoQ7zbc0nnzh1kwRR06MVBmnUA=="],
"@scalar/types": ["@scalar/types@0.9.6", "", { "dependencies": { "@scalar/helpers": "0.6.0", "nanoid": "^5.1.6", "type-fest": "^5.3.1", "zod": "^4.3.5" } }, "sha512-UaCQQcscFTJdxZREE8KhUdSJgaDlc44TZbmWcZffs4m1hzqOvEI7lEBS13iBpLq7/cxUXFgyJdecywvNqJ0PkA=="],
"@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
@@ -90,8 +106,12 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="],
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"openapi3-ts": ["openapi3-ts@4.5.0", "", { "dependencies": { "yaml": "^2.8.0" } }, "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
@@ -114,8 +134,12 @@
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
"type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+2
View File
@@ -10,7 +10,9 @@
"prepare": "husky"
},
"dependencies": {
"@hono/zod-openapi": "^1.4.0",
"@phosphor-icons/core": "^2.1.1",
"@scalar/hono-api-reference": "^0.10.14",
"hono": "^4.12.18",
"ioredis": "^5.10.1",
"zod": "^4.4.3"
+28 -2
View File
@@ -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,32 @@ 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 Reference
app.get(
"/api",
Scalar({
theme: "solarized",
url: "/doc",
}),
);
app.get("/docs", (c) => c.redirect("/api"));
app.use("*", logger());
app.use("*", prettyJSON());
+109 -15
View File
@@ -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<string, { accessToken: string | null; refreshToken: string | null }> = {};
const status: z.infer<typeof StatusResponseSchema> = {};
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 };
+65 -18
View File
@@ -1,15 +1,74 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
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 { GenericProvider } from "../providers/GenericProvider";
const authRoutes = new Hono({ strict: false });
const authRoutes = new OpenAPIHono();
authRoutes.get("/:provider/login", async (c) => {
const providerName = c.req.param("provider");
const AuthErrorResponse = z
.object({
error: z.string(),
message: z.string(),
})
.openapi("AuthError");
// Routes
const loginRoute = createRoute({
method: "get",
path: "/{provider}/login",
request: {
params: z.object({
provider: z.string().openapi({ example: "trakt" }),
}),
},
responses: {
302: {
description: "Redirect to the provider's OAuth2 authorization page",
},
404: {
content: { "application/json": { schema: AuthErrorResponse } },
description: "Provider not configured",
},
},
});
const callbackRoute = createRoute({
method: "get",
path: "/callback",
request: {
query: z.object({
state: z
.string()
.openapi({ description: "The provider name (passed as state during login)" }),
code: z.string().openapi({ description: "The authorization code from the provider" }),
}),
},
responses: {
200: {
description: "Success page indicating successful token exchange",
content: { "text/html": { schema: { type: "string" } } },
},
400: {
content: { "application/json": { schema: AuthErrorResponse } },
description: "Invalid request (missing state or code)",
},
404: {
content: { "application/json": { schema: AuthErrorResponse } },
description: "Provider configuration not found",
},
500: {
content: { "application/json": { schema: AuthErrorResponse } },
description: "Token exchange failure",
},
},
});
// Implementations
authRoutes.openapi(loginRoute, async (c) => {
const providerName = c.req.valid("param").provider;
const configManager = new ConfigManager(redis);
const providerConfig = await configManager.getProviderConfig(providerName);
@@ -24,26 +83,14 @@ authRoutes.get("/:provider/login", async (c) => {
}
const provider = new GenericProvider(providerName, providerConfig);
const url = new URL(c.req.url);
const redirectUri = providerConfig.redirectUri || `${url.origin}/auth/callback`;
return c.redirect(provider.getAuthUrl(providerName, redirectUri));
});
authRoutes.get("/callback", async (c) => {
const providerName = c.req.query("state");
const code = c.req.query("code");
if (!providerName || !code) {
return c.json(
{
error: "Invalid Request",
message: "Missing state (provider) or authorization code.",
},
400,
);
}
authRoutes.openapi(callbackRoute, async (c) => {
const { state: providerName, code } = c.req.valid("query");
const configManager = new ConfigManager(redis);
const providerConfig = await configManager.getProviderConfig(providerName);
+79 -21
View File
@@ -1,33 +1,91 @@
import { Hono } from "hono";
import { ConfigManager, ProviderConfigSchema } from "../core/ConfigManager";
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { ConfigManager } from "../core/ConfigManager";
import { redis } from "../core/RedisClient";
import { authMiddleware } from "../middleware/auth";
const configRoutes = new Hono({ strict: false });
const configRoutes = new OpenAPIHono();
configRoutes.use("*", authMiddleware);
// Schemas
const ProviderConfigSpec = z
.object({
authUrl: z.url().openapi({ example: "https://trakt.tv/oauth/authorize" }),
tokenUrl: z.url().openapi({ example: "https://api.trakt.tv/oauth/token" }),
clientId: z.string().openapi({ example: "your_client_id" }),
clientSecret: z.string().openapi({ example: "your_client_secret" }),
scope: z.string().openapi({ example: "public" }),
redirectUri: z.url().optional().openapi({ example: "http://localhost:3000/auth/callback" }),
})
.openapi("ProviderConfig");
configRoutes.get("/", async (c) => {
const configManager = new ConfigManager(redis);
const providers = await configManager.getAllProviders();
return c.json(providers);
const AllProvidersResponse = z
.record(z.string(), ProviderConfigSpec)
.openapi("AllProvidersResponse");
const SuccessMessage = z
.object({
message: z.string(),
})
.openapi("SuccessMessage");
const ErrorResponse = z
.object({
error: z.string(),
})
.openapi("ErrorResponse");
// Routes
const listConfigRoute = createRoute({
method: "get",
path: "/",
security: [{ API_KEY: [] }],
responses: {
200: {
content: { "application/json": { schema: AllProvidersResponse } },
description: "Retrieve all registered provider configurations",
},
},
});
configRoutes.post("/:provider", async (c) => {
const provider = c.req.param("provider");
const setConfigRoute = createRoute({
method: "post",
path: "/{provider}",
security: [{ API_KEY: [] }],
request: {
params: z.object({
provider: z.string().openapi({ example: "trakt" }),
}),
body: {
content: { "application/json": { schema: ProviderConfigSpec } },
},
},
responses: {
200: {
content: { "application/json": { schema: SuccessMessage } },
description: "Save or update a provider configuration",
},
400: {
content: { "application/json": { schema: ErrorResponse } },
description: "Invalid configuration data",
},
},
});
// Implementations
configRoutes.use("*", authMiddleware);
configRoutes.openapi(listConfigRoute, async (c) => {
const configManager = new ConfigManager(redis);
const providers = await configManager.getAllProviders();
return c.json(providers, 200);
});
configRoutes.openapi(setConfigRoute, async (c) => {
const provider = c.req.valid("param").provider;
const body = c.req.valid("json");
const configManager = new ConfigManager(redis);
try {
const body = await c.req.json();
const validatedConfig = ProviderConfigSchema.parse(body);
await configManager.setProviderConfig(provider, validatedConfig);
return c.json({ message: `Config for ${provider} saved successfully` });
} catch (error) {
if (error instanceof Error) {
return c.json({ error: error.message }, 400);
}
return c.json({ error: "Internal Server Error" }, 500);
}
await configManager.setProviderConfig(provider, body);
return c.json({ message: `Config for ${provider} saved successfully` }, 200);
});
export { configRoutes };