docs: generate complete dev Postman API collection

This commit is contained in:
Alexandre Brandizzi 2026-09-15 12:44:00 -03:00
parent 5cf7f7da8c
commit a90e11451e
3 changed files with 19473 additions and 154 deletions

View file

@ -1,55 +1,65 @@
# SeaHaven Dev Postman starter
# SeaHaven Dev Postman collection
A small, versioned Postman starter for the SeaHaven backend **dev** API.
- The **live OpenAPI document** (`{{baseUrl}}/swagger/v1/swagger.json`) is the
complete source of truth for routes and schemas. This committed collection is
a curated starter — it is **not** a generated copy of the full API surface.
- **Dev only.** Never point these requests at any other environment.
This directory contains the complete SeaHaven backend **dev** API collection,
generated from the live OpenAPI contract. The generated collection currently
contains every operation in the contract (368 operations across 330 paths),
including all documented parameters, request bodies, schemas, and response
codes.
## Files
| File | Purpose |
| --- | --- |
| `SeaHaven-Dev.postman_collection.json` | Starter collection: setup requests plus safe read-only requests |
| `SeaHaven-Dev.postman_environment.json` | Dev environment: `baseUrl` plus empty secret `username` / `password` / `token` |
| `SeaHaven-Dev.postman_collection.json` | Complete OpenAPI-derived Postman v2.1 collection, grouped by API tag |
| `SeaHaven-Dev.postman_environment.json` | Dev authentication environment with secret username, password, and reusable bearer token |
| `../scripts/generate-postman-collection.mjs` | Deterministic generator and coverage checker |
## Getting started
1. **Import order** — collection first, then environment:
1. Postman → Import → `postman/SeaHaven-Dev.postman_collection.json`
2. Postman → Import → `postman/SeaHaven-Dev.postman_environment.json`
2. **Select the environment** — pick `SeaHaven - Dev` in the environment picker
(top right of Postman).
3. **Fill credentials locally** — open the environment and enter values for
`username` and `password`. They ship empty and marked secret on purpose;
keep them local to your Postman workspace.
4. **Run login** — run `Authentication - Login` in the `Setup` folder once. A
successful (200) response captures the bearer token automatically. Every
login attempt clears the previous token value without deleting the variable,
so a failed login cannot leave stale authentication active.
5. **Use the safe reads** — everything in the `Safe reads` folder inherits the
collection-level `Bearer {{token}}` auth, so no extra setup is needed.
1. Import `SeaHaven-Dev.postman_collection.json` into Postman.
2. Import `SeaHaven-Dev.postman_environment.json` and select **SeaHaven - Dev**.
3. Enter the dev test account's `username` and `password` in the environment.
They are empty and secret by default; never commit or share populated values.
4. Run **Setup → Authentication - Login**. The pre-request script clears any
stale token, skips the request when either credential is missing, and never
logs credentials. A successful response stores only its non-empty `token`
field in the environment's `token` variable.
5. All generated API requests inherit collection-level `Bearer {{token}}`
authentication. They reuse the captured token automatically.
## Token capture behavior
## Generated requests and examples
The login test script stores the returned bearer token **only** in the
environment variable `token` via `pm.environment.set("token", token)`. It never
writes collection or global variables and never prints credentials or tokens to
the Postman console. The pre-request script skips the request and stops with a
clear message if `username` or `password` is empty.
Every OpenAPI operation is included under its first alphabetically sorted tag.
Path, query, and header parameters retain their required/optional status,
schema hint, and description. JSON request bodies use recursively generated
illustrative placeholders from local OpenAPI schemas; multipart requests use
form-data fields and mark binary fields as files. These examples are not live
records: replace placeholder values with a valid dev fixture before sending.
No response bodies, credentials, tokens, or other dev data are stored in this
repository.
**Clearing the token:** open `Environments → SeaHaven - Dev`, clear the `token`
value (or use the environment reset), and save. Do this before sharing screens,
exporting, or switching machines.
The generated test script only checks that the response status is one of the
codes documented by OpenAPI. It does not assume every operation returns `200`.
## Safety rules
## Regenerate or check coverage
- Keep populated `username`, `password`, and `token` values local. **Never mark
them shared, export them, or commit them.**
- **Never save dev response bodies as collection examples or commit them.**
- **Full API surface:** in Postman, choose **Import → Link** and paste
`https://api.dev.seahaven.com/swagger/v1/swagger.json`. The `Swagger - OpenAPI
document` request is also available to check that the contract is reachable.
- **Canonical routes:** some legacy aliases may still resolve on dev, but this
collection sticks to canonical routes — prefer those when adding requests.
From the backend repository root:
```bash
node scripts/generate-postman-collection.mjs
node scripts/generate-postman-collection.mjs --check
```
The generator fetches `https://api.dev.seahaven.com/swagger/v1/swagger.json`,
fails on a non-JSON/non-success response, and rewrites the collection and the
empty-credential environment deterministically. `--check` compares the checked
in collection with the current contract and verifies operation count, unique
operation keys, request-body coverage, Bearer inheritance, and the explicit
no-auth login exception.
## Safety
This collection is **dev-only**. Do not point it at staging or production. Keep
`username`, `password`, and `token` local to your Postman workspace; never mark
them shared, export them, or commit them. Review every illustrative body and
parameter before running a mutating request.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,375 @@
#!/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);
}