mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 21:13:12 +00:00
* docs: add dev Postman starter collection * docs: generate complete dev Postman API collection
375 lines
18 KiB
JavaScript
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);
|
|
}
|