@intent-driven/importer-openapi
Advanced tools
| /** | ||
| * Kubernetes Custom Resources в OpenAPI schemas часто именуются как | ||
| * `v<digits>(alpha|beta)?<digits>?<Name>` — `v1alpha1Application`, | ||
| * `v1beta1Cluster`, `v2HorizontalPodAutoscaler`. Importer создаёт две | ||
| * раздельные entities: | ||
| * - path-derived stub `Application` (fields:{id}, kind:"internal") из | ||
| * `/api/v1/applications/{name}` через `entityNameFromPath` | ||
| * - schema-derived `v1alpha1Application` (все поля, kind:"embedded" из-за | ||
| * markEmbeddedTypes — wrapper `v1alpha1ApplicationList` референсит его) | ||
| * | ||
| * Intents.creates="Application" → dangling reference на stub. Host | ||
| * обходит через явные merge-таблицы (ArgoCD `K8S_CRD_MERGE` в 10 pairs). | ||
| * | ||
| * Этот helper автоматизирует pattern'ы типа `v<ver><PascalName>` с точным | ||
| * match'ем short-name (case-insensitive для PascalCase mismatch'а вроде | ||
| * `Applicationset` стаба vs `ApplicationSet` schema). Merge: | ||
| * - stub.id побеждает (синтетический PK) | ||
| * - full.fields заполняют все недостающие поля | ||
| * - kind переводится в "internal" (stub был top-level через path) | ||
| * - full остаётся в entities под оригинальным именем для wrapper-refs | ||
| * (`v1alpha1ApplicationList.items[]` → `v1alpha1Application`). Author | ||
| * может opt-in через `opts.stripOriginal: true` + rewrite references. | ||
| * | ||
| * Не handled автоматически (требуют host-override): | ||
| * - Name mismatch: `Project` (path) ← `v1alpha1AppProject` (schema) | ||
| * - Semantic alias: `Gpgkey` (path) ← `v1alpha1GnuPGPublicKey` | ||
| * | ||
| * Closes ArgoCD G-A-1 (sdk-improvements-backlog §10). | ||
| * | ||
| * @param {Record<string, object>} entities | ||
| * @param {object} [opts] | ||
| * @param {boolean} [opts.stripOriginal=false] — удалить v<ver><Name> после merge | ||
| * @returns {{ entities: Record<string, object>, aliases: Record<string, string> }} | ||
| */ | ||
| const K8S_VERSION_RE = /^v\d+(?:alpha\d*|beta\d*)?([A-Z]\w+)$/; | ||
| export function mergeK8sCrdDuplicates(entities, opts = {}) { | ||
| const stripOriginal = !!opts.stripOriginal; | ||
| const result = { ...entities }; | ||
| const aliases = {}; | ||
| // Быстрый lookup существующих entity ключей case-insensitive для PascalCase | ||
| // mismatch (`Applicationset` vs `ApplicationSet`). | ||
| const ciIndex = new Map(); | ||
| for (const key of Object.keys(result)) { | ||
| ciIndex.set(key.toLowerCase(), key); | ||
| } | ||
| for (const longName of Object.keys(entities)) { | ||
| const m = longName.match(K8S_VERSION_RE); | ||
| if (!m) continue; | ||
| const schemaName = m[1]; // "Application" из "v1alpha1Application" | ||
| const stubKey = ciIndex.get(schemaName.toLowerCase()); | ||
| if (!stubKey || stubKey === longName) continue; | ||
| const stub = result[stubKey]; | ||
| const full = result[longName]; | ||
| if (!stub || !full) continue; | ||
| // Merge: stub.fields имеют приоритет (id-stub), full заполняет остальное | ||
| result[stubKey] = { | ||
| ...full, | ||
| ...stub, | ||
| name: stub.name || stubKey, | ||
| fields: { ...(full.fields || {}), ...(stub.fields || {}) }, | ||
| relations: { ...(full.relations || {}), ...(stub.relations || {}) }, | ||
| kind: "internal", | ||
| }; | ||
| aliases[longName] = stubKey; | ||
| if (stripOriginal) { | ||
| delete result[longName]; | ||
| } | ||
| } | ||
| return { entities: result, aliases }; | ||
| } |
| import { describe, it, expect } from "vitest"; | ||
| import { mergeK8sCrdDuplicates } from "./mergeK8sCrdDuplicates.js"; | ||
| describe("mergeK8sCrdDuplicates", () => { | ||
| it("merges v1alpha1Application → Application (path stub с id + schema с полями)", () => { | ||
| const entities = { | ||
| Application: { name: "Application", kind: "internal", fields: { id: { type: "string" } } }, | ||
| v1alpha1Application: { | ||
| name: "v1alpha1Application", | ||
| kind: "embedded", | ||
| fields: { | ||
| metadata: { type: "object" }, | ||
| spec: { type: "object" }, | ||
| status: { type: "object" }, | ||
| }, | ||
| }, | ||
| }; | ||
| const { entities: merged, aliases } = mergeK8sCrdDuplicates(entities); | ||
| expect(merged.Application.kind).toBe("internal"); | ||
| expect(Object.keys(merged.Application.fields)).toEqual( | ||
| expect.arrayContaining(["id", "metadata", "spec", "status"]), | ||
| ); | ||
| // id stub preserves (синтетический PK) | ||
| expect(merged.Application.fields.id).toEqual({ type: "string" }); | ||
| expect(aliases.v1alpha1Application).toBe("Application"); | ||
| // Default: v1alpha1X сохранён для wrapper-refs | ||
| expect(merged.v1alpha1Application).toBeDefined(); | ||
| }); | ||
| it("поддерживает v1, v1alpha, v1alpha1, v1beta2 etc.", () => { | ||
| const entities = { | ||
| Application: { name: "Application", fields: { id: { type: "string" } } }, | ||
| Cluster: { name: "Cluster", fields: { id: { type: "string" } } }, | ||
| Pod: { name: "Pod", fields: { id: { type: "string" } } }, | ||
| Hpa: { name: "Hpa", fields: { id: { type: "string" } } }, | ||
| v1alpha1Application: { fields: { spec: { type: "object" } } }, | ||
| v1beta2Cluster: { fields: { server: { type: "string" } } }, | ||
| v1Pod: { fields: { status: { type: "object" } } }, | ||
| v2Hpa: { fields: { minReplicas: { type: "integer" } } }, | ||
| }; | ||
| const { aliases } = mergeK8sCrdDuplicates(entities); | ||
| expect(aliases.v1alpha1Application).toBe("Application"); | ||
| expect(aliases.v1beta2Cluster).toBe("Cluster"); | ||
| expect(aliases.v1Pod).toBe("Pod"); | ||
| expect(aliases.v2Hpa).toBe("Hpa"); | ||
| }); | ||
| it("case-insensitive lookup: Applicationset stub ← v1alpha1ApplicationSet", () => { | ||
| const entities = { | ||
| Applicationset: { name: "Applicationset", fields: { id: { type: "string" } } }, | ||
| v1alpha1ApplicationSet: { | ||
| fields: { metadata: { type: "object" }, spec: { type: "object" } }, | ||
| }, | ||
| }; | ||
| const { entities: merged, aliases } = mergeK8sCrdDuplicates(entities); | ||
| expect(aliases.v1alpha1ApplicationSet).toBe("Applicationset"); | ||
| expect(Object.keys(merged.Applicationset.fields)).toContain("spec"); | ||
| }); | ||
| it("skip если нет short-name stub'а (no path-derived entity)", () => { | ||
| const entities = { | ||
| v1alpha1AppProject: { fields: { spec: { type: "object" } } }, | ||
| // Нет path-derived "AppProject" или "Project" stub'а | ||
| }; | ||
| const { entities: merged, aliases } = mergeK8sCrdDuplicates(entities); | ||
| expect(aliases).toEqual({}); | ||
| expect(merged.v1alpha1AppProject).toBeDefined(); | ||
| }); | ||
| it("skip на не-K8s pattern'ах (RealmRepresentation, PolicyDTO etc.)", () => { | ||
| const entities = { | ||
| Realm: { fields: { id: { type: "string" } } }, | ||
| RealmRepresentation: { fields: { enabled: { type: "boolean" } } }, | ||
| Role: { fields: { id: { type: "string" } } }, | ||
| RoleDTO: { fields: { name: { type: "string" } } }, | ||
| version1Foo: { fields: { x: { type: "string" } } }, // v<digit> required | ||
| v: { fields: {} }, // no digits | ||
| }; | ||
| const { aliases } = mergeK8sCrdDuplicates(entities); | ||
| expect(aliases).toEqual({}); | ||
| }); | ||
| it("stripOriginal:true удаляет v1alpha1X entity после merge", () => { | ||
| const entities = { | ||
| Application: { fields: { id: { type: "string" } } }, | ||
| v1alpha1Application: { fields: { spec: { type: "object" } } }, | ||
| }; | ||
| const { entities: merged, aliases } = mergeK8sCrdDuplicates(entities, { | ||
| stripOriginal: true, | ||
| }); | ||
| expect(merged.v1alpha1Application).toBeUndefined(); | ||
| expect(aliases.v1alpha1Application).toBe("Application"); | ||
| }); | ||
| it("field conflict: stub.name побеждает over full.name (PK convention)", () => { | ||
| const entities = { | ||
| Application: { fields: { id: { type: "string", readOnly: true } } }, | ||
| v1alpha1Application: { | ||
| fields: { | ||
| id: { type: "string", description: "override from full" }, | ||
| spec: { type: "object" }, | ||
| }, | ||
| }, | ||
| }; | ||
| const { entities: merged } = mergeK8sCrdDuplicates(entities); | ||
| // stub.id побеждает (readOnly:true), full.id теряется | ||
| expect(merged.Application.fields.id.readOnly).toBe(true); | ||
| expect(merged.Application.fields.id.description).toBeUndefined(); | ||
| }); | ||
| it("kind: 'internal' всегда для merged entity (unmark embedded из full)", () => { | ||
| const entities = { | ||
| Application: { kind: "internal", fields: { id: {} } }, | ||
| v1alpha1Application: { kind: "embedded", fields: { spec: { type: "object" } } }, | ||
| }; | ||
| const { entities: merged } = mergeK8sCrdDuplicates(entities); | ||
| expect(merged.Application.kind).toBe("internal"); | ||
| }); | ||
| it("relations мёрджатся (для FK синтеза)", () => { | ||
| const entities = { | ||
| Application: { | ||
| fields: { id: {} }, | ||
| relations: { parent: { to: "Project" } }, | ||
| }, | ||
| v1alpha1Application: { | ||
| fields: { spec: { type: "object" } }, | ||
| relations: { cluster: { to: "Cluster" } }, | ||
| }, | ||
| }; | ||
| const { entities: merged } = mergeK8sCrdDuplicates(entities); | ||
| expect(merged.Application.relations).toEqual({ | ||
| parent: { to: "Project" }, | ||
| cluster: { to: "Cluster" }, | ||
| }); | ||
| }); | ||
| it("ArgoCD fixture — типичный разброс (Application, Cluster, ApplicationSet)", () => { | ||
| const entities = { | ||
| // Path-derived stubs (короткие, {id}-only) | ||
| Application: { name: "Application", kind: "internal", fields: { id: { type: "string" } } }, | ||
| Cluster: { name: "Cluster", kind: "internal", fields: { id: { type: "string" } } }, | ||
| Applicationset: { name: "Applicationset", kind: "internal", fields: { id: { type: "string" } } }, | ||
| // Schema-derived (полные, embedded) | ||
| v1alpha1Application: { kind: "embedded", fields: { metadata: {}, operation: {}, spec: {}, status: {} } }, | ||
| v1alpha1Cluster: { kind: "embedded", fields: { name: {}, server: {}, connectionState: {}, config: {} } }, | ||
| v1alpha1ApplicationSet: { kind: "embedded", fields: { metadata: {}, spec: {}, status: {} } }, | ||
| // Wrapper types — не должны трогаться | ||
| v1alpha1ApplicationList: { kind: "embedded", fields: { items: {}, metadata: {} } }, | ||
| }; | ||
| const { entities: merged, aliases } = mergeK8sCrdDuplicates(entities); | ||
| expect(Object.keys(aliases)).toEqual( | ||
| expect.arrayContaining([ | ||
| "v1alpha1Application", | ||
| "v1alpha1Cluster", | ||
| "v1alpha1ApplicationSet", | ||
| ]), | ||
| ); | ||
| // Wrapper не помечен (нет ApplicationList stub'а) | ||
| expect(aliases.v1alpha1ApplicationList).toBeUndefined(); | ||
| expect(Object.keys(merged.Application.fields).sort()).toEqual( | ||
| ["id", "metadata", "operation", "spec", "status"], | ||
| ); | ||
| expect(Object.keys(merged.Cluster.fields)).toContain("server"); | ||
| expect(Object.keys(merged.Applicationset.fields)).toContain("spec"); | ||
| }); | ||
| }); |
+1
-1
| { | ||
| "name": "@intent-driven/importer-openapi", | ||
| "version": "0.11.0", | ||
| "version": "0.12.0", | ||
| "description": "OpenAPI 3 spec → IDF ontology", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+2
-0
@@ -11,2 +11,3 @@ import { resolveRef } from "./resolveRef.js"; | ||
| } from "./mergeRepresentationDuplicates.js"; | ||
| import { mergeK8sCrdDuplicates } from "./mergeK8sCrdDuplicates.js"; | ||
| import { markEmbeddedTypes } from "./markEmbeddedTypes.js"; | ||
@@ -24,2 +25,3 @@ import { inferFieldRole, inferFieldRolesForEntities } from "./inferFieldRoles.js"; | ||
| rewriteIntentTargetsByAliases, | ||
| mergeK8sCrdDuplicates, | ||
| markEmbeddedTypes, | ||
@@ -26,0 +28,0 @@ inferFieldRole, |
78064
16.52%18
12.5%1817
14.42%