docs/src/lib/openapi.ts
Rama master · réplica de lectura del repositorio original.
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, relative, sep } from "node:path";
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
type HttpMethod = (typeof HTTP_METHODS)[number];
interface OpenApiOperation {
description: string;
externalDocs: { url: string };
operationId: string;
parameters?: Array<Record<string, unknown>>;
requestBody?: Record<string, unknown>;
responses: Record<string, unknown>;
security: Array<Record<string, string[]>>;
summary: string;
tags: string[];
}
type OpenApiPathItem = Partial<Record<Lowercase<HttpMethod>, OpenApiOperation>>;
export interface OpenApiDocument {
components: {
parameters: Record<string, unknown>;
schemas: { ApiError: { required: string[]; [key: string]: unknown } };
securitySchemes: Record<string, unknown>;
};
externalDocs: { description: string; url: string };
info: Record<string, unknown>;
jsonSchemaDialect: string;
openapi: "3.1.1";
paths: Record<string, OpenApiPathItem>;
servers: Array<{ description: string; url: string }>;
tags: Array<{ description: string; name: string }>;
}
function apiDocsRoot(): string {
const candidates = [join(process.cwd(), "content/docs/api"), join(process.cwd(), "docs/content/docs/api")];
const root = candidates.find(existsSync);
if (!root) throw new Error("Could not locate content/docs/api for OpenAPI generation.");
return root;
}
function mdxFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return mdxFiles(path);
return entry.isFile() && entry.name.endsWith(".mdx") ? [path] : [];
});
}
function frontmatterValue(source: string, key: string): string | undefined {
const match = source.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
return match?.[1]?.trim().replace(/^['"]|['"]$/g, "");
}
function canonicalParameterName(name: string): string {
return name === "siteId" ? "site" : name;
}
function openApiPath(path: string): string {
return path.replace(/:([A-Za-z][A-Za-z0-9_]*)/g, (_match, name: string) => `{${canonicalParameterName(name)}}`);
}
function operationId(method: HttpMethod, path: string): string {
const words = `${method.toLowerCase()} ${path}`.match(/[A-Za-z0-9]+/g) ?? [];
return words
.map((word, index) => (index === 0 ? word.toLowerCase() : `${word[0].toUpperCase()}${word.slice(1)}`))
.join("");
}
function tagFor(root: string, file: string): string {
const [first] = relative(root, file).split(sep);
if (first.endsWith(".mdx")) return "General";
return first
.split("-")
.map(word => `${word[0].toUpperCase()}${word.slice(1)}`)
.join(" ");
}
function docsUrl(root: string, file: string): string {
const slug = relative(root, file)
.split(sep)
.join("/")
.replace(/\.mdx$/, "");
return `https://rybbit.com/docs/api/${slug}`;
}
const commonParameterReferences = [
"StartDate",
"EndDate",
"TimeZone",
"StartDatetime",
"EndDatetime",
"PastMinutesStart",
"PastMinutesEnd",
"Filters",
"SegmentId",
].map(name => ({ $ref: `#/components/parameters/${name}` }));
function pathParameters(path: string): Array<Record<string, unknown>> {
return [...path.matchAll(/\{([A-Za-z][A-Za-z0-9_]*)\}/g)].map(match => ({
description: match[1] === "site" ? "Numeric Rybbit Site ID or string identifier." : `${match[1]} identifier.`,
in: "path",
name: match[1],
required: true,
schema:
match[1] === "site"
? {
oneOf: [
{ type: "integer", minimum: 1 },
{ type: "string", minLength: 1 },
],
}
: { type: "string", minLength: 1 },
}));
}
function operationSecurity(path: string): Array<Record<string, string[]>> {
if (path === "/api/track") return [{}, { BearerAuth: [] }, { QueryApiKey: [] }];
if (path.startsWith("/api/auth/")) return [{ CookieSession: [] }];
return [{ BearerAuth: [] }, { QueryApiKey: [] }, { CookieSession: [] }];
}
function successResponse(path: string): Record<string, unknown> {
if (path.endsWith("/export/pdf")) {
return {
description: "PDF report.",
content: { "application/pdf": { schema: { type: "string", contentEncoding: "base64" } } },
};
}
return {
description: "Successful response. Refer to the linked endpoint documentation for the response fields.",
content: { "application/json": { schema: {} } },
};
}
function errorResponses(): Record<string, unknown> {
return Object.fromEntries(
[
["400", "Invalid request"],
["401", "Authentication required"],
["403", "Insufficient permission"],
["404", "Resource not found"],
["429", "Rate limit exceeded"],
["500", "Unexpected server error"],
].map(([status, description]) => [
status,
{
description,
content: { "application/json": { schema: { $ref: "#/components/schemas/ApiError" } } },
},
])
);
}
export function buildOpenApiDocument(): OpenApiDocument {
const root = apiDocsRoot();
const paths: Record<string, OpenApiPathItem> = {};
const tags = new Set<string>();
for (const file of mdxFiles(root).sort()) {
const source = readFileSync(file, "utf8");
const route = source.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\/api\/\S+)\s*$/m);
if (!route || !HTTP_METHODS.includes(route[1] as HttpMethod)) continue;
const method = route[1] as HttpMethod;
const path = openApiPath(route[2]);
const methodKey = method.toLowerCase() as Lowercase<HttpMethod>;
const tag = tagFor(root, file);
const parameters = pathParameters(path);
if (/common parameters/i.test(source)) parameters.push(...commonParameterReferences);
const operation: OpenApiOperation = {
summary: frontmatterValue(source, "title") ?? `${method} ${path}`,
description: frontmatterValue(source, "description") ?? "See the endpoint documentation for details.",
operationId: operationId(method, path),
tags: [tag],
security: operationSecurity(path),
externalDocs: { url: docsUrl(root, file) },
responses: { "200": successResponse(path), ...errorResponses() },
...(parameters.length === 0 ? {} : { parameters }),
...(["POST", "PUT", "PATCH"].includes(method)
? {
requestBody: {
required: true,
content: {
"application/json": {
schema: { type: "object", additionalProperties: true },
},
},
},
}
: {}),
};
if (paths[path]?.[methodKey]) {
throw new Error(`Duplicate OpenAPI operation ${method} ${path} while reading ${file}.`);
}
paths[path] = { ...paths[path], [methodKey]: operation };
tags.add(tag);
}
return {
openapi: "3.1.1",
jsonSchemaDialect: "https://json-schema.org/draft/2020-12/schema",
info: {
title: "Rybbit API",
version: "2.8.0-beta",
description:
"REST API for Rybbit web and product analytics. The API is in beta and may introduce breaking changes.",
termsOfService: "https://rybbit.com/terms-and-conditions",
contact: { name: "Rybbit", url: "https://rybbit.com/contact" },
license: {
name: "GNU Affero General Public License v3.0",
identifier: "AGPL-3.0-only",
},
},
servers: [{ url: "https://app.rybbit.io", description: "Rybbit Cloud API" }],
tags: [...tags].sort().map(name => ({ name, description: `${name} API operations.` })),
paths,
components: {
securitySchemes: {
BearerAuth: {
type: "http",
scheme: "bearer",
description: "Personal or organization API key sent as an Authorization Bearer token.",
},
QueryApiKey: {
type: "apiKey",
in: "query",
name: "api_key",
description: "Testing-only fallback. Bearer authentication is recommended.",
},
CookieSession: {
type: "apiKey",
in: "cookie",
name: "better-auth.session_token",
description:
"Authenticated dashboard session cookie. Secure deployments may prefix the cookie name with __Secure-.",
},
},
parameters: {
StartDate: { name: "start_date", in: "query", schema: { type: "string", format: "date" } },
EndDate: { name: "end_date", in: "query", schema: { type: "string", format: "date" } },
TimeZone: {
name: "time_zone",
in: "query",
schema: { type: "string", example: "America/Los_Angeles" },
},
StartDatetime: { name: "start_datetime", in: "query", schema: { type: "string" } },
EndDatetime: { name: "end_datetime", in: "query", schema: { type: "string" } },
PastMinutesStart: { name: "past_minutes_start", in: "query", schema: { type: "integer", minimum: 1 } },
PastMinutesEnd: { name: "past_minutes_end", in: "query", schema: { type: "integer", minimum: 0 } },
Filters: {
name: "filters",
in: "query",
description: "JSON-encoded array of analytics filters.",
schema: { type: "string" },
},
SegmentId: {
name: "segment_id",
in: "query",
description:
"ID of a saved segment whose filters are applied server-side, ANDed with any `filters` also passed.",
schema: { type: "integer", minimum: 1 },
},
},
schemas: {
ApiError: {
type: "object",
additionalProperties: true,
required: ["error", "code", "message", "resolution"],
properties: {
error: { type: "string", description: "Backward-compatible human-readable error." },
code: { type: "string", description: "Stable machine-readable error code." },
message: { type: "string", description: "Human-readable explanation." },
resolution: { type: "string", description: "Action the caller can take to recover." },
details: { description: "Optional structured context for this error." },
},
},
},
},
externalDocs: {
description: "Rybbit API documentation",
url: "https://rybbit.com/docs/api/getting-started",
},
};
}
export function openApiResponse(includeBody = true): Response {
const serialized = `${JSON.stringify(buildOpenApiDocument(), null, 2)}\n`;
return new Response(includeBody ? serialized : null, {
headers: {
"Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
"Content-Length": String(Buffer.byteLength(serialized)),
"Content-Type": "application/json; charset=utf-8",
"X-Content-Type-Options": "nosniff",
},
});
}