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:
2026-03-14 20:05:44 -04:00
parent aede4fc90a
commit 33fd871114
20 changed files with 5153 additions and 2928 deletions
+75 -5
View File
@@ -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("<", "&lt;")
.replaceAll(">", "&gt;");
}
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);
}),
+259
View File
@@ -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);
}
+4 -2
View File
@@ -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;