342 lines
14 KiB
JavaScript
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();
|
|
}
|