Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/api/services/ApiSpecsService.ts
T

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,
};