From a90cb3e9e7b7fc28f6b688880270696cdb49e78c Mon Sep 17 00:00:00 2001 From: zgjimhaziri Date: Fri, 17 Apr 2026 15:30:38 +0200 Subject: [PATCH] SP-45: Add asset-registry validate command Three mutually exclusive modes: - --configuration/-c: validate a configuration JSON before import - --nodeKey: validate an already-stored node on the platform - -f: provide a full ValidateRequest file (multi-node/advanced) The CLI assembles the ValidateRequest envelope in the first two modes. Update SKILL.md, agentic guide, and command docs. Includes-AI-Code: true Made-with: Cursor --- .../skills/asset-registry-endpoints/SKILL.md | 42 ++- docs/user-guide/agentic-development-guide.md | 21 +- docs/user-guide/asset-registry-commands.md | 45 ++++ .../asset-registry/asset-registry-api.ts | 8 + .../asset-registry/asset-registry.service.ts | 76 +++++- src/commands/asset-registry/module.ts | 23 ++ .../asset-registry-module.spec.ts | 57 ++++ .../asset-registry-validate.spec.ts | 245 ++++++++++++++++++ 8 files changed, 506 insertions(+), 11 deletions(-) create mode 100644 tests/commands/asset-registry/asset-registry-validate.spec.ts diff --git a/.cursor/skills/asset-registry-endpoints/SKILL.md b/.cursor/skills/asset-registry-endpoints/SKILL.md index e2068ee..114603b 100644 --- a/.cursor/skills/asset-registry-endpoints/SKILL.md +++ b/.cursor/skills/asset-registry-endpoints/SKILL.md @@ -223,20 +223,46 @@ methodology — a 404 means the endpoint is not available. $CLI asset-registry methodology --assetType -p ``` -### Validate (POST — via config import) +### Validate -Use `config import --validate` to validate assets against their schema before -importing: +Validate a configuration before importing it: + +```bash +$CLI asset-registry validate --assetType \ + --packageKey --configuration '' -p +``` + +Or load configuration from a file: + +```bash +$CLI asset-registry validate --assetType \ + --packageKey -c config.json -p +``` + +Validate an already-stored node on the platform: + +```bash +$CLI asset-registry validate --assetType \ + --packageKey --nodeKey -p +``` + +For multi-node validation, provide a full `ValidateRequest` file: + +```bash +$CLI asset-registry validate --assetType -f request.json -p +``` + +You can also validate during import with `config import --validate`: ```bash $CLI config import -d --validate --overwrite -p ``` **Important**: If validation returns errors, do **not** proceed with the import. -Instead, fix the schema violations in the node JSON and re-run the command. If -you cannot resolve the errors automatically, present the validation results to -the user and ask whether they want to continue importing with invalid -configuration or stop to fix it manually. +Instead, fix the schema violations in the node JSON and re-validate. If you +cannot resolve the errors automatically, present the validation results to the +user and ask whether they want to continue importing with invalid configuration +or stop to fix it manually. ## Troubleshooting @@ -284,6 +310,8 @@ $CLI config import -d --validate --overwrite -p | `asset-registry list` | List all registered asset types | | `asset-registry get --assetType X` | Get the full descriptor for an asset type | | `asset-registry schema --assetType X` | Get the JSON Schema for the asset's configuration | +| `asset-registry validate --assetType X --packageKey P --configuration '{}'` | Validate a configuration before import | +| `asset-registry validate --assetType X --packageKey P --nodeKey K` | Validate a stored node | | `asset-registry examples --assetType X` | Get example configurations (if available) | | `asset-registry methodology --assetType X` | Get methodology / best-practices (if available) | | `config list` | List packages | diff --git a/docs/user-guide/agentic-development-guide.md b/docs/user-guide/agentic-development-guide.md index dd7e3c4..6ba5b71 100644 --- a/docs/user-guide/agentic-development-guide.md +++ b/docs/user-guide/agentic-development-guide.md @@ -73,15 +73,30 @@ Add a new JSON file in the `nodes/` directory: Set `schemaVersion` to the value from the asset descriptor's `assetSchema.version` field (returned by `asset-registry get`). The `spaceId` is required — omitting it causes import errors. -### 5. Validate and import +### 5. Validate + +Before importing, validate the asset configuration: + +```bash +content-cli asset-registry validate --assetType \ + --packageKey --configuration '{ ... }' +``` + +Or validate during import with the `--validate` flag: ```bash content-cli config import -d --validate --overwrite ``` -The `--validate` option performs schema validations for the assets. If there are no schema validations, then the package and its assets are imported. Otherwise, the validation errors are returned and the package import isn't performed. +If validation returns errors, fix the issues before importing. + +### 6. Import + +```bash +content-cli config import -d --overwrite +``` -This creates a new version in staging (not deployed) if there are no schema validation errors. To create a brand-new package instead of updating, omit `--overwrite`. +This creates a new version in staging (not deployed). To create a brand-new package instead of updating, omit `--overwrite`. To later export a staging version, use `--keysByVersion`: diff --git a/docs/user-guide/asset-registry-commands.md b/docs/user-guide/asset-registry-commands.md index 2e2a623..9d88994 100644 --- a/docs/user-guide/asset-registry-commands.md +++ b/docs/user-guide/asset-registry-commands.md @@ -71,6 +71,51 @@ Options: - `--assetType ` (required) – The asset type identifier - `--json` – Write the schema to a JSON file in the working directory +## Validate + +Validate asset configurations against the asset service's validation endpoint. + +### Validate a configuration before import + +Provide the configuration JSON inline or via a file. The CLI wraps it into the `ValidateRequest` envelope for you. + +``` +content-cli asset-registry validate --assetType BOARD_V2 \ + --packageKey my-pkg --configuration '{"components":[{"type":"kpi"}]}' +``` + +``` +content-cli asset-registry validate --assetType BOARD_V2 \ + --packageKey my-pkg -c config.json +``` + +### Validate an already-stored node + +``` +content-cli asset-registry validate --assetType BOARD_V2 \ + --packageKey my-pkg --nodeKey my-view +``` + +### Full request from file + +For multi-node validation or other advanced use, provide a JSON file containing the entire `ValidateRequest` body. + +``` +content-cli asset-registry validate --assetType BOARD_V2 -f request.json +``` + +### Options + +- `--assetType ` (required) – The asset type identifier +- `--packageKey ` – Package key containing the node +- `--nodeKey ` – Key of an already-stored node to validate on the platform +- `--configuration ` – Inline JSON of the configuration to validate before import +- `-c, --configFile ` – Path to a JSON file containing the configuration to validate before import +- `-f, --file ` – Path to a JSON file containing a full ValidateRequest (alternative to all other options) +- `--json` – Write the validation response to a JSON file in the working directory + +The options `--nodeKey`, `--configuration`/`-c`, and `-f` are mutually exclusive. + ## Get Examples Fetch example configurations for an asset type. Not all asset types provide examples. diff --git a/src/commands/asset-registry/asset-registry-api.ts b/src/commands/asset-registry/asset-registry-api.ts index 9777025..3fb9fe7 100644 --- a/src/commands/asset-registry/asset-registry-api.ts +++ b/src/commands/asset-registry/asset-registry-api.ts @@ -49,4 +49,12 @@ export class AssetRegistryApi { throw new FatalError(`Problem getting methodology for asset type '${assetType}': ${e}`); }); } + + public async validate(assetType: string, body: any): Promise { + return this.httpClient() + .post(`/pacman/api/core/asset-registry/validate/${encodeURIComponent(assetType)}`, body) + .catch((e) => { + throw new FatalError(`Problem validating asset type '${assetType}': ${e}`); + }); + } } diff --git a/src/commands/asset-registry/asset-registry.service.ts b/src/commands/asset-registry/asset-registry.service.ts index 47a26f9..8e53df9 100644 --- a/src/commands/asset-registry/asset-registry.service.ts +++ b/src/commands/asset-registry/asset-registry.service.ts @@ -2,8 +2,9 @@ import { AssetRegistryApi } from "./asset-registry-api"; import { AssetRegistryDescriptor } from "./asset-registry.interfaces"; import { Context } from "../../core/command/cli-context"; import { fileService, FileService } from "../../core/utils/file-service"; -import { logger } from "../../core/utils/logger"; +import { FatalError, logger } from "../../core/utils/logger"; import { v4 as uuidv4 } from "uuid"; +import * as fs from "fs"; export class AssetRegistryService { private api: AssetRegistryApi; @@ -58,6 +59,69 @@ export class AssetRegistryService { this.outputResponse(data, jsonResponse); } + public async validate(opts: ValidateOptions): Promise { + const payload = this.buildValidatePayload(opts); + const data = await this.api.validate(opts.assetType, payload); + this.outputResponse(data, opts.json); + } + + private buildValidatePayload(opts: ValidateOptions): any { + const hasNodeKey = !!opts.nodeKey; + const hasConfig = !!(opts.configuration || opts.configFile); + const hasFile = !!opts.file; + + const modeCount = [hasNodeKey, hasConfig, hasFile].filter(Boolean).length; + if (modeCount === 0) { + throw new FatalError( + "Provide one of: --nodeKey (stored node), --configuration/-c (configuration JSON), or -f (full request file)." + ); + } + if (modeCount > 1) { + throw new FatalError( + "Options --nodeKey, --configuration/-c, and -f are mutually exclusive." + ); + } + + if (hasFile) { + return this.parseJson(fs.readFileSync(opts.file!, "utf-8"), `-f ${opts.file}`); + } + + if (!opts.packageKey) { + throw new FatalError("--packageKey is required when using --nodeKey or --configuration/-c."); + } + + if (hasNodeKey) { + return { + assetType: opts.assetType, + packageKey: opts.packageKey, + nodeKeys: [opts.nodeKey], + }; + } + + if (opts.configuration && opts.configFile) { + throw new FatalError("Provide either --configuration or -c, not both."); + } + + const configJson = this.parseJson( + opts.configFile ? fs.readFileSync(opts.configFile, "utf-8") : opts.configuration!, + opts.configFile ? `-c ${opts.configFile}` : "--configuration" + ); + + return { + assetType: opts.assetType, + packageKey: opts.packageKey, + nodes: [{ key: "validation-node", configuration: configJson }], + }; + } + + private parseJson(raw: string, source: string): any { + try { + return JSON.parse(raw); + } catch { + throw new FatalError(`Invalid JSON in ${source}.`); + } + } + private outputResponse(data: any, jsonResponse: boolean): void { if (jsonResponse) { const filename = uuidv4() + ".json"; @@ -94,3 +158,13 @@ export class AssetRegistryService { } } } + +export interface ValidateOptions { + assetType: string; + packageKey?: string; + nodeKey?: string; + configuration?: string; + configFile?: string; + file?: string; + json: boolean; +} diff --git a/src/commands/asset-registry/module.ts b/src/commands/asset-registry/module.ts index 6d2d92c..38214d9 100644 --- a/src/commands/asset-registry/module.ts +++ b/src/commands/asset-registry/module.ts @@ -32,6 +32,17 @@ class Module extends IModule { .option("--json", "Return the response as a JSON file") .action(this.getExamples); + assetRegistryCommand.command("validate") + .description("Validate asset configuration against the asset service's validate endpoint.") + .requiredOption("--assetType ", "The asset type identifier (e.g., BOARD_V2)") + .option("--packageKey ", "Package key containing the node") + .option("--nodeKey ", "Key of an already-stored node to validate on the platform") + .option("--configuration ", "Inline JSON of the configuration to validate before import") + .option("-c, --configFile ", "Path to a JSON file containing the configuration to validate before import") + .option("-f, --file ", "Path to a JSON file containing a full ValidateRequest (alternative to all other options)") + .option("--json", "Return the response as a JSON file") + .action(this.validate); + assetRegistryCommand.command("methodology") .description("Get the methodology / best-practices guide for an asset type") .requiredOption("--assetType ", "The asset type identifier (e.g., BOARD_V2)") @@ -51,6 +62,18 @@ class Module extends IModule { await new AssetRegistryService(context).getSchema(options.assetType, !!options.json); } + private async validate(context: Context, command: Command, options: OptionValues): Promise { + await new AssetRegistryService(context).validate({ + assetType: options.assetType, + packageKey: options.packageKey, + nodeKey: options.nodeKey, + configuration: options.configuration, + configFile: options.configFile, + file: options.file, + json: !!options.json, + }); + } + private async getExamples(context: Context, command: Command, options: OptionValues): Promise { await new AssetRegistryService(context).getExamples(options.assetType, !!options.json); } diff --git a/tests/commands/asset-registry/asset-registry-module.spec.ts b/tests/commands/asset-registry/asset-registry-module.spec.ts index 7585422..f856085 100644 --- a/tests/commands/asset-registry/asset-registry-module.spec.ts +++ b/tests/commands/asset-registry/asset-registry-module.spec.ts @@ -19,6 +19,7 @@ describe("Asset Registry Module", () => { listTypes: jest.fn().mockResolvedValue(undefined), getType: jest.fn().mockResolvedValue(undefined), getSchema: jest.fn().mockResolvedValue(undefined), + validate: jest.fn().mockResolvedValue(undefined), getExamples: jest.fn().mockResolvedValue(undefined), getMethodology: jest.fn().mockResolvedValue(undefined), } as any; @@ -33,6 +34,62 @@ describe("Asset Registry Module", () => { expect(mockService.getSchema).toHaveBeenCalledWith("BOARD_V2", true); }); + it("should call validate with configuration mode options", async () => { + const options: OptionValues = { + assetType: "BOARD_V2", + packageKey: "my-pkg", + configuration: '{"components":[]}', + json: true, + }; + await (module as any).validate(testContext, mockCommand, options); + expect(mockService.validate).toHaveBeenCalledWith({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + nodeKey: undefined, + configuration: '{"components":[]}', + configFile: undefined, + file: undefined, + json: true, + }); + }); + + it("should call validate with nodeKey mode options", async () => { + const options: OptionValues = { + assetType: "BOARD_V2", + packageKey: "my-pkg", + nodeKey: "my-view", + json: "", + }; + await (module as any).validate(testContext, mockCommand, options); + expect(mockService.validate).toHaveBeenCalledWith({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + nodeKey: "my-view", + configuration: undefined, + configFile: undefined, + file: undefined, + json: false, + }); + }); + + it("should call validate with file mode options", async () => { + const options: OptionValues = { + assetType: "BOARD_V2", + file: "request.json", + json: "", + }; + await (module as any).validate(testContext, mockCommand, options); + expect(mockService.validate).toHaveBeenCalledWith({ + assetType: "BOARD_V2", + packageKey: undefined, + nodeKey: undefined, + configuration: undefined, + configFile: undefined, + file: "request.json", + json: false, + }); + }); + it("should call getExamples with correct parameters", async () => { const options: OptionValues = { assetType: "BOARD_V2", json: "" }; await (module as any).getExamples(testContext, mockCommand, options); diff --git a/tests/commands/asset-registry/asset-registry-validate.spec.ts b/tests/commands/asset-registry/asset-registry-validate.spec.ts new file mode 100644 index 0000000..e006997 --- /dev/null +++ b/tests/commands/asset-registry/asset-registry-validate.spec.ts @@ -0,0 +1,245 @@ +import { mockAxiosPost, mockedPostRequestBodyByUrl } from "../../utls/http-requests-mock"; +import { AssetRegistryService } from "../../../src/commands/asset-registry/asset-registry.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; +import * as path from "path"; +import * as fs from "fs"; + +jest.mock("fs", () => ({ + ...jest.requireActual("fs"), + readFileSync: jest.fn(), +})); + +describe("Asset registry validate", () => { + const validateResponse = { + valid: false, + diagnostics: [ + { + severity: "ERROR", + nodeKey: "my-view", + assetType: "BOARD_V2", + path: "$.components[0].type", + code: "INVALID_ENUM_VALUE", + message: "Invalid component type", + }, + ], + }; + + const mockUrl = "https://myTeam.celonis.cloud/pacman/api/core/asset-registry/validate/BOARD_V2"; + + describe("configuration mode (validate before import)", () => { + it("Should validate with inline --configuration and print result", async () => { + mockAxiosPost(mockUrl, validateResponse); + + await new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + configuration: '{"components":[{"type":"bad"}]}', + json: false, + }); + + expect(loggingTestTransport.logMessages.length).toBe(1); + expect(loggingTestTransport.logMessages[0].message).toContain("INVALID_ENUM_VALUE"); + }); + + it("Should validate with --configuration and save as JSON file", async () => { + mockAxiosPost(mockUrl, validateResponse); + + await new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + configuration: '{"components":[{"type":"bad"}]}', + json: true, + }); + + const msg = loggingTestTransport.logMessages[0].message; + const expectedFileName = msg.split(FileService.fileDownloadedMessage)[1]; + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.resolve(process.cwd(), expectedFileName), + expect.any(String), + { encoding: "utf-8" } + ); + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1]); + expect(written.valid).toBe(false); + }); + + it("Should validate with config from file (-c)", async () => { + (fs.readFileSync as jest.Mock).mockReturnValue('{"components":[{"type":"bad"}]}'); + mockAxiosPost(mockUrl, validateResponse); + + await new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + configFile: "config.json", + json: false, + }); + + expect(fs.readFileSync).toHaveBeenCalledWith("config.json", "utf-8"); + expect(loggingTestTransport.logMessages[0].message).toContain("INVALID_ENUM_VALUE"); + }); + + it("Should build the envelope with nodes[] containing the configuration", async () => { + mockAxiosPost(mockUrl, validateResponse); + + await new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + configuration: '{"components":[]}', + json: false, + }); + + const captured = mockedPostRequestBodyByUrl.get(mockUrl); + const parsed = typeof captured === "string" ? JSON.parse(captured) : captured; + expect(parsed).toEqual({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + nodes: [{ key: "validation-node", configuration: { components: [] } }], + }); + }); + + it("Should throw when both --configuration and -c are provided", async () => { + await expect( + new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + configuration: "{}", + configFile: "config.json", + json: false, + }) + ).rejects.toThrow("Provide either --configuration or -c, not both."); + }); + + it("Should throw when --configuration is not valid JSON", async () => { + await expect( + new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + configuration: "not-json{", + json: false, + }) + ).rejects.toThrow("Invalid JSON in --configuration."); + }); + + it("Should throw when --packageKey is missing", async () => { + await expect( + new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + configuration: "{}", + json: false, + }) + ).rejects.toThrow("--packageKey is required"); + }); + }); + + describe("nodeKey mode (validate stored node)", () => { + it("Should build the envelope with nodeKeys[]", async () => { + mockAxiosPost(mockUrl, validateResponse); + + await new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + nodeKey: "my-view", + json: false, + }); + + const captured = mockedPostRequestBodyByUrl.get(mockUrl); + const parsed = typeof captured === "string" ? JSON.parse(captured) : captured; + expect(parsed).toEqual({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + nodeKeys: ["my-view"], + }); + }); + + it("Should throw when --packageKey is missing", async () => { + await expect( + new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + nodeKey: "my-view", + json: false, + }) + ).rejects.toThrow("--packageKey is required"); + }); + }); + + describe("file mode (full ValidateRequest from file)", () => { + const fullRequest = { + assetType: "BOARD_V2", + packageKey: "my-pkg", + nodes: [{ key: "my-view", configuration: { components: [{ type: "bad" }] } }], + }; + + it("Should validate with -f file and print result", async () => { + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(fullRequest)); + mockAxiosPost(mockUrl, validateResponse); + + await new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + file: "request.json", + json: false, + }); + + expect(fs.readFileSync).toHaveBeenCalledWith("request.json", "utf-8"); + expect(loggingTestTransport.logMessages[0].message).toContain("INVALID_ENUM_VALUE"); + }); + + it("Should throw when -f file contains invalid JSON", async () => { + (fs.readFileSync as jest.Mock).mockReturnValue("not-json{"); + + await expect( + new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + file: "bad.json", + json: false, + }) + ).rejects.toThrow("Invalid JSON in -f bad.json."); + }); + }); + + describe("mutual exclusivity", () => { + it("Should throw when --nodeKey and --configuration are both provided", async () => { + await expect( + new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + nodeKey: "my-view", + configuration: "{}", + json: false, + }) + ).rejects.toThrow("mutually exclusive"); + }); + + it("Should throw when --nodeKey and -f are both provided", async () => { + await expect( + new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + nodeKey: "my-view", + file: "request.json", + json: false, + }) + ).rejects.toThrow("mutually exclusive"); + }); + + it("Should throw when --configuration and -f are both provided", async () => { + await expect( + new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + configuration: "{}", + file: "request.json", + json: false, + }) + ).rejects.toThrow("mutually exclusive"); + }); + + it("Should throw when no mode options are provided", async () => { + await expect( + new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + json: false, + }) + ).rejects.toThrow("Provide one of"); + }); + }); +});