monkeytype/backend/scripts/openapi.ts
2024-09-20 11:54:14 +02:00

302 lines
8.9 KiB
TypeScript

import { generateOpenApi } from "@ts-rest/open-api";
import { contract } from "@monkeytype/contracts/index";
import { writeFileSync, mkdirSync } from "fs";
import {
EndpointMetadata,
PermissionId,
} from "@monkeytype/contracts/schemas/api";
import type { OpenAPIObject, OperationObject } from "openapi3-ts";
import {
RateLimitIds,
getLimits,
RateLimiterId,
Window,
} from "@monkeytype/contracts/rate-limit/index";
import { formatDuration } from "date-fns";
type SecurityRequirementObject = {
[name: string]: string[];
};
export function getOpenApi(): OpenAPIObject {
const openApiDocument = generateOpenApi(
contract,
{
openapi: "3.1.0",
info: {
title: "Monkeytype API",
description:
"Documentation for the endpoints provided by the Monkeytype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY`\n\nThere is a rate limit of `30 requests per minute` across all endpoints with some endpoints being more strict. Rate limit rates are shared across all ape keys.",
version: "2.0.0",
termsOfService: "https://monkeytype.com/terms-of-service",
contact: {
name: "Support",
email: "support@monkeytype.com",
},
"x-logo": {
url: "https://monkeytype.com/images/mtfulllogo.png",
},
license: {
name: "GPL-3.0",
url: "https://www.gnu.org/licenses/gpl-3.0.html",
},
},
servers: [
{
url: "https://api.monkeytype.com",
description: "Production server",
},
],
components: {
securitySchemes: {
BearerAuth: {
type: "http",
scheme: "bearer",
},
ApeKey: {
type: "http",
scheme: "ApeKey",
},
},
},
tags: [
{
name: "users",
description: "User account data.",
"x-displayName": "Users",
"x-public": "yes",
},
{
name: "configs",
description:
"User specific configs like test settings, theme or tags.",
"x-displayName": "User configs",
"x-public": "no",
},
{
name: "presets",
description: "User specific configuration presets.",
"x-displayName": "User presets",
"x-public": "no",
},
{
name: "results",
description: "User test results",
"x-displayName": "Test results",
"x-public": "yes",
},
{
name: "ape-keys",
description: "Ape keys provide access to certain API endpoints.",
"x-displayName": "Ape Keys",
"x-public": "no",
},
{
name: "public",
description: "Public endpoints such as typing stats.",
"x-displayName": "Public",
"x-public": "yes",
},
{
name: "leaderboards",
description: "All-time and daily leaderboards of the fastest typers.",
"x-displayName": "Leaderboards",
},
{
name: "psas",
description: "Public service announcements.",
"x-displayName": "PSAs",
"x-public": "yes",
},
{
name: "quotes",
description: "Quote ratings and new quote submissions",
"x-displayName": "Quotes",
"x-public": "yes",
},
{
name: "admin",
description:
"Various administrative endpoints. Require user to have admin permissions.",
"x-displayName": "Admin",
"x-public": "no",
},
{
name: "configuration",
description: "Server configuration",
"x-displayName": "Server configuration",
"x-public": "yes",
},
{
name: "development",
description:
"Development related endpoints. Only available on dev environment",
"x-displayName": "Development",
"x-public": "no",
},
{
name: "webhooks",
description: "Endpoints for incoming webhooks.",
"x-displayName": "Webhooks",
"x-public": "yes",
},
],
},
{
jsonQuery: true,
setOperationId: "concatenated-path",
operationMapper: (operation, route) => {
const metadata = route.metadata as EndpointMetadata;
if (!operation.description?.trim()?.endsWith(".")) {
operation.description += ".";
}
operation.description += "\n\n";
addAuth(operation, metadata);
addRateLimit(operation, metadata);
addRequiredConfiguration(operation, metadata);
addTags(operation, metadata);
return operation;
},
}
);
return openApiDocument;
}
function addAuth(
operation: OperationObject,
metadata: EndpointMetadata | undefined
): void {
const auth = metadata?.authenticationOptions ?? {};
const permissions = getRequiredPermissions(metadata) ?? [];
const security: SecurityRequirementObject[] = [];
if (!auth.isPublic && !auth.isPublicOnDev) {
security.push({ BearerAuth: permissions });
if (auth.acceptApeKeys === true) {
security.push({ ApeKey: permissions });
}
}
const includeInPublic = auth.isPublic === true || auth.acceptApeKeys === true;
operation["x-public"] = includeInPublic ? "yes" : "no";
operation.security = security;
if (permissions.length !== 0) {
operation.description += `**Required permissions:** ${permissions.join(
", "
)}\n\n`;
}
}
function getRequiredPermissions(
metadata: EndpointMetadata | undefined
): PermissionId[] | undefined {
if (metadata === undefined || metadata.requirePermission === undefined)
return undefined;
if (Array.isArray(metadata.requirePermission))
return metadata.requirePermission;
return [metadata.requirePermission];
}
function addTags(
operation: OperationObject,
metadata: EndpointMetadata | undefined
): void {
if (metadata === undefined || metadata.openApiTags === undefined) return;
operation.tags = Array.isArray(metadata.openApiTags)
? metadata.openApiTags
: [metadata.openApiTags];
}
function addRateLimit(
operation: OperationObject,
metadata: EndpointMetadata | undefined
): void {
if (metadata === undefined || metadata.rateLimit === undefined) return;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const okResponse = operation.responses["200"];
if (okResponse === undefined) return;
operation.description += getRateLimitDescription(metadata.rateLimit);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
okResponse["headers"] = {
...okResponse["headers"],
"x-ratelimit-limit": {
schema: { type: "integer" },
description: "The number of allowed requests in the current period",
},
"x-ratelimit-remaining": {
schema: { type: "integer" },
description: "The number of remaining requests in the current period",
},
"x-ratelimit-reset": {
schema: { type: "integer" },
description: "The timestamp of the start of the next period",
},
};
}
function getRateLimitDescription(limit: RateLimiterId | RateLimitIds): string {
const limits = getLimits(limit);
let result = `**Rate limit:** This operation can be called up to ${
limits.limiter.max
} times ${formatWindow(limits.limiter.window)} for regular users`;
if (limits.apeKeyLimiter !== undefined) {
result += ` and up to ${limits.apeKeyLimiter.max} times ${formatWindow(
limits.apeKeyLimiter.window
)} with ApeKeys`;
}
return result + ".\n\n";
}
function formatWindow(window: Window): string {
if (typeof window === "number") {
const seconds = Math.floor(window / 1000);
const duration = formatDuration({
hours: Math.floor(seconds / 3600),
minutes: Math.floor(seconds / 60) % 60,
seconds: seconds % 60,
});
return `every ${duration}`;
}
return "per " + window;
}
function addRequiredConfiguration(
operation: OperationObject,
metadata: EndpointMetadata | undefined
): void {
if (metadata === undefined || metadata.requireConfiguration === undefined)
return;
//@ts-expect-error
operation.description += `**Required configuration:** This operation can only be called if the [configuration](#tag/configuration/operation/configuration.get) for \`${metadata.requireConfiguration.path}\` is \`true\`.\n\n`;
}
//detect if we run this as a main
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length !== 1) {
console.error("Provide filename.");
process.exit(1);
}
const outFile = args[0] as string;
//create directories if needed
const lastSlash = outFile.lastIndexOf("/");
if (lastSlash > 1) {
const dir = outFile.substring(0, lastSlash);
mkdirSync(dir, { recursive: true });
}
const openapi = getOpenApi();
writeFileSync(args[0] as string, JSON.stringify(openapi, null, 2));
}