diff --git a/.cursor/skills/asset-registry-endpoints/SKILL.md b/.cursor/skills/asset-registry-endpoints/SKILL.md index e2068ee..52d4ea1 100644 --- a/.cursor/skills/asset-registry-endpoints/SKILL.md +++ b/.cursor/skills/asset-registry-endpoints/SKILL.md @@ -223,20 +223,49 @@ 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: +Two top-level modes: + +**Build-from-options** — `--packageKey` plus exactly one of `--nodeKey` (stored +node) or `--configuration` (raw configuration JSON). `--nodeKey` and +`--configuration` are mutually exclusive. + +Validate an already-stored node: + +```bash +$CLI asset-registry validate --assetType \ + --packageKey --nodeKey -p +``` + +Validate a raw configuration before import: + +```bash +$CLI asset-registry validate --assetType \ + --packageKey \ + --configuration '' -p +``` + +**`-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 +``` + +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 +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 --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/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..8f3e56e 100644 --- a/docs/user-guide/asset-registry-commands.md +++ b/docs/user-guide/asset-registry-commands.md @@ -71,6 +71,70 @@ 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. There are two top-level modes: + +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. + + `--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 --nodeKey my-view +``` + +Sends: + +```json +{ + "assetType": "BOARD_V2", + "packageKey": "my-pkg", + "nodeKeys": ["my-view"] +} +``` + +### Validate a raw configuration (`--configuration`) + +``` +content-cli asset-registry validate --assetType BOARD_V2 \ + --packageKey my-pkg \ + --configuration '{"components":[{"type":"kpi"}]}' +``` + +Sends: + +```json +{ + "assetType": "BOARD_V2", + "packageKey": "my-pkg", + "nodes": [{ "key": "validation-node", "configuration": { "components": [{ "type": "kpi" }] } }] +} +``` + +### 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. 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 + ## 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.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 47a26f9..0f23309 100644 --- a/src/commands/asset-registry/asset-registry.service.ts +++ b/src/commands/asset-registry/asset-registry.service.ts @@ -1,9 +1,10 @@ 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 { 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,67 @@ 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 static readonly INLINE_VALIDATION_NODE_KEY = "validation-node"; + + private buildValidatePayload(opts: ValidateOptions): any { + const hasNodeKey = !!opts.nodeKey; + const hasConfig = !!opts.configuration; + const hasFile = !!opts.file; + + if (hasFile && (hasNodeKey || hasConfig || !!opts.packageKey)) { + throw new FatalError( + "Option -f is mutually exclusive with --packageKey, --nodeKey and --configuration." + ); + } + + if (hasFile) { + 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."); + } + + if (hasNodeKey) { + return { + assetType: opts.assetType, + packageKey: opts.packageKey, + nodeKeys: [opts.nodeKey], + }; + } + + const configJson = this.parseJson(opts.configuration!, "--configuration"); + return { + assetType: opts.assetType, + packageKey: opts.packageKey, + nodes: [{ key: AssetRegistryService.INLINE_VALIDATION_NODE_KEY, 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"; diff --git a/src/commands/asset-registry/module.ts b/src/commands/asset-registry/module.ts index 6d2d92c..9e2f5ec 100644 --- a/src/commands/asset-registry/module.ts +++ b/src/commands/asset-registry/module.ts @@ -32,6 +32,16 @@ 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. 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); + 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 +61,17 @@ 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, + 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..9ebd9ea 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,59 @@ describe("Asset Registry Module", () => { expect(mockService.getSchema).toHaveBeenCalledWith("BOARD_V2", true); }); + it("should call validate with --configuration sub-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":[]}', + file: undefined, + json: true, + }); + }); + + it("should call validate with --nodeKey sub-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, + 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, + 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..e756516 --- /dev/null +++ b/tests/commands/asset-registry/asset-registry-validate.spec.ts @@ -0,0 +1,243 @@ +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 sub-mode (validate a raw configuration)", () => { + 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), + expect.objectContaining({ encoding: "utf-8" }) + ); + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1]); + expect(written.valid).toBe(false); + }); + + it("Should build the envelope with nodes[] using a synthetic node key", 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 --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 sub-mode (validate an already-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("-f / --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 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{"); + + 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 and missing modes", () => { + 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("--nodeKey and --configuration are mutually exclusive"); + }); + + 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", + nodeKey: "my-view", + file: "request.json", + json: false, + }) + ).rejects.toThrow("-f is mutually exclusive"); + }); + + it("Should throw when -f is combined with --configuration", async () => { + await expect( + new AssetRegistryService(testContext).validate({ + assetType: "BOARD_V2", + configuration: "{}", + file: "request.json", + json: false, + }) + ).rejects.toThrow("-f is 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 --packageKey with one of --nodeKey"); + }); + }); +});