shoc-backend/scripts/generate-postman-collection.mjs
Alexandre Brandizzi 13ce4b7e88
docs: add complete dev Postman API collection (#119)
* docs: add dev Postman starter collection

* docs: generate complete dev Postman API collection
2026-09-15 16:39:49 -03:00

375 lines
18 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
const DEFAULT_SOURCE = "https://api.dev.seahaven.com/swagger/v1/swagger.json";
const ROOT = path.resolve(new URL("..", import.meta.url).pathname);
const COLLECTION_PATH = path.join(ROOT, "postman", "SeaHaven-Dev.postman_collection.json");
const ENVIRONMENT_PATH = path.join(ROOT, "postman", "SeaHaven-Dev.postman_environment.json");
const METHODS = ["get", "post", "put", "patch", "delete", "options", "head", "trace"];
const sourceArg = process.argv.find((arg) => arg.startsWith("--source="));
const inputArg = process.argv.find((arg) => arg.startsWith("--input="));
const sourceUrl = sourceArg ? sourceArg.slice("--source=".length) : DEFAULT_SOURCE;
const checkOnly = process.argv.includes("--check");
function operationEntries(document) {
return Object.entries(document.paths ?? {}).flatMap(([route, pathItem]) =>
METHODS.filter((method) => pathItem?.[method]).map((method) => ({
route,
method,
operation: pathItem[method],
pathParameters: pathItem.parameters ?? [],
})),
);
}
function schemaRefName(ref) {
return typeof ref === "string" ? ref.split("/").pop() : undefined;
}
function resolveSchema(schema, document) {
if (!schema) return undefined;
if (schema.$ref) return document.components?.schemas?.[schemaRefName(schema.$ref)] ?? schema;
return schema;
}
function safeSample(schema, document, name = "value", depth = 0, seen = new Set()) {
const resolved = resolveSchema(schema, document) ?? {};
if (depth > 4) return "<nested value>";
if (resolved.$ref) {
if (seen.has(resolved.$ref)) return "<recursive value>";
seen.add(resolved.$ref);
}
if (resolved.enum?.length) return resolved.enum[0];
if (resolved.oneOf?.length) return safeSample(resolved.oneOf[0], document, name, depth + 1, seen);
if (resolved.anyOf?.length) return safeSample(resolved.anyOf[0], document, name, depth + 1, seen);
if (resolved.allOf?.length) {
return resolved.allOf.reduce((acc, part) => {
const value = safeSample(part, document, name, depth + 1, seen);
return typeof value === "object" && value && !Array.isArray(value) ? { ...acc, ...value } : value;
}, {});
}
if (resolved.type === "array" || resolved.items) {
return [safeSample(resolved.items ?? {}, document, `${name}Item`, depth + 1, seen)];
}
if (resolved.type === "object" || resolved.properties || resolved.additionalProperties) {
const object = {};
for (const [property, propertySchema] of Object.entries(resolved.properties ?? {})) {
object[property] = safeSample(propertySchema, document, property, depth + 1, seen);
}
return object;
}
switch (resolved.format) {
case "date-time": return "2026-01-01T00:00:00Z";
case "date": return "2026-01-01";
case "uuid": return "00000000-0000-0000-0000-000000000000";
case "email": return "user@example.com";
case "uri": return "https://example.com/resource";
case "binary": return "<binary file>";
default: break;
}
switch (resolved.type) {
case "integer":
case "number": return 0;
case "boolean": return false;
case "null": return null;
default: return "<string>";
}
}
function schemaHint(schema) {
if (!schema) return "schema unspecified";
if (schema.$ref) return schema.$ref;
const bits = [schema.type, schema.format].filter(Boolean);
if (schema.enum?.length) bits.push(`enum: ${schema.enum.join(", ")}`);
return bits.join("/") || "schema";
}
function parameterValue(parameter, document) {
const schema = parameter.schema ?? {};
if (schema.enum?.length) return String(schema.enum[0]);
if (parameter.example !== undefined) return String(parameter.example);
if (schema.type === "boolean") return "false";
if (["integer", "number"].includes(schema.type)) return "0";
if (schema.type === "array") return "";
return "";
}
function mergeParameters(entry) {
const parameters = [...entry.pathParameters, ...(entry.operation.parameters ?? [])];
const seen = new Set();
return parameters.filter((parameter) => {
const key = `${parameter.in}:${parameter.name}`;
if (seen.has(key)) return false;
seen.add(key);
return ["path", "query", "header"].includes(parameter.in);
});
}
function urlFor(entry, parameters) {
const pathParameters = parameters.filter((p) => p.in === "path");
const queryParameters = parameters.filter((p) => p.in === "query");
const headerParameters = parameters.filter((p) => p.in === "header");
const postmanPath = entry.route.replaceAll(/\{([^}]+)\}/g, ":$1");
const query = queryParameters.map((p) => ({
key: p.name,
value: parameterValue(p),
description: `${p.required ? "Required" : "Optional"}; ${schemaHint(p.schema)}`,
disabled: false,
}));
const raw = `{{baseUrl}}${postmanPath}${query.length ? `?${query.map((p) => `${p.key}=${p.value}`).join("&")}` : ""}`;
return {
raw,
host: ["{{baseUrl}}"],
path: postmanPath.split("/").filter(Boolean),
...(query.length ? { query } : {}),
...(pathParameters.length ? {
variable: pathParameters.map((p) => ({
key: p.name,
value: parameterValue(p),
description: `${p.required ? "Required" : "Optional"}; ${schemaHint(p.schema)}`,
})),
} : {}),
};
}
function bodyFor(entry, document) {
const content = entry.operation.requestBody?.content ?? {};
const mediaTypes = Object.keys(content);
const jsonType = mediaTypes.find((type) => type === "application/json" || type === "text/json" || type.includes("+json"));
const type = jsonType ?? mediaTypes.find((value) => value === "multipart/form-data") ?? mediaTypes[0];
if (!type) return undefined;
const schema = content[type]?.schema;
const description = `Illustrative placeholder generated from ${schemaHint(schema)}; replace placeholder values before sending.`;
if (type === "multipart/form-data") {
const resolved = resolveSchema(schema, document) ?? {};
const formdata = Object.entries(resolved.properties ?? {}).map(([key, propertySchema]) => ({
key,
type: propertySchema.format === "binary" ? "file" : "text",
...(propertySchema.format === "binary" ? { src: "" } : { value: String(safeSample(propertySchema, document, key)) }),
description: `${resolved.required?.includes(key) ? "Required" : "Optional"}; ${schemaHint(propertySchema)}`,
}));
return { mode: "formdata", formdata, description };
}
if (type === "application/octet-stream") return { mode: "file", file: { src: "" }, description };
if (type.startsWith("text/")) return { mode: "raw", raw: "<text payload>", options: { raw: { language: "text" } }, description };
return { mode: "raw", raw: JSON.stringify(safeSample(schema, document), null, 2), options: { raw: { language: "json" } }, description };
}
function operationDescription(entry, document, parameters, body) {
const operation = entry.operation;
const responses = Object.entries(operation.responses ?? {})
.sort(([a], [b]) => a.localeCompare(b))
.map(([code, response]) => {
const mediaTypes = Object.keys(response.content ?? {});
return `${code}${mediaTypes.length ? ` (${mediaTypes.join(", ")})` : ""}`;
})
.join(", ") || "not documented";
const requestMediaTypes = Object.keys(operation.requestBody?.content ?? {});
const lines = [
`Source: ${sourceUrl}`,
`Tag(s): ${(operation.tags ?? ["Other"]).join(", ")}`,
`Operation ID: ${operation.operationId ?? "not specified"}`,
"",
operation.summary ?? "",
operation.description ?? "",
"",
`Parameters: ${parameters.length ? parameters.map((p) => `${p.in} ${p.name} (${p.required ? "required" : "optional"}, ${schemaHint(p.schema)})`).join("; ") : "none"}`,
`Request body: ${body ? `${requestMediaTypes.join(", ") || "media type unspecified"}; ${body.description}` : "none documented"}`,
`Documented response codes: ${responses}`,
"",
"Body values and parameter values are safe illustrative placeholders; review them against the target record before sending.",
];
return lines.filter((line, index) => !(line === "" && lines[index - 1] === "")).join("\n").trim();
}
function testEvent(operation) {
const codes = Object.keys(operation.responses ?? {}).filter((key) => /^\d{3}$/.test(key)).map(Number);
if (!codes.length) return [];
return [{
listen: "test",
script: {
type: "text/javascript",
exec: [
`const documented = [${codes.join(", ")}];`,
"pm.test(\"Response status is documented by OpenAPI\", function () {",
" pm.expect(documented).to.include(pm.response.code);",
"});",
],
},
}];
}
function requestItem(entry, document) {
const parameters = mergeParameters(entry);
const body = bodyFor(entry, document);
const headers = parameters.filter((p) => p.in === "header").map((p) => ({
key: p.name,
value: parameterValue(p),
description: `${p.required ? "Required" : "Optional"}; ${schemaHint(p.schema)}`,
}));
if (body?.mode === "raw" && body.options?.raw?.language === "json") headers.push({ key: "Content-Type", value: "application/json" });
if (body?.mode === "raw" && body.options?.raw?.language === "text") headers.push({ key: "Content-Type", value: "text/plain" });
const item = {
name: `${entry.method.toUpperCase()} ${entry.route}`,
request: {
method: entry.method.toUpperCase(),
header: headers,
url: urlFor(entry, parameters),
description: operationDescription(entry, document, parameters, body),
...(body ? { body } : {}),
},
event: testEvent(entry.operation),
};
return item;
}
function setupItems(document) {
const login = operationEntries(document).find((entry) => entry.route.toLowerCase() === "/api/authentication/login" && entry.method === "post");
const loginSchema = login?.operation.requestBody?.content?.["application/json"]?.schema;
return [
{
name: "Swagger - OpenAPI document",
request: {
auth: { type: "noauth" }, method: "GET", header: [],
url: { raw: "{{baseUrl}}/swagger/v1/swagger.json", host: ["{{baseUrl}}"], path: ["swagger", "v1", "swagger.json"] },
description: `Live OpenAPI contract for dev (${sourceUrl}). Import this URL when you need the latest server surface. No auth required.`,
},
event: [{ listen: "test", script: { type: "text/javascript", exec: [
"pm.test(\"OpenAPI document responded 200\", function () { pm.response.to.have.status(200); });",
"pm.test(\"OpenAPI document is JSON\", function () { pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\"); });",
"pm.test(\"OpenAPI document has paths\", function () { pm.expect(pm.response.json()).to.have.property(\"paths\"); });",
] } }],
},
{
name: "Authentication - Login",
request: {
auth: { type: "noauth" }, method: "POST", header: [{ key: "Content-Type", value: "application/json" }],
body: { mode: "raw", raw: JSON.stringify({ username: "{{username}}", password: "{{password}}" }, null, 2), options: { raw: { language: "json" } }, description: "Credentials are read from the selected secret environment and are never logged." },
url: { raw: "{{baseUrl}}/api/Authentication/login", host: ["{{baseUrl}}"], path: ["api", "Authentication", "login"] },
description: `Authentication login from ${sourceUrl}. A successful response stores only the non-empty token in the selected environment. Schema: ${schemaHint(loginSchema)}.`,
},
event: [
{ listen: "prerequest", script: { type: "text/javascript", exec: [
"pm.environment.set(\"token\", \"\");",
"const username = pm.environment.get(\"username\");",
"const password = pm.environment.get(\"password\");",
"if (!username || !password) {",
" pm.execution.skipRequest();",
" throw new Error(\"Select the SeaHaven - Dev environment and set username and password before logging in.\");",
"}",
] } },
{ listen: "test", script: { type: "text/javascript", exec: [
"pm.test(\"Login responded 200\", function () { pm.response.to.have.status(200); });",
"let body;",
"pm.test(\"Login responded with JSON\", function () { pm.expect(pm.response.headers.get(\"Content-Type\")).to.include(\"application/json\"); body = pm.response.json(); });",
"const token = body && body.token;",
"pm.test(\"Login response contained a non-empty token\", function () { pm.expect(token, \"expected a non-empty token field\").to.be.a(\"string\").and.not.empty; });",
"if (typeof token === \"string\" && token.length > 0) pm.environment.set(\"token\", token);",
] } },
],
},
];
}
function buildCollection(document) {
const entries = operationEntries(document);
const loginEntry = entries.find((entry) => entry.route.toLowerCase() === "/api/authentication/login" && entry.method === "post");
const folders = new Map();
for (const entry of entries) {
if (entry === loginEntry) continue;
const tag = [...(entry.operation.tags ?? ["Other"])].sort()[0] ?? "Other";
if (!folders.has(tag)) folders.set(tag, []);
folders.get(tag).push(requestItem(entry, document));
}
const generatedFolders = [...folders.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, items]) => ({
name,
item: items.sort((a, b) => a.name.localeCompare(b.name)),
}));
return {
info: {
name: "SeaHaven - Dev API (OpenAPI generated)",
_postman_id: "a1f0514a-5e9f-41bd-a145-7163671b94db",
description: `Complete Postman collection generated from ${sourceUrl}. It contains every OpenAPI operation in the published dev contract. Dev only. Replace illustrative placeholders before sending requests.`,
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
},
auth: { type: "bearer", bearer: [{ key: "token", value: "{{token}}", type: "string" }] },
variable: [{ key: "baseUrl", value: "https://api.dev.seahaven.com", type: "string" }],
item: [{ name: "Setup", item: setupItems(document) }, ...generatedFolders],
};
}
function flattenCollectionItems(items, folder = "") {
return items.flatMap((item) => item.item ? flattenCollectionItems(item.item, `${folder}${item.name}/`) : [{ ...item, folder }]);
}
function validate(document, collection) {
const sourceEntries = operationEntries(document);
const allItems = flattenCollectionItems(collection.item);
const generated = allItems.filter((item) => !item.folder.startsWith("Setup/"));
const setupLogin = allItems.find((item) => item.name === "Authentication - Login");
const sourceKeys = new Set(sourceEntries.map((entry) => `${entry.method.toUpperCase()} ${entry.route}`));
const requestKey = (item) => `${item.request.method} ${item.request.url.raw.replace("{{baseUrl}}", "").replace(/\?.*$/, "").replaceAll(/:([^/]+)/g, "{$1}")}`;
const representedItems = [...generated, setupLogin].filter(Boolean);
const representedKeys = new Set(representedItems.map(requestKey));
const bodyCount = sourceEntries.filter((entry) => entry.operation.requestBody).length;
const generatedBodyCount = representedItems.filter((item) => item.request.body).length;
const authFailures = generated.filter((item) => item.request.auth?.type === "noauth");
const authConfig = collection.auth?.type === "bearer" && collection.auth.bearer?.some((entry) => entry.key === "token" && entry.value === "{{token}}");
const missingKeys = [...sourceKeys].filter((key) => !representedKeys.has(key));
const extraKeys = [...representedKeys].filter((key) => !sourceKeys.has(key));
const checks = [
["operation count", representedKeys.size, sourceEntries.length],
["unique operation keys", missingKeys.length + extraKeys.length, 0],
["request-body coverage", generatedBodyCount, bodyCount],
["protected operations inherit collection auth", authFailures.length, 0],
["collection bearer token auth", authConfig, true],
["login is explicitly noauth", setupLogin?.request?.auth?.type, "noauth"],
];
const failures = checks.filter(([, actual, expected]) => actual !== expected);
if (failures.length) {
for (const [label, actual, expected] of failures) console.error(`FAIL ${label}: expected ${expected}, observed ${actual}`);
process.exitCode = 1;
return false;
}
for (const [label, actual, expected] of checks) console.log(`PASS ${label}: ${actual}`);
if (missingKeys.length || extraKeys.length) {
console.error(`Operation key mismatch. Missing: ${missingKeys.join(", ")}; extra: ${extraKeys.join(", ")}`);
process.exitCode = 1;
return false;
}
return true;
}
async function loadDocument() {
if (inputArg) return JSON.parse(await fs.readFile(inputArg.slice("--input=".length), "utf8"));
const response = await fetch(sourceUrl, { headers: { accept: "application/json" } });
if (!response.ok) throw new Error(`OpenAPI fetch failed: HTTP ${response.status}`);
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("json")) throw new Error(`OpenAPI fetch returned non-JSON content type: ${contentType}`);
return response.json();
}
const document = await loadDocument();
const collection = buildCollection(document);
if (checkOnly) {
const existing = JSON.parse(await fs.readFile(COLLECTION_PATH, "utf8"));
validate(document, existing);
} else {
await fs.writeFile(COLLECTION_PATH, `${JSON.stringify(collection, null, 2)}\n`);
await fs.writeFile(ENVIRONMENT_PATH, `${JSON.stringify({
name: "SeaHaven - Dev",
values: [
{ key: "baseUrl", value: "https://api.dev.seahaven.com", type: "default", enabled: true },
{ key: "username", value: "", type: "secret", enabled: true },
{ key: "password", value: "", type: "secret", enabled: true },
{ key: "token", value: "", type: "secret", enabled: true },
],
_postman_variable_scope: "environment",
}, null, 2)}\n`);
console.log(`Generated ${operationEntries(document).length} operations at ${COLLECTION_PATH}`);
validate(document, collection);
}