This commit is contained in:
309
backend/scripts/openapi.ts
Normal file
309
backend/scripts/openapi.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { generateOpenApi } from "@ts-rest/open-api";
|
||||
import { contract } from "@monkeytype/contracts/index";
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
import { EndpointMetadata, PermissionId } from "@monkeytype/contracts/util/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: "connections",
|
||||
description: "Connections between users.",
|
||||
"x-displayName": "Connections",
|
||||
"x-public": "no",
|
||||
},
|
||||
{
|
||||
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;
|
||||
// oxlint-disable-next-line no-unsafe-assignment
|
||||
const okResponse = operation.responses["200"];
|
||||
if (okResponse === undefined) return;
|
||||
|
||||
operation.description += getRateLimitDescription(metadata.rateLimit);
|
||||
|
||||
// oxlint-disable-next-line no-unsafe-assignment no-unsafe-member-access
|
||||
okResponse["headers"] = {
|
||||
// oxlint-disable-next-line no-unsafe-member-access
|
||||
...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 somehow path doesnt exist
|
||||
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));
|
||||
}
|
||||
7
backend/scripts/tsconfig.json
Normal file
7
backend/scripts/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@monkeytype/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES6"
|
||||
},
|
||||
"include": ["./**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user