mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
feat(server): serve normalized OpenAPI spec at /api/v1/spec.json with shared schema components
Extract spec generation into `openapi-spec.ts` with a `generateOpenApiSpec` function that registers common `$ref` schemas (Title, Person, Episode, Season, etc.) to deduplicate inline definitions across the spec. Add a `/api/v1/spec.json` interceptor that returns the fully normalized document, and point the Scalar docs UI at it instead of the plugin's raw generator output.
Also strip oRPC's impossible `{ not: {} }` void placeholders from request/response bodies via `normalizeOpenApiSpec`, and fix the handler registration so both `/api/v1` and `/api/v1/*` routes are matched.
This commit is contained in:
@@ -5,7 +5,7 @@ import { registerJobScheduleProvider } from "@sofa/core/system-health";
|
||||
import { closeDatabase } from "@sofa/db/client";
|
||||
import { runMigrations } from "@sofa/db/migrate";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { Hono } from "hono";
|
||||
import { type Context, Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { cors } from "hono/cors";
|
||||
import { getJobSchedules, startJobs, stopJobs } from "./cron";
|
||||
@@ -84,13 +84,16 @@ app.all("/rpc/*", async (c) => {
|
||||
});
|
||||
|
||||
// oRPC OpenAPI handler + Scalar docs
|
||||
app.all("/api/v1/*", async (c) => {
|
||||
const handleOpenApi = async (c: Context) => {
|
||||
const { response } = await openApiHandler.handle(c.req.raw, {
|
||||
prefix: "/api/v1",
|
||||
context: { headers: c.req.raw.headers },
|
||||
});
|
||||
return response ?? c.text("Not found", 404);
|
||||
});
|
||||
};
|
||||
|
||||
app.all("/api/v1", handleOpenApi);
|
||||
app.all("/api/v1/*", handleOpenApi);
|
||||
|
||||
// ─── SPA static serving (production) ─────────────────────────
|
||||
|
||||
|
||||
@@ -2,9 +2,13 @@ import { SmartCoercionPlugin } from "@orpc/json-schema";
|
||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
|
||||
import { onError } from "@orpc/server";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { router } from "./router";
|
||||
import {
|
||||
generateOpenApiSpec,
|
||||
openApiTags,
|
||||
schemaConverters,
|
||||
} from "./openapi-spec";
|
||||
import { implementedRouter } from "./router";
|
||||
|
||||
const log = createLogger("openapi");
|
||||
|
||||
@@ -13,10 +17,15 @@ const sessionCookieName = isSecure
|
||||
? "__Secure-better-auth.session_token"
|
||||
: "better-auth.session_token";
|
||||
|
||||
// https://orpc.dev/docs/openapi/plugins/smart-coercion
|
||||
const schemaConverters = [new ZodToJsonSchemaConverter()];
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
export const openApiHandler = new OpenAPIHandler(router, {
|
||||
export const openApiHandler = new OpenAPIHandler(implementedRouter, {
|
||||
plugins: [
|
||||
new SmartCoercionPlugin({ schemaConverters }),
|
||||
new OpenAPIReferencePlugin({
|
||||
@@ -27,6 +36,7 @@ export const openApiHandler = new OpenAPIHandler(router, {
|
||||
version: process.env.APP_VERSION || "0.0.0",
|
||||
},
|
||||
servers: [{ url: "/api/v1" }],
|
||||
tags: [...openApiTags],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
session: {
|
||||
@@ -38,9 +48,69 @@ export const openApiHandler = new OpenAPIHandler(router, {
|
||||
},
|
||||
},
|
||||
},
|
||||
// Load the spec from /spec.json so the docs use the normalized document
|
||||
// rather than the plugin's raw generator output.
|
||||
renderDocsHtml: (specUrl, title, head, scriptUrl, config) => {
|
||||
const scalarConfig = {
|
||||
url: specUrl,
|
||||
...(typeof config === "object" && config !== null ? config : {}),
|
||||
};
|
||||
|
||||
return `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
${head}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" data-config="${escapeHtml(JSON.stringify(scalarConfig))}"></div>
|
||||
|
||||
<script src="${escapeHtml(scriptUrl)}"></script>
|
||||
|
||||
<script>
|
||||
Scalar.createApiReference('#app', JSON.parse(document.getElementById('app').dataset.config))
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
},
|
||||
}),
|
||||
],
|
||||
interceptors: [
|
||||
async (options) => {
|
||||
const requestPathname =
|
||||
options.request.url.pathname.replace(/\/$/, "") || "/";
|
||||
const prefix = options.prefix?.replace(/\/$/, "") || "";
|
||||
const specPath = `${prefix}/spec.json`.replace(/\/$/, "") || "/";
|
||||
|
||||
if (options.request.method !== "GET" || requestPathname !== specPath) {
|
||||
return options.next();
|
||||
}
|
||||
|
||||
const spec = await generateOpenApiSpec({
|
||||
title: "Sofa API",
|
||||
version: process.env.APP_VERSION || "0.0.0",
|
||||
servers: [{ url: prefix || "/api/v1" }],
|
||||
sessionCookieName,
|
||||
tags: [...openApiTags],
|
||||
});
|
||||
|
||||
return {
|
||||
matched: true,
|
||||
response: {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: new File([JSON.stringify(spec)], "spec.json", {
|
||||
type: "application/json",
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
onError((error) => {
|
||||
log.error("OpenAPI error", error);
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import {
|
||||
BackupSchema,
|
||||
CastMemberSchema,
|
||||
EpisodeSchema,
|
||||
IntegrationEventSchema,
|
||||
IntegrationSchema,
|
||||
JobSchema,
|
||||
PersonCreditSchema,
|
||||
PersonSchema,
|
||||
RecommendationItemSchema,
|
||||
ResolvedTitleSchema,
|
||||
SeasonSchema,
|
||||
SystemHealthSchema,
|
||||
TmdbBrowseItem,
|
||||
} from "@sofa/api/schemas";
|
||||
import { implementedRouter } from "./router";
|
||||
|
||||
export const schemaConverters = [new ZodToJsonSchemaConverter()];
|
||||
export const openApiTags = [
|
||||
{ name: "Titles", description: "Movie and TV show management" },
|
||||
{ name: "Episodes", description: "Episode watch tracking" },
|
||||
{ name: "Seasons", description: "Season watch tracking" },
|
||||
{ name: "People", description: "Cast and crew information" },
|
||||
{ name: "Dashboard", description: "User dashboard data" },
|
||||
{ name: "Explore", description: "Discover trending and popular content" },
|
||||
{ name: "Search", description: "Search for movies and TV shows" },
|
||||
{
|
||||
name: "Discover",
|
||||
description: "Advanced content discovery with filters",
|
||||
},
|
||||
{ name: "System", description: "Server status and configuration" },
|
||||
{ name: "Integrations", description: "Media server integrations" },
|
||||
{ name: "Admin", description: "Server administration" },
|
||||
{ name: "Account", description: "User account management" },
|
||||
] as const;
|
||||
|
||||
const generator = new OpenAPIGenerator({
|
||||
schemaConverters,
|
||||
});
|
||||
|
||||
const httpMethods = [
|
||||
"get",
|
||||
"put",
|
||||
"post",
|
||||
"delete",
|
||||
"options",
|
||||
"head",
|
||||
"patch",
|
||||
"trace",
|
||||
] as const;
|
||||
|
||||
type OpenApiSpec = Awaited<ReturnType<OpenAPIGenerator["generate"]>>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isEmptyRecord(value: unknown): value is Record<string, never> {
|
||||
return isRecord(value) && Object.keys(value).length === 0;
|
||||
}
|
||||
|
||||
function isImpossibleSchema(schema: unknown): boolean {
|
||||
return (
|
||||
isRecord(schema) &&
|
||||
Object.keys(schema).length === 1 &&
|
||||
"not" in schema &&
|
||||
isEmptyRecord(schema.not)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSchema(schema: unknown): unknown {
|
||||
if (!isRecord(schema)) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
if (isImpossibleSchema(schema)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = { ...schema };
|
||||
|
||||
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
|
||||
const branches = normalized[key];
|
||||
if (!Array.isArray(branches)) continue;
|
||||
|
||||
const nextBranches = branches.flatMap((branch) => {
|
||||
const nextBranch = normalizeSchema(branch);
|
||||
return nextBranch === undefined ? [] : [nextBranch];
|
||||
});
|
||||
|
||||
if (nextBranches.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
normalized[key] = nextBranches;
|
||||
}
|
||||
|
||||
if (isRecord(normalized.properties)) {
|
||||
const nextProperties = Object.fromEntries(
|
||||
Object.entries(normalized.properties).flatMap(
|
||||
([name, propertySchema]) => {
|
||||
const nextPropertySchema = normalizeSchema(propertySchema);
|
||||
return nextPropertySchema === undefined
|
||||
? []
|
||||
: [[name, nextPropertySchema]];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (Object.keys(nextProperties).length > 0) {
|
||||
normalized.properties = nextProperties;
|
||||
} else {
|
||||
delete normalized.properties;
|
||||
}
|
||||
|
||||
if (Array.isArray(normalized.required)) {
|
||||
const propertyNames = new Set(Object.keys(nextProperties));
|
||||
const nextRequired = normalized.required.filter(
|
||||
(name): name is string =>
|
||||
typeof name === "string" && propertyNames.has(name),
|
||||
);
|
||||
|
||||
if (nextRequired.length > 0) {
|
||||
normalized.required = nextRequired;
|
||||
} else {
|
||||
delete normalized.required;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("items" in normalized) {
|
||||
const nextItems = normalizeSchema(normalized.items);
|
||||
if (nextItems === undefined) {
|
||||
delete normalized.items;
|
||||
} else {
|
||||
normalized.items = nextItems;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecord(normalized.additionalProperties)) {
|
||||
const nextAdditionalProperties = normalizeSchema(
|
||||
normalized.additionalProperties,
|
||||
);
|
||||
|
||||
if (nextAdditionalProperties === undefined) {
|
||||
delete normalized.additionalProperties;
|
||||
} else {
|
||||
normalized.additionalProperties = nextAdditionalProperties;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeContent(
|
||||
content: Record<string, { schema?: unknown }> | undefined,
|
||||
): Record<string, { schema?: unknown }> | undefined {
|
||||
if (!content) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const [mediaType, mediaTypeObject] of Object.entries(content)) {
|
||||
const nextSchema = normalizeSchema(mediaTypeObject.schema);
|
||||
|
||||
if (nextSchema === undefined) {
|
||||
delete content[mediaType];
|
||||
continue;
|
||||
}
|
||||
|
||||
mediaTypeObject.schema = nextSchema;
|
||||
}
|
||||
|
||||
return Object.keys(content).length > 0 ? content : undefined;
|
||||
}
|
||||
|
||||
export function normalizeOpenApiSpec<T extends OpenApiSpec>(spec: T): T {
|
||||
for (const pathItem of Object.values(spec.paths ?? {})) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
for (const method of httpMethods) {
|
||||
const operation = pathItem[method];
|
||||
if (!operation) continue;
|
||||
|
||||
if (operation.requestBody && "content" in operation.requestBody) {
|
||||
const nextContent = normalizeContent(operation.requestBody.content);
|
||||
|
||||
if (nextContent) {
|
||||
operation.requestBody.content =
|
||||
nextContent as typeof operation.requestBody.content;
|
||||
} else {
|
||||
delete operation.requestBody;
|
||||
}
|
||||
}
|
||||
|
||||
for (const response of Object.values(operation.responses ?? {})) {
|
||||
if (!response || !("content" in response)) continue;
|
||||
|
||||
const nextContent = normalizeContent(response.content);
|
||||
|
||||
if (nextContent) {
|
||||
response.content = nextContent as typeof response.content;
|
||||
} else {
|
||||
delete response.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
export async function generateOpenApiSpec(options: {
|
||||
title: string;
|
||||
version: string;
|
||||
servers: Array<{ url: string }>;
|
||||
sessionCookieName: string;
|
||||
tags?: Array<{ name: string; description?: string }>;
|
||||
}): Promise<OpenApiSpec> {
|
||||
const spec = await generator.generate(implementedRouter, {
|
||||
info: {
|
||||
title: options.title,
|
||||
version: options.version,
|
||||
},
|
||||
servers: options.servers,
|
||||
tags: options.tags,
|
||||
commonSchemas: {
|
||||
Title: { schema: ResolvedTitleSchema },
|
||||
Person: { schema: PersonSchema },
|
||||
PersonCredit: { schema: PersonCreditSchema },
|
||||
Episode: { schema: EpisodeSchema },
|
||||
Season: { schema: SeasonSchema },
|
||||
CastMember: { schema: CastMemberSchema },
|
||||
BrowseItem: { schema: TmdbBrowseItem },
|
||||
Recommendation: { schema: RecommendationItemSchema },
|
||||
Integration: { schema: IntegrationSchema },
|
||||
IntegrationEvent: { schema: IntegrationEventSchema },
|
||||
Backup: { schema: BackupSchema },
|
||||
Job: { schema: JobSchema },
|
||||
SystemHealth: { schema: SystemHealthSchema },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
session: {
|
||||
type: "apiKey",
|
||||
name: options.sessionCookieName,
|
||||
in: "cookie",
|
||||
description: "Better Auth session cookie",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// oRPC represents void/undefined with an impossible schema placeholder.
|
||||
// OpenAPI has no "undefined" payload, so drop impossible request/response
|
||||
// bodies entirely while preserving real `null` schemas.
|
||||
return normalizeOpenApiSpec(spec);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import * as status from "./procedures/status";
|
||||
import * as system from "./procedures/system";
|
||||
import * as titles from "./procedures/titles";
|
||||
|
||||
export const router = os.router({
|
||||
export const implementedRouter = {
|
||||
titles: {
|
||||
detail: titles.detail,
|
||||
resolve: titles.resolve,
|
||||
@@ -87,6 +87,8 @@ export const router = os.router({
|
||||
uploadAvatar: account.uploadAvatar,
|
||||
removeAvatar: account.removeAvatar,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const router = os.router(implementedRouter);
|
||||
|
||||
export type Router = typeof router;
|
||||
|
||||
Reference in New Issue
Block a user