Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/generate-kwp-scoped-contract.mjs
T

342 lines
14 KiB
JavaScript

#!/usr/bin/env node
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname, resolve } from "node:path";
const EXTERNAL_SCOPES = Object.freeze(["adresse-extern", "archiv-extern", "termin-extern", "vorgang-extern", "aktivitaet-extern"]);
const CAPABILITY_BY_TAG = Object.freeze({
Adresse: "address",
Aktivitaet: "activity",
Aktivitaetenverwaltung: "activity",
ArchivExternal: "archive",
Monteursteuerung: "schedule",
Projekt: "project",
Projektampel: "project",
Termin: "schedule",
VorgangAnzeige: "project",
VorgangInfo: "project",
});
const MUTATION_PATH_PATTERN = /\/(?:Add|Delete|Import|Publish|Remove|Reset|Save)[^/]*$/u;
const SUPPORTED_METHODS = new Set(["delete", "get", "post", "put", "patch"]);
const CONTRACT_OUTPUT = "packages/integrations/src/kwp/contract/kwp-scoped-openapi.json";
const CATALOG_OUTPUT = "packages/integrations/src/kwp/contract/catalog.generated.ts";
const SCOPE_DOC_OUTPUT = "docs/integrations/KWP-API-SCOPE.md";
const [inputArg, contractOutputArg, scopeDocOutputArg, catalogOutputArg] = process.argv.slice(2);
if (!inputArg) {
process.stderr.write("Usage: node scripts/generate-kwp-scoped-contract.mjs <tenant-openapi.json> [contract-output] [scope-doc-output] [catalog-output]\n");
process.exitCode = 1;
} else {
await generate({
inputPath: resolve(inputArg),
contractOutputPath: resolve(contractOutputArg ?? CONTRACT_OUTPUT),
scopeDocOutputPath: resolve(scopeDocOutputArg ?? SCOPE_DOC_OUTPUT),
catalogOutputPath: resolve(catalogOutputArg ?? CATALOG_OUTPUT),
});
}
async function generate({ inputPath, contractOutputPath, scopeDocOutputPath, catalogOutputPath }) {
const source = JSON.parse(await readFile(inputPath, "utf8"));
assertOpenApi(source);
const allOperations = listOperations(source);
const scopedOperations = allOperations
.filter((operation) => operation.scopes.some((scope) => EXTERNAL_SCOPES.includes(scope)))
.map((operation) => ({
...operation,
capability: requireCapability(operation.tag),
mode: MUTATION_PATH_PATTERN.test(operation.path) ? "mutation" : "read",
}))
.sort(compareOperations);
assertInventory(allOperations, scopedOperations);
const duplicatePaths = new Set(
scopedOperations
.filter((operation, index, operations) => operations.some((candidate, candidateIndex) => candidateIndex !== index && candidate.path === operation.path))
.map((operation) => operation.path)
);
const paths = {};
for (const operation of scopedOperations) {
const operationId = buildOperationId(operation, duplicatePaths);
const operationContract = stripDocumentationMetadata(structuredClone(operation.contract));
operationContract.operationId = operationId;
operationContract["x-hivegrid-capability"] = operation.capability;
operationContract["x-hivegrid-mode"] = operation.mode;
paths[operation.path] ??= {};
paths[operation.path][operation.method] = operationContract;
}
const schemaNames = collectReferencedSchemaNames(paths, source.components.schemas);
const schemas = Object.fromEntries(
[...schemaNames].sort((left, right) => left.localeCompare(right)).map((name) => [name, stripDocumentationMetadata(source.components.schemas[name])])
);
const inventory = {
sourceOperationCount: allOperations.length,
scopedOperationCount: scopedOperations.length,
readOperationCount: scopedOperations.filter((operation) => operation.mode === "read").length,
mutationOperationCount: scopedOperations.filter((operation) => operation.mode === "mutation").length,
excludedOperationCount: allOperations.length - scopedOperations.length,
externalScopes: EXTERNAL_SCOPES,
capabilities: Object.fromEntries(
[...new Set(scopedOperations.map((operation) => operation.capability))].sort().map((capability) => [
capability,
{
total: scopedOperations.filter((operation) => operation.capability === capability).length,
read: scopedOperations.filter((operation) => operation.capability === capability && operation.mode === "read").length,
mutation: scopedOperations.filter((operation) => operation.capability === capability && operation.mode === "mutation").length,
},
])
),
};
const scopedContract = {
openapi: source.openapi,
info: {
title: "KWP Api, licensed scope",
version: source.info.version,
},
paths: sortObject(paths),
components: { schemas },
"x-hivegrid-inventory": inventory,
};
await mkdir(dirname(contractOutputPath), { recursive: true });
await writeFile(contractOutputPath, `${JSON.stringify(scopedContract, null, 2)}\n`);
await mkdir(dirname(scopeDocOutputPath), { recursive: true });
await writeFile(scopeDocOutputPath, renderScopeDocument(scopedOperations, inventory, inputPath));
await mkdir(dirname(catalogOutputPath), { recursive: true });
await writeFile(catalogOutputPath, renderTypeScriptCatalog(scopedOperations, duplicatePaths, inventory));
process.stdout.write(
`Generated ${scopedOperations.length} KWP scoped operations (${inventory.readOperationCount} read, ${inventory.mutationOperationCount} mutation).\n`
);
}
function assertOpenApi(value) {
if (
!value ||
typeof value !== "object" ||
typeof value.openapi !== "string" ||
!value.info ||
typeof value.info.version !== "string" ||
!value.paths ||
typeof value.paths !== "object" ||
!value.components?.schemas ||
typeof value.components.schemas !== "object"
) {
throw new Error("The input is not the expected KWP OpenAPI document");
}
}
function listOperations(source) {
const operations = [];
for (const [path, pathItem] of Object.entries(source.paths)) {
for (const [method, contract] of Object.entries(pathItem)) {
if (!SUPPORTED_METHODS.has(method) || !contract || typeof contract !== "object") continue;
const tag = Array.isArray(contract.tags) ? contract.tags[0] : undefined;
if (typeof tag !== "string" || tag.length === 0) {
throw new Error(`KWP operation ${method.toUpperCase()} ${path} has no tag`);
}
operations.push({
method,
path,
tag,
summary: typeof contract.summary === "string" ? contract.summary : "",
scopes: readSecurityScopes(contract.security),
contract,
});
}
}
return operations;
}
function readSecurityScopes(security) {
if (!Array.isArray(security)) return [];
return [
...new Set(
security.flatMap((entry) =>
entry && typeof entry === "object" && Array.isArray(entry.oauth2) ? entry.oauth2.filter((scope) => typeof scope === "string") : []
)
),
];
}
function requireCapability(tag) {
const capability = CAPABILITY_BY_TAG[tag];
if (!capability) throw new Error(`Scoped KWP tag ${tag} is not assigned to a capability`);
return capability;
}
function assertInventory(allOperations, scopedOperations) {
const readCount = scopedOperations.filter((operation) => operation.mode === "read").length;
const mutationCount = scopedOperations.length - readCount;
if (allOperations.length !== 554 || scopedOperations.length !== 113 || readCount !== 77 || mutationCount !== 36) {
throw new Error(`KWP inventory drifted: total=${allOperations.length}, scoped=${scopedOperations.length}, read=${readCount}, mutation=${mutationCount}`);
}
}
function buildOperationId(operation, duplicatePaths) {
const base = `${toIdentifier(operation.tag, false)}${toIdentifier(operation.path.split("/").at(-1), true)}`;
return duplicatePaths.has(operation.path) ? `${base}${toIdentifier(operation.method, true)}` : base;
}
function toIdentifier(value, upperFirst) {
const words = String(value ?? "").match(/[A-Za-z0-9]+/gu) ?? [];
const joined = words
.map((word, index) => {
const normalized = word[0].toUpperCase() + word.slice(1);
if (index === 0 && !upperFirst) return normalized[0].toLowerCase() + normalized.slice(1);
return normalized;
})
.join("");
if (!joined) throw new Error(`Cannot derive an operation identifier from ${value}`);
return joined;
}
function collectReferencedSchemaNames(value, allSchemas) {
const found = new Set();
const pending = [...findRefs(value)];
while (pending.length > 0) {
const ref = pending.pop();
const prefix = "#/components/schemas/";
if (!ref.startsWith(prefix)) {
throw new Error(`Unsupported KWP contract reference ${ref}`);
}
const name = decodeURIComponent(ref.slice(prefix.length));
if (found.has(name)) continue;
const schema = allSchemas[name];
if (!schema) throw new Error(`Missing KWP schema ${name}`);
found.add(name);
pending.push(...findRefs(schema));
}
return found;
}
function findRefs(value) {
if (Array.isArray(value)) return value.flatMap(findRefs);
if (!value || typeof value !== "object") return [];
return Object.entries(value).flatMap(([key, nested]) => (key === "$ref" && typeof nested === "string" ? [nested] : findRefs(nested)));
}
function stripDocumentationMetadata(value) {
if (Array.isArray(value)) return value.map(stripDocumentationMetadata);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value)
.filter(([key]) => key !== "description")
.map(([key, nested]) => [key, stripDocumentationMetadata(nested)])
);
}
function compareOperations(left, right) {
return (
left.capability.localeCompare(right.capability) ||
left.tag.localeCompare(right.tag) ||
left.path.localeCompare(right.path) ||
left.method.localeCompare(right.method)
);
}
function sortObject(value) {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nested]) => [key, nested && typeof nested === "object" && !Array.isArray(nested) ? sortObject(nested) : nested])
);
}
function renderScopeDocument(operations, inventory) {
const capabilityRows = Object.entries(inventory.capabilities)
.map(([capability, counts]) => `| ${capability} | ${counts.total} | ${counts.read} | ${counts.mutation} |`)
.join("\n");
const operationRows = operations
.map((operation) => {
const id = buildOperationId(
operation,
new Set(
operations
.filter((candidate, index, all) => all.some((other, otherIndex) => otherIndex !== index && other.path === candidate.path))
.map((candidate) => candidate.path)
)
);
return `| \`${id}\` | ${operation.capability} | ${operation.mode} | \`${operation.method.toUpperCase()}\` | \`${operation.path}\` | ${escapeTableText(operation.summary)} |`;
})
.join("\n");
return `# KWP API: lizenzierter HiveGrid-Scope
> Generiert aus der am 2026-07-24 secretsicher gelesenen Tenant-OpenAPI. Die kundenspezifische
> Roh-Spezifikation, Server-URL und Credentials werden nicht eingecheckt.
## Inventur
- OpenAPI-Operationen gesamt: **${inventory.sourceOperationCount}**
- Über die fünf HiveGrid-\`*-extern\`-Scopes erreichbar: **${inventory.scopedOperationCount}**
- Semantisch lesend und runtime-allowlisted: **${inventory.readOperationCount}**
- Mutierend, nur Contract/Fixture und runtime-gesperrt: **${inventory.mutationOperationCount}**
- Außerhalb des lizenzierten HiveGrid-Scopes: **${inventory.excludedOperationCount}**
| Capability | Gesamt | Read | Mutation |
|---|---:|---:|---:|
${capabilityRows}
Die OpenAPI-Security-Arrays nennen mehrere alternative KWP-Produkte und die fünf externen Scopes, obwohl
die OAuth-Flow-Scope-Liste diese externen Namen nicht aufführt. HiveGrid klassifiziert eine Operation nur
dann als scoped, wenn mindestens einer der fünf tatsächlich angeforderten externen Scopes am Endpoint steht.
## Sicherheitsvertrag
- Fachcode übergibt nur eine feste Operation-ID plus deren typisierten Input, niemals URL, Pfad, Token,
Mandant oder Kundenkürzel.
- \`Search\`, \`Load\`, \`Count\`, \`Get\` und \`Check\` können trotz \`POST\` read-only sein.
- \`Save\`, \`Add\`, \`Delete\`, \`Remove\`, \`Import\`, \`Publish\` und \`Reset\` sind Mutation.
- Mutation wird vor Rate-Budget, Relay-Auflösung und HTTP blockiert.
- Der Scoped-Contract enthält nur rekursiv benötigte Schemas und keine tenant-spezifische Server-URL.
## Operationen
| ID | Capability | Modus | Methode | Pfad | KWP-Zusammenfassung |
|---|---|---|---|---|---|
${operationRows}
`;
}
function renderTypeScriptCatalog(operations, duplicatePaths, inventory) {
const entries = operations
.map((operation) => {
const value = {
id: buildOperationId(operation, duplicatePaths),
capability: operation.capability,
mode: operation.mode,
method: operation.method.toUpperCase(),
path: operation.path,
};
return ` ${JSON.stringify(value)},`;
})
.join("\n");
return `// Generated by scripts/generate-kwp-scoped-contract.mjs. Do not edit manually.
export const KWP_SCOPED_OPERATION_INVENTORY = Object.freeze({
sourceOperationCount: ${inventory.sourceOperationCount},
scopedOperationCount: ${inventory.scopedOperationCount},
readOperationCount: ${inventory.readOperationCount},
mutationOperationCount: ${inventory.mutationOperationCount},
excludedOperationCount: ${inventory.excludedOperationCount},
} as const);
export const KWP_SCOPED_OPERATIONS = Object.freeze([
${entries}
] as const);
export type KwpScopedOperation = (typeof KWP_SCOPED_OPERATIONS)[number];
export type KwpScopedOperationId = KwpScopedOperation["id"];
export type KwpReadOperationId = Extract<KwpScopedOperation, { readonly mode: "read" }>["id"];
export type KwpMutationOperationId = Extract<KwpScopedOperation, { readonly mode: "mutation" }>["id"];
export type KwpCapability = KwpScopedOperation["capability"];
`;
}
function escapeTableText(value) {
return value.replaceAll("|", "\\|").replaceAll(/\s+/gu, " ").trim();
}