#!/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 ""; if (resolved.$ref) { if (seen.has(resolved.$ref)) return ""; 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 ""; default: break; } switch (resolved.type) { case "integer": case "number": return 0; case "boolean": return false; case "null": return null; default: return ""; } } 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: "", 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); }