From a90cb3e9e7b7fc28f6b688880270696cdb49e78c Mon Sep 17 00:00:00 2001 From: zgjimhaziri Date: Fri, 17 Apr 2026 15:30:38 +0200 Subject: [PATCH 1/3] 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"); + }); + }); +}); From c2da4545b58389cab7f29d2aa89fad70df97565e Mon Sep 17 00:00:00 2001 From: zgjimhaziri Date: Mon, 27 Apr 2026 15:56:36 +0200 Subject: [PATCH 2/3] SP-45: Refactor options --- .../skills/asset-registry-endpoints/SKILL.md | 28 ++++--- docs/user-guide/asset-registry-commands.md | 55 +++++++++----- .../asset-registry.interfaces.ts | 9 +++ .../asset-registry/asset-registry.service.ts | 52 +++++-------- src/commands/asset-registry/module.ts | 10 +-- .../asset-registry-module.spec.ts | 7 +- .../asset-registry-validate.spec.ts | 76 +++++++++---------- 7 files changed, 125 insertions(+), 112 deletions(-) diff --git a/.cursor/skills/asset-registry-endpoints/SKILL.md b/.cursor/skills/asset-registry-endpoints/SKILL.md index 114603b..52d4ea1 100644 --- a/.cursor/skills/asset-registry-endpoints/SKILL.md +++ b/.cursor/skills/asset-registry-endpoints/SKILL.md @@ -225,28 +225,31 @@ $CLI asset-registry methodology --assetType -p ### Validate -Validate a configuration before importing it: +Two top-level modes: -```bash -$CLI asset-registry validate --assetType \ - --packageKey --configuration '' -p -``` +**Build-from-options** — `--packageKey` plus exactly one of `--nodeKey` (stored +node) or `--configuration` (raw configuration JSON). `--nodeKey` and +`--configuration` are mutually exclusive. -Or load configuration from a file: +Validate an already-stored node: ```bash $CLI asset-registry validate --assetType \ - --packageKey -c config.json -p + --packageKey --nodeKey -p ``` -Validate an already-stored node on the platform: +Validate a raw configuration before import: ```bash $CLI asset-registry validate --assetType \ - --packageKey --nodeKey -p + --packageKey \ + --configuration '' -p ``` -For multi-node validation, provide a full `ValidateRequest` file: +**`-f` / `--file` mode** — Provide a JSON file containing a full +`ValidateRequest` body. Use this for multi-node validation or any case the +build-from-options mode doesn't cover. Mutually exclusive with the +build-from-options flags. ```bash $CLI asset-registry validate --assetType -f request.json -p @@ -310,8 +313,9 @@ $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 validate --assetType X --packageKey P --nodeKey K` | Validate an already-stored node | +| `asset-registry validate --assetType X --packageKey P --configuration '{}'` | Validate a raw configuration before import | +| `asset-registry validate --assetType X -f request.json` | Validate using a full ValidateRequest file (multi-node, etc.) | | `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/asset-registry-commands.md b/docs/user-guide/asset-registry-commands.md index 9d88994..8f3e56e 100644 --- a/docs/user-guide/asset-registry-commands.md +++ b/docs/user-guide/asset-registry-commands.md @@ -73,49 +73,68 @@ Options: ## Validate -Validate asset configurations against the asset service's validation endpoint. +Validate asset configurations against the asset service's validation endpoint. There are two top-level modes: -### Validate a configuration before import +1. **Build-from-options mode** – `--packageKey` plus exactly one of: + - **`--nodeKey`** to validate an already-stored node on the platform, or + - **`--configuration`** to validate a raw configuration JSON before import. -Provide the configuration JSON inline or via a file. The CLI wraps it into the `ValidateRequest` envelope for you. + `--nodeKey` and `--configuration` are mutually exclusive. The CLI wraps the inputs into a `ValidateRequest` envelope for you. + +2. **File mode (`-f` / `--file`)** – Provide a JSON file containing the full `ValidateRequest` body. Use this for multi-node validation or any case the build-from-options mode doesn't cover. Mutually exclusive with `--packageKey`, `--nodeKey` and `--configuration`. + +### Validate an already-stored node (`--nodeKey`) ``` content-cli asset-registry validate --assetType BOARD_V2 \ - --packageKey my-pkg --configuration '{"components":[{"type":"kpi"}]}' + --packageKey my-pkg --nodeKey my-view ``` -``` -content-cli asset-registry validate --assetType BOARD_V2 \ - --packageKey my-pkg -c config.json +Sends: + +```json +{ + "assetType": "BOARD_V2", + "packageKey": "my-pkg", + "nodeKeys": ["my-view"] +} ``` -### Validate an already-stored node +### Validate a raw configuration (`--configuration`) ``` content-cli asset-registry validate --assetType BOARD_V2 \ - --packageKey my-pkg --nodeKey my-view + --packageKey my-pkg \ + --configuration '{"components":[{"type":"kpi"}]}' ``` -### Full request from file +Sends: + +```json +{ + "assetType": "BOARD_V2", + "packageKey": "my-pkg", + "nodes": [{ "key": "validation-node", "configuration": { "components": [{ "type": "kpi" }] } }] +} +``` -For multi-node validation or other advanced use, provide a JSON file containing the entire `ValidateRequest` body. +### Full request from file (`-f`) ``` content-cli asset-registry validate --assetType BOARD_V2 -f request.json ``` +Use this when you need control over the full body (e.g., multiple inline nodes with specific keys). + ### 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) +- `--packageKey ` – Package key. Required when validating with `--nodeKey` or `--configuration`. +- `--nodeKey ` – Key of an already-stored node to validate (use with `--packageKey`). +- `--configuration ` – Inline JSON of a configuration to validate (use with `--packageKey`). +- `-f, --file ` – Path to a JSON file containing a full `ValidateRequest` body. Mutually exclusive with the build-from-options flags. - `--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.interfaces.ts b/src/commands/asset-registry/asset-registry.interfaces.ts index 60cf5e7..23acc4b 100644 --- a/src/commands/asset-registry/asset-registry.interfaces.ts +++ b/src/commands/asset-registry/asset-registry.interfaces.ts @@ -33,3 +33,12 @@ export interface AssetContributions { dataPipelineEntityTypes: string[]; actionTypes: string[]; } + +export interface ValidateOptions { + assetType: string; + packageKey?: string; + nodeKey?: string; + configuration?: string; + file?: string; + json: boolean; +} diff --git a/src/commands/asset-registry/asset-registry.service.ts b/src/commands/asset-registry/asset-registry.service.ts index 8e53df9..3b57333 100644 --- a/src/commands/asset-registry/asset-registry.service.ts +++ b/src/commands/asset-registry/asset-registry.service.ts @@ -1,5 +1,5 @@ import { AssetRegistryApi } from "./asset-registry-api"; -import { AssetRegistryDescriptor } from "./asset-registry.interfaces"; +import { AssetRegistryDescriptor, ValidateOptions } from "./asset-registry.interfaces"; import { Context } from "../../core/command/cli-context"; import { fileService, FileService } from "../../core/utils/file-service"; import { FatalError, logger } from "../../core/utils/logger"; @@ -65,20 +65,16 @@ export class AssetRegistryService { this.outputResponse(data, opts.json); } + private static readonly INLINE_VALIDATION_NODE_KEY = "validation-node"; + private buildValidatePayload(opts: ValidateOptions): any { const hasNodeKey = !!opts.nodeKey; - const hasConfig = !!(opts.configuration || opts.configFile); + const hasConfig = !!opts.configuration; 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) { + if (hasFile && (hasNodeKey || hasConfig || !!opts.packageKey)) { throw new FatalError( - "Options --nodeKey, --configuration/-c, and -f are mutually exclusive." + "Option -f is mutually exclusive with --packageKey, --nodeKey and --configuration." ); } @@ -86,8 +82,18 @@ export class AssetRegistryService { return this.parseJson(fs.readFileSync(opts.file!, "utf-8"), `-f ${opts.file}`); } + if (hasNodeKey && hasConfig) { + throw new FatalError( + "Options --nodeKey and --configuration are mutually exclusive. Use --nodeKey to validate a stored node, or --configuration to validate a configuration. For full control, use -f." + ); + } + if (!hasNodeKey && !hasConfig) { + throw new FatalError( + "Provide --packageKey with one of --nodeKey (validate a stored node) or --configuration (validate a configuration), or use -f for a full request file." + ); + } if (!opts.packageKey) { - throw new FatalError("--packageKey is required when using --nodeKey or --configuration/-c."); + throw new FatalError("--packageKey is required when using --nodeKey or --configuration."); } if (hasNodeKey) { @@ -98,19 +104,11 @@ export class AssetRegistryService { }; } - 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" - ); - + const configJson = this.parseJson(opts.configuration!, "--configuration"); return { assetType: opts.assetType, packageKey: opts.packageKey, - nodes: [{ key: "validation-node", configuration: configJson }], + nodes: [{ key: AssetRegistryService.INLINE_VALIDATION_NODE_KEY, configuration: configJson }], }; } @@ -157,14 +155,4 @@ export class AssetRegistryService { logger.info(` examples: ${descriptor.endpoints.examples}`); } } -} - -export interface ValidateOptions { - assetType: string; - packageKey?: string; - nodeKey?: string; - configuration?: string; - configFile?: string; - file?: string; - json: boolean; -} +} \ No newline at end of file diff --git a/src/commands/asset-registry/module.ts b/src/commands/asset-registry/module.ts index 38214d9..9e2f5ec 100644 --- a/src/commands/asset-registry/module.ts +++ b/src/commands/asset-registry/module.ts @@ -35,11 +35,10 @@ class Module extends IModule { 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("--packageKey ", "Package key. Required when validating with --nodeKey or --configuration.") + .option("--nodeKey ", "Key of an already-stored node to validate (use with --packageKey).") + .option("--configuration ", "Inline JSON of a configuration to validate (use with --packageKey).") + .option("-f, --file ", "Path to a JSON file containing a full ValidateRequest body. Mutually exclusive with the build-from-options flags.") .option("--json", "Return the response as a JSON file") .action(this.validate); @@ -68,7 +67,6 @@ class Module extends IModule { packageKey: options.packageKey, nodeKey: options.nodeKey, configuration: options.configuration, - configFile: options.configFile, file: options.file, json: !!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 f856085..9ebd9ea 100644 --- a/tests/commands/asset-registry/asset-registry-module.spec.ts +++ b/tests/commands/asset-registry/asset-registry-module.spec.ts @@ -34,7 +34,7 @@ describe("Asset Registry Module", () => { expect(mockService.getSchema).toHaveBeenCalledWith("BOARD_V2", true); }); - it("should call validate with configuration mode options", async () => { + it("should call validate with --configuration sub-mode options", async () => { const options: OptionValues = { assetType: "BOARD_V2", packageKey: "my-pkg", @@ -47,13 +47,12 @@ describe("Asset Registry Module", () => { packageKey: "my-pkg", nodeKey: undefined, configuration: '{"components":[]}', - configFile: undefined, file: undefined, json: true, }); }); - it("should call validate with nodeKey mode options", async () => { + it("should call validate with --nodeKey sub-mode options", async () => { const options: OptionValues = { assetType: "BOARD_V2", packageKey: "my-pkg", @@ -66,7 +65,6 @@ describe("Asset Registry Module", () => { packageKey: "my-pkg", nodeKey: "my-view", configuration: undefined, - configFile: undefined, file: undefined, json: false, }); @@ -84,7 +82,6 @@ describe("Asset Registry Module", () => { packageKey: undefined, nodeKey: undefined, configuration: undefined, - configFile: undefined, file: "request.json", json: false, }); diff --git a/tests/commands/asset-registry/asset-registry-validate.spec.ts b/tests/commands/asset-registry/asset-registry-validate.spec.ts index e006997..2c05a90 100644 --- a/tests/commands/asset-registry/asset-registry-validate.spec.ts +++ b/tests/commands/asset-registry/asset-registry-validate.spec.ts @@ -28,7 +28,7 @@ describe("Asset registry validate", () => { const mockUrl = "https://myTeam.celonis.cloud/pacman/api/core/asset-registry/validate/BOARD_V2"; - describe("configuration mode (validate before import)", () => { + describe("--configuration sub-mode (validate a raw configuration)", () => { it("Should validate with inline --configuration and print result", async () => { mockAxiosPost(mockUrl, validateResponse); @@ -64,22 +64,7 @@ describe("Asset registry validate", () => { 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 () => { + it("Should build the envelope with nodes[] using a synthetic node key", async () => { mockAxiosPost(mockUrl, validateResponse); await new AssetRegistryService(testContext).validate({ @@ -98,18 +83,6 @@ describe("Asset registry validate", () => { }); }); - 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({ @@ -132,7 +105,7 @@ describe("Asset registry validate", () => { }); }); - describe("nodeKey mode (validate stored node)", () => { + describe("--nodeKey sub-mode (validate an already-stored node)", () => { it("Should build the envelope with nodeKeys[]", async () => { mockAxiosPost(mockUrl, validateResponse); @@ -163,7 +136,7 @@ describe("Asset registry validate", () => { }); }); - describe("file mode (full ValidateRequest from file)", () => { + describe("-f / --file mode (full ValidateRequest from file)", () => { const fullRequest = { assetType: "BOARD_V2", packageKey: "my-pkg", @@ -184,6 +157,21 @@ describe("Asset registry validate", () => { expect(loggingTestTransport.logMessages[0].message).toContain("INVALID_ENUM_VALUE"); }); + it("Should send the file body as-is (no envelope wrapping)", 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, + }); + + const captured = mockedPostRequestBodyByUrl.get(mockUrl); + const parsed = typeof captured === "string" ? JSON.parse(captured) : captured; + expect(parsed).toEqual(fullRequest); + }); + it("Should throw when -f file contains invalid JSON", async () => { (fs.readFileSync as jest.Mock).mockReturnValue("not-json{"); @@ -197,7 +185,7 @@ describe("Asset registry validate", () => { }); }); - describe("mutual exclusivity", () => { + describe("mutual exclusivity and missing modes", () => { it("Should throw when --nodeKey and --configuration are both provided", async () => { await expect( new AssetRegistryService(testContext).validate({ @@ -207,10 +195,21 @@ describe("Asset registry validate", () => { configuration: "{}", json: false, }) - ).rejects.toThrow("mutually exclusive"); + ).rejects.toThrow("--nodeKey and --configuration are mutually exclusive"); }); - it("Should throw when --nodeKey and -f are both provided", async () => { + it("Should throw when -f is combined with --packageKey", async () => { + await expect( + new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + file: "request.json", + json: false, + }) + ).rejects.toThrow("-f is mutually exclusive"); + }); + + it("Should throw when -f is combined with --nodeKey", async () => { await expect( new AssetRegistryService(testContext).validate({ assetType: "BOARD_V2", @@ -218,19 +217,18 @@ describe("Asset registry validate", () => { file: "request.json", json: false, }) - ).rejects.toThrow("mutually exclusive"); + ).rejects.toThrow("-f is mutually exclusive"); }); - it("Should throw when --configuration and -f are both provided", async () => { + it("Should throw when -f is combined with --configuration", async () => { await expect( new AssetRegistryService(testContext).validate({ assetType: "BOARD_V2", - packageKey: "my-pkg", configuration: "{}", file: "request.json", json: false, }) - ).rejects.toThrow("mutually exclusive"); + ).rejects.toThrow("-f is mutually exclusive"); }); it("Should throw when no mode options are provided", async () => { @@ -239,7 +237,7 @@ describe("Asset registry validate", () => { assetType: "BOARD_V2", json: false, }) - ).rejects.toThrow("Provide one of"); + ).rejects.toThrow("Provide --packageKey with one of --nodeKey"); }); }); }); From d8cc16eb543e6d97440c6b55d82b25fd518b89f9 Mon Sep 17 00:00:00 2001 From: zgjimhaziri Date: Mon, 27 Apr 2026 17:07:18 +0200 Subject: [PATCH 3/3] SP-45: Loosen writeFileSync options assertion to fix CI flake The "save as JSON file" test asserted exact equality of the third argument to fs.writeFileSync ({ encoding: "utf-8" }). On the GitHub Actions runner, the per-file `jest.mock("fs", () => ...requireActual)` factory in this spec interacts with jest.setup's mock such that the captured args also include `mode: 384` (= 0o600), even though the production code only passes `{ encoding: "utf-8" }`. The same call captures only `encoding` locally on macOS. Use `expect.objectContaining({ encoding: "utf-8" })` so the test verifies the field we actually care about and is resilient to runtime-injected options. Also restored the trailing newline on asset-registry.service.ts. Includes-AI-Code: true Made-with: Cursor --- src/commands/asset-registry/asset-registry.service.ts | 2 +- tests/commands/asset-registry/asset-registry-validate.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/asset-registry/asset-registry.service.ts b/src/commands/asset-registry/asset-registry.service.ts index 3b57333..0f23309 100644 --- a/src/commands/asset-registry/asset-registry.service.ts +++ b/src/commands/asset-registry/asset-registry.service.ts @@ -155,4 +155,4 @@ export class AssetRegistryService { logger.info(` examples: ${descriptor.endpoints.examples}`); } } -} \ No newline at end of file +} diff --git a/tests/commands/asset-registry/asset-registry-validate.spec.ts b/tests/commands/asset-registry/asset-registry-validate.spec.ts index 2c05a90..e756516 100644 --- a/tests/commands/asset-registry/asset-registry-validate.spec.ts +++ b/tests/commands/asset-registry/asset-registry-validate.spec.ts @@ -58,7 +58,7 @@ describe("Asset registry validate", () => { expect(mockWriteFileSync).toHaveBeenCalledWith( path.resolve(process.cwd(), expectedFileName), expect.any(String), - { encoding: "utf-8" } + expect.objectContaining({ encoding: "utf-8" }) ); const written = JSON.parse(mockWriteFileSync.mock.calls[0][1]); expect(written.valid).toBe(false);