docs: add complete dev Postman API collection (#119)

* docs: add dev Postman starter collection

* docs: generate complete dev Postman API collection
This commit is contained in:
Alexandre Brandizzi 2026-09-15 16:39:49 -03:00 • committed by GitHub
parent 1a6edd255a
commit 13ce4b7e88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 19784 additions and 0 deletions

65
postman/README.md Normal file
View file

@ -0,0 +1,65 @@
# SeaHaven Dev Postman collection
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` | 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 `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.
## Generated requests and examples
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.
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`.
## Regenerate or check coverage
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,30 @@
{
"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"
}

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