341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
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<ReturnType<typeof toPostmanItem>>;
|
|
};
|
|
|
|
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<ApiSpecsDto> {
|
|
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<string, unknown>) {
|
|
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<string, Record<string, unknown>>;
|
|
|
|
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<string, unknown> = {
|
|
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: "<analytics-token>", type: "text" });
|
|
}
|
|
if (securityScheme === "stripeWebhookSignature") {
|
|
headers.push({ key: "stripe-signature", value: "<stripe-signature>", type: "text" });
|
|
}
|
|
if (securityScheme === "mailjetWebhookAuth") {
|
|
headers.push({ key: "Authorization", value: "Basic <base64(mailjet:webhook-secret)>", type: "text" });
|
|
}
|
|
if (securityScheme === "bearerAuth" || securityScheme === "apiSessionBearer") {
|
|
headers.push({ key: "Authorization", value: "Bearer <token>", type: "text" });
|
|
}
|
|
if (parameters.some((entry) => isParameterNamed(entry, "Idempotency-Key"))) {
|
|
headers.push({ key: "Idempotency-Key", value: "<unique-operation-key>", 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<string, unknown>)[key] === "string"
|
|
? (operation as Record<string, string>)[key]
|
|
: undefined;
|
|
}
|
|
|
|
function getOperationArray(operation: unknown, key: string) {
|
|
const value = operation && typeof operation === "object" ? (operation as Record<string, unknown>)[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) ? "<api-key>" : "<service-token>";
|
|
}
|
|
|
|
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<string, unknown>).name === name);
|
|
}
|
|
|
|
function getParameterName(value: unknown) {
|
|
return value && typeof value === "object" && typeof (value as Record<string, unknown>).name === "string"
|
|
? ((value as Record<string, string>).name as string)
|
|
: "";
|
|
}
|
|
|
|
function getParameterLocation(value: unknown) {
|
|
return value && typeof value === "object" && typeof (value as Record<string, unknown>).in === "string"
|
|
? ((value as Record<string, string>).in as string)
|
|
: "";
|
|
}
|
|
|
|
function getParameterRequired(value: unknown) {
|
|
return Boolean(value && typeof value === "object" && (value as Record<string, unknown>).required === true);
|
|
}
|
|
|
|
function getParameterDescription(value: unknown) {
|
|
if (value && typeof value === "object" && typeof (value as Record<string, unknown>).description === "string") {
|
|
return { description: (value as Record<string, string>).description };
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function isHttpMethod(value: string) {
|
|
return ["get", "post", "put", "patch", "delete", "options", "head"].includes(value);
|
|
}
|
|
|
|
export default {
|
|
generateSpecs,
|
|
getEntityEndpoints,
|
|
};
|