import { openApiDocument } from "@toolctrl/api/openapi"; import { getAllLegacyEntities, type LegacyEntityWithDetails } from "@toolctrl/db/entities.server"; import type { TFunction } from "i18next"; import type { ApiEndpointDto } from "../dtos/ApiEndpointDto"; import ApiHelper from "~/utils/helpers/ApiHelper"; import { getTranslations } from "~/locale/i18next.server"; import { getAbsoluteApiUrl } from "~/utils/url.server"; import { apiPath } from "~/utils/app/CanonicalUrlUtils"; export type ApiSpecsDto = { endpoints?: ApiEndpointDto[]; openApi: CanonicalOpenApiDocument; postmanCollection: PostmanCollection; apiDocsUrl: string; }; type CanonicalOpenApiDocument = typeof openApiDocument & { servers: Array<{ url: string; description: string }>; }; type PostmanCollection = { info: { _postman_id: string; name: string; schema: string; }; item: Array>; }; type PostmanHeader = { key: string; value: string; type: "text"; description?: string }; type PostmanQueryParam = { key: string; value: string; disabled?: boolean; description?: string }; async function generateSpecs({ request }: { request: Request }): Promise { const { t } = await getTranslations(request); const entities = (await getAllLegacyEntities({ tenantId: null, active: true })).filter((entity) => entity.hasApi); const openApi = generateCanonicalOpenApiSpecs({ request, t }); return { endpoints: entities.flatMap((entity) => getEntityEndpoints({ entity, t, apiOrigin: getApiOrigin(request) })), openApi, postmanCollection: generatePostmanCollection({ request, openApi }), apiDocsUrl: getAbsoluteApiUrl(request, apiPath("docs", { mode: "host-aware" })), }; } function getApiOrigin(request: Request) { return new URL(getAbsoluteApiUrl(request, "/")).origin; } function generateCanonicalOpenApiSpecs({ request, t }: { request: Request; t: TFunction }): CanonicalOpenApiDocument { return { ...openApiDocument, servers: [ { url: getApiOrigin(request), description: translateApiSpec(t, "api.specs.hostDescription", "API host"), }, ], } as CanonicalOpenApiDocument; } function getEntityApiRoute(apiOrigin: string, path: string) { return new URL(apiPath(path, { mode: "host-aware" }), apiOrigin).toString(); } function getEntityEndpoints({ entity, t, apiOrigin }: { entity: LegacyEntityWithDetails; t: TFunction; apiOrigin: string }) { const body = Object.fromEntries( entity.properties .filter((property) => !property.isDefault) .map((property) => [property.name, ApiHelper.getSampleValue({ property: property as never }) ?? ""]) ); const schema = Object.fromEntries( entity.properties .filter((property) => !property.isDefault) .map((property) => [property.name, ApiHelper.getSchemaType({ property: property as never }) ?? ""]) ); return [ { entity, route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows`), method: "GET", description: translateApiSpec(t, "api.specs.operations.getAll", `Get all ${t(entity.titlePlural)}`, { name: t(entity.titlePlural) }), responseSchema: JSON.stringify(schema, null, 2), bodyExample: "", }, { entity, route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows/:rowId`), method: "GET", description: translateApiSpec(t, "api.specs.operations.get", `Get ${t(entity.title)}`, { name: t(entity.title) }), responseSchema: JSON.stringify(schema, null, 2), bodyExample: "", }, { entity, route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows`), method: "POST", description: translateApiSpec(t, "api.specs.operations.create", `Create ${t(entity.title)}`, { name: t(entity.title) }), responseSchema: JSON.stringify(schema, null, 2), bodyExample: JSON.stringify(body, null, 2), }, { entity, route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows/:rowId`), method: "PATCH", description: translateApiSpec(t, "api.specs.operations.update", `Update ${t(entity.title)}`, { name: t(entity.title) }), responseSchema: JSON.stringify(schema, null, 2), bodyExample: JSON.stringify(body, null, 2), }, { entity, route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows/:rowId`), method: "DELETE", description: translateApiSpec(t, "api.specs.operations.delete", `Delete ${t(entity.title)}`, { name: t(entity.title) }), responseSchema: "", bodyExample: "", }, ] satisfies ApiEndpointDto[]; } function translateApiSpec(t: TFunction, key: string, fallback: string, options?: Record) { const translated = t(key, options); return translated === key ? fallback : translated; } function generatePostmanCollection({ request, openApi }: { request: Request; openApi: CanonicalOpenApiDocument }): PostmanCollection { const serverUrl = getApiOrigin(request); const paths = openApi.paths as Record>; return { info: { _postman_id: "canonical-api", name: `${process.env.APP_NAME ?? "Boilerplate"} API`, schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", }, item: Object.entries(paths).flatMap(([path, operations]) => Object.entries(operations) .filter(([method]) => isHttpMethod(method)) .map(([method, operation]) => toPostmanItem({ serverUrl, path, method: method.toUpperCase(), operation })) ), }; } function toPostmanItem({ serverUrl, path, method, operation }: { serverUrl: string; path: string; method: string; operation: unknown }) { const description = getOperationString(operation, "description") ?? getOperationString(operation, "summary") ?? ""; const postmanPath = path.replace(/\{([^}]+)\}/g, ":$1"); const request: Record = { url: getPostmanUrl(serverUrl, postmanPath, operation), method, header: getPostmanHeaders(operation), description, }; if (hasRequestBody(operation)) { request.body = { mode: "raw", raw: "{}", options: { raw: { language: "json" } }, }; } return { name: `${method} ${postmanPath}`, request, }; } function getPostmanUrl(serverUrl: string, route: string, operation: unknown) { const url = new URL(route, serverUrl); const query = getPostmanQueryParams(operation); for (const parameter of query) { if (!parameter.disabled) { url.searchParams.set(parameter.key, parameter.value); } } return { raw: url.toString(), protocol: url.protocol.replace(":", ""), host: [url.hostname], path: url.pathname.replace(/^\//, "").split("/"), port: url.port, ...(query.length > 0 ? { query } : {}), }; } function getPostmanHeaders(operation: unknown) { const headers: PostmanHeader[] = []; const securityScheme = getPreferredPostmanSecurityScheme(getOperationArray(operation, "security")); const parameters = getOperationArray(operation, "parameters"); if (securityScheme === "apiKeyHeader") { headers.push({ key: "x-api-key", value: getApiKeyHeaderPlaceholder(operation), type: "text" }); } if (securityScheme === "analyticsTokenHeader") { headers.push({ key: "x-analytics-token", value: "", type: "text" }); } if (securityScheme === "stripeWebhookSignature") { headers.push({ key: "stripe-signature", value: "", type: "text" }); } if (securityScheme === "mailjetWebhookAuth") { headers.push({ key: "Authorization", value: "Basic ", type: "text" }); } if (securityScheme === "bearerAuth" || securityScheme === "apiSessionBearer") { headers.push({ key: "Authorization", value: "Bearer ", type: "text" }); } if (parameters.some((entry) => isParameterNamed(entry, "Idempotency-Key"))) { headers.push({ key: "Idempotency-Key", value: "", type: "text" }); } for (const parameter of getPostmanHeaderParams(parameters)) { if (!headers.some((header) => header.key.toLowerCase() === parameter.key.toLowerCase())) { headers.push(parameter); } } return headers; } function getPostmanQueryParams(operation: unknown): PostmanQueryParam[] { return getOperationArray(operation, "parameters") .filter((parameter) => getParameterLocation(parameter) === "query") .map((parameter) => { const key = getParameterName(parameter); return { key, value: `<${key}>`, ...(getParameterRequired(parameter) ? {} : { disabled: true }), ...getParameterDescription(parameter), }; }) .filter((parameter) => parameter.key.length > 0); } function getPostmanHeaderParams(parameters: unknown[]): PostmanHeader[] { return parameters .filter((parameter) => getParameterLocation(parameter) === "header") .map((parameter) => { const key = getParameterName(parameter); return { key, value: `<${key}>`, type: "text" as const, ...getParameterDescription(parameter), }; }) .filter((parameter) => parameter.key.length > 0); } function getPreferredPostmanSecurityScheme(security: unknown[]) { if (security.some((entry) => isSecurityScheme(entry, "apiKeyHeader"))) { return "apiKeyHeader"; } if (security.some((entry) => isSecurityScheme(entry, "analyticsTokenHeader"))) { return "analyticsTokenHeader"; } if (security.some((entry) => isSecurityScheme(entry, "stripeWebhookSignature"))) { return "stripeWebhookSignature"; } if (security.some((entry) => isSecurityScheme(entry, "mailjetWebhookAuth"))) { return "mailjetWebhookAuth"; } if (security.some((entry) => isSecurityScheme(entry, "bearerAuth"))) { return "bearerAuth"; } if (security.some((entry) => isSecurityScheme(entry, "apiSessionBearer"))) { return "apiSessionBearer"; } return null; } function getOperationString(operation: unknown, key: string) { return operation && typeof operation === "object" && typeof (operation as Record)[key] === "string" ? (operation as Record)[key] : undefined; } function getOperationArray(operation: unknown, key: string) { const value = operation && typeof operation === "object" ? (operation as Record)[key] : undefined; return Array.isArray(value) ? value : []; } function hasRequestBody(operation: unknown) { return Boolean(operation && typeof operation === "object" && "requestBody" in operation); } function hasScopedApiKeyPermissions(operation: unknown) { return Boolean(operation && typeof operation === "object" && "x-api-key-permissions" in operation); } function getApiKeyHeaderPlaceholder(operation: unknown) { if (operation && typeof operation === "object" && "x-api-key-placeholder" in operation) { const placeholder = (operation as { "x-api-key-placeholder"?: unknown })["x-api-key-placeholder"]; if (typeof placeholder === "string" && placeholder.length > 0) { return placeholder; } } return hasScopedApiKeyPermissions(operation) ? "" : ""; } function isSecurityScheme(value: unknown, scheme: string) { return Boolean(value && typeof value === "object" && scheme in value); } function isParameterNamed(value: unknown, name: string) { return Boolean(value && typeof value === "object" && (value as Record).name === name); } function getParameterName(value: unknown) { return value && typeof value === "object" && typeof (value as Record).name === "string" ? ((value as Record).name as string) : ""; } function getParameterLocation(value: unknown) { return value && typeof value === "object" && typeof (value as Record).in === "string" ? ((value as Record).in as string) : ""; } function getParameterRequired(value: unknown) { return Boolean(value && typeof value === "object" && (value as Record).required === true); } function getParameterDescription(value: unknown) { if (value && typeof value === "object" && typeof (value as Record).description === "string") { return { description: (value as Record).description }; } return {}; } function isHttpMethod(value: string) { return ["get", "post", "put", "patch", "delete", "options", "head"].includes(value); } export default { generateSpecs, getEntityEndpoints, };