Skip to content

Commit b7c26c6

Browse files
committed
SP-38: Add new CLI commands for singe node operations
1 parent 8ab5e3e commit b7c26c6

8 files changed

Lines changed: 366 additions & 2 deletions

File tree

src/commands/configuration-management/api/node-api.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { HttpClient } from "../../../core/http/http-client";
22
import { Context } from "../../../core/command/cli-context";
3-
import { NodeTransport } from "../interfaces/node.interfaces";
3+
import { NodeTransport, SaveNodeTransport, UpdateNodeTransport } from "../interfaces/node.interfaces";
44
import { FatalError } from "../../../core/utils/logger";
55

66
export class NodeApi {
@@ -33,6 +33,37 @@ export class NodeApi {
3333
});
3434
}
3535

36+
public async createStagingNode(packageKey: string, transport: SaveNodeTransport, validateOnly: boolean): Promise<NodeTransport | void> {
37+
const suffix = validateOnly ? "?validate=true" : "";
38+
39+
return this.httpClient()
40+
.post(`/pacman/api/core/staging/packages/${packageKey}/nodes${suffix}`, transport)
41+
.catch((e) => {
42+
throw new FatalError(`Problem creating node in package ${packageKey}: ${e}`);
43+
});
44+
}
45+
46+
public async updateStagingNode(packageKey: string, nodeKey: string, transport: UpdateNodeTransport, validateOnly: boolean): Promise<NodeTransport | void> {
47+
const suffix = validateOnly ? "?validate=true" : "";
48+
49+
return this.httpClient()
50+
.put(`/pacman/api/core/staging/packages/${packageKey}/nodes/${nodeKey}${suffix}`, transport)
51+
.catch((e) => {
52+
throw new FatalError(`Problem updating node ${nodeKey} in package ${packageKey}: ${e}`);
53+
});
54+
}
55+
56+
public async archiveStagingNode(packageKey: string, nodeKey: string, force: boolean): Promise<void> {
57+
const queryParams = new URLSearchParams();
58+
queryParams.set("force", force.toString());
59+
60+
return this.httpClient()
61+
.delete(`/pacman/api/core/staging/packages/${packageKey}/nodes/${nodeKey}/archive?${queryParams.toString()}`)
62+
.catch((e) => {
63+
throw new FatalError(`Problem archiving node ${nodeKey} in package ${packageKey}: ${e}`);
64+
});
65+
}
66+
3667
public async findVersionedNodesByPackage(packageKey: string, version: string, withConfiguration: boolean, limit: number, offset: number): Promise<NodeTransport[]> {
3768
const queryParams = new URLSearchParams();
3869
queryParams.set("version", version);

src/commands/configuration-management/interfaces/node.interfaces.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,25 @@ export interface NodeTransport {
2020
schemaVersion: number;
2121
flavor?: string;
2222
}
23+
24+
export interface SaveNodeTransport {
25+
key: string;
26+
name: string;
27+
parentNodeKey: string;
28+
type: string;
29+
configuration?: NodeConfiguration;
30+
invalidConfiguration?: string;
31+
invalidContent?: boolean;
32+
schemaVersion?: number;
33+
[key: string]: any;
34+
}
35+
36+
export interface UpdateNodeTransport {
37+
name: string;
38+
parentNodeKey: string;
39+
configuration?: NodeConfiguration;
40+
invalidConfiguration?: string;
41+
invalidContent?: boolean;
42+
schemaVersion?: number;
43+
[key: string]: any;
44+
}

src/commands/configuration-management/module.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { NodeDiffService } from "./node-diff.service";
1212
import { NodeDependencyService } from "./node-dependency.service";
1313
import { PackageVersionCommandService } from "./package-version-command.service";
1414
import { PackageValidationService } from "./package-validation.service";
15+
import { fileService } from "../../core/utils/file-service";
1516

1617
class Module extends IModule {
1718

@@ -116,6 +117,32 @@ class Module extends IModule {
116117
.option("--json", "Return the response as a JSON file")
117118
.action(this.findNode);
118119

120+
nodesCommand.command("create")
121+
.description("Create a new staging node in a package")
122+
.requiredOption("--packageKey <packageKey>", "Identifier of the package")
123+
.option("--body <body>", "Node payload as JSON string")
124+
.option("-f, --file <file>", "Path to a JSON file containing the node payload")
125+
.option("--validate", "Only validate the payload without persisting. Returns success if valid.", false)
126+
.option("--json", "Return the response as a JSON file")
127+
.action(this.createNode);
128+
129+
nodesCommand.command("update")
130+
.description("Update a staging node in a package")
131+
.requiredOption("--packageKey <packageKey>", "Identifier of the package")
132+
.requiredOption("--nodeKey <nodeKey>", "Identifier of the node")
133+
.option("--body <body>", "Node payload as JSON string")
134+
.option("-f, --file <file>", "Path to a JSON file containing the node payload")
135+
.option("--validate", "Only validate the payload without persisting. Returns success if valid.", false)
136+
.option("--json", "Return the response as a JSON file")
137+
.action(this.updateNode);
138+
139+
nodesCommand.command("delete")
140+
.description("Delete (archive) a staging node in a package")
141+
.requiredOption("--packageKey <packageKey>", "Identifier of the package")
142+
.requiredOption("--nodeKey <nodeKey>", "Identifier of the node")
143+
.option("--force", "Force delete even if the node has dependants", false)
144+
.action(this.archiveNode);
145+
119146
nodesCommand.command("list")
120147
.description("List nodes in a specific package version")
121148
.requiredOption("--packageKey <packageKey>", "Identifier of the package")
@@ -249,6 +276,33 @@ class Module extends IModule {
249276
await new NodeService(context).findNode(options.packageKey, options.nodeKey, options.withConfiguration, options.packageVersion ?? null, options.json);
250277
}
251278

279+
private async createNode(context: Context, command: Command, options: OptionValues): Promise<void> {
280+
const body = Module.resolveBody(options.body, options.file);
281+
await new NodeService(context).createNode(options.packageKey, body, options.validate, options.json);
282+
}
283+
284+
private async updateNode(context: Context, command: Command, options: OptionValues): Promise<void> {
285+
const body = Module.resolveBody(options.body, options.file);
286+
await new NodeService(context).updateNode(options.packageKey, options.nodeKey, body, options.validate, options.json);
287+
}
288+
289+
private static resolveBody(body: string | undefined, file: string | undefined): string {
290+
if (body && file) {
291+
throw new Error("Please provide either --body or --file, but not both.");
292+
}
293+
if (!body && !file) {
294+
throw new Error("Please provide either --body or --file.");
295+
}
296+
if (file) {
297+
return fileService.readFile(file);
298+
}
299+
return body!;
300+
}
301+
302+
private async archiveNode(context: Context, command: Command, options: OptionValues): Promise<void> {
303+
await new NodeService(context).archiveNode(options.packageKey, options.nodeKey, options.force);
304+
}
305+
252306
private async listNodes(context: Context, command: Command, options: OptionValues): Promise<void> {
253307
await new NodeService(context).listNodes(options.packageKey, options.packageVersion, options.limit, options.offset, options.withConfiguration, options.json);
254308
}

src/commands/configuration-management/node.service.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Context } from "../../core/command/cli-context";
33
import { fileService, FileService } from "../../core/utils/file-service";
44
import { logger } from "../../core/utils/logger";
55
import { v4 as uuidv4 } from "uuid";
6-
import { NodeTransport } from "./interfaces/node.interfaces";
6+
import { NodeTransport, SaveNodeTransport, UpdateNodeTransport } from "./interfaces/node.interfaces";
77

88
export class NodeService {
99
private nodeApi: NodeApi;
@@ -40,6 +40,47 @@ export class NodeService {
4040
}
4141
}
4242

43+
public async createNode(packageKey: string, body: string, validateOnly: boolean, jsonResponse: boolean): Promise<void> {
44+
const transport: SaveNodeTransport = JSON.parse(body);
45+
const node = await this.nodeApi.createStagingNode(packageKey, transport, validateOnly);
46+
47+
if (validateOnly) {
48+
logger.info(`Validation successful for node ${transport.key} in package ${packageKey}.`);
49+
return;
50+
}
51+
52+
if (jsonResponse) {
53+
const filename = uuidv4() + ".json";
54+
fileService.writeToFileWithGivenName(JSON.stringify(node, null, 2), filename);
55+
logger.info(FileService.fileDownloadedMessage + filename);
56+
} else {
57+
this.printNode(node as NodeTransport);
58+
}
59+
}
60+
61+
public async updateNode(packageKey: string, nodeKey: string, body: string, validateOnly: boolean, jsonResponse: boolean): Promise<void> {
62+
const transport: UpdateNodeTransport = JSON.parse(body);
63+
const node = await this.nodeApi.updateStagingNode(packageKey, nodeKey, transport, validateOnly);
64+
65+
if (validateOnly) {
66+
logger.info(`Validation successful for node ${nodeKey} in package ${packageKey}.`);
67+
return;
68+
}
69+
70+
if (jsonResponse) {
71+
const filename = uuidv4() + ".json";
72+
fileService.writeToFileWithGivenName(JSON.stringify(node, null, 2), filename);
73+
logger.info(FileService.fileDownloadedMessage + filename);
74+
} else {
75+
this.printNode(node as NodeTransport);
76+
}
77+
}
78+
79+
public async archiveNode(packageKey: string, nodeKey: string, force: boolean): Promise<void> {
80+
await this.nodeApi.archiveStagingNode(packageKey, nodeKey, force);
81+
logger.info(`Node ${nodeKey} in package ${packageKey} archived successfully.`);
82+
}
83+
4384
private printNode(node: NodeTransport): void {
4485
logger.info(`ID: ${node.id}`);
4586
logger.info(`Key: ${node.key}`);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { mockAxiosDelete } from "../../utls/http-requests-mock";
2+
import { NodeService } from "../../../src/commands/configuration-management/node.service";
3+
import { testContext } from "../../utls/test-context";
4+
import { loggingTestTransport } from "../../jest.setup";
5+
6+
describe("Node archive", () => {
7+
const packageKey = "package-key";
8+
const nodeKey = "node-key";
9+
10+
it("Should archive node without force", async () => {
11+
const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${packageKey}/nodes/${nodeKey}/archive?force=false`;
12+
mockAxiosDelete(apiUrl);
13+
14+
await new NodeService(testContext).archiveNode(packageKey, nodeKey, false);
15+
16+
expect(loggingTestTransport.logMessages.length).toBe(1);
17+
expect(loggingTestTransport.logMessages[0].message).toContain(`Node ${nodeKey} in package ${packageKey} archived successfully.`);
18+
});
19+
20+
it("Should archive node with force", async () => {
21+
const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${packageKey}/nodes/${nodeKey}/archive?force=true`;
22+
mockAxiosDelete(apiUrl);
23+
24+
await new NodeService(testContext).archiveNode(packageKey, nodeKey, true);
25+
26+
expect(loggingTestTransport.logMessages.length).toBe(1);
27+
expect(loggingTestTransport.logMessages[0].message).toContain(`Node ${nodeKey} in package ${packageKey} archived successfully.`);
28+
});
29+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { NodeTransport, SaveNodeTransport } from "../../../src/commands/configuration-management/interfaces/node.interfaces";
2+
import { mockAxiosPost, mockedPostRequestBodyByUrl } from "../../utls/http-requests-mock";
3+
import { NodeService } from "../../../src/commands/configuration-management/node.service";
4+
import { testContext } from "../../utls/test-context";
5+
import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup";
6+
import { FileService } from "../../../src/core/utils/file-service";
7+
import * as path from "path";
8+
9+
describe("Node create", () => {
10+
const packageKey = "package-key";
11+
const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${packageKey}/nodes`;
12+
const validateOnlyUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${packageKey}/nodes?validate=true`;
13+
14+
const createdNode: NodeTransport = {
15+
id: "new-node-id",
16+
key: "new-node-key",
17+
name: "New Node",
18+
packageNodeKey: packageKey,
19+
parentNodeKey: packageKey,
20+
packageNodeId: "package-node-id",
21+
type: "VIEW",
22+
invalidContent: false,
23+
creationDate: new Date().toISOString(),
24+
changeDate: new Date().toISOString(),
25+
createdBy: "user-id",
26+
updatedBy: "user-id",
27+
schemaVersion: 2,
28+
flavor: "STUDIO",
29+
};
30+
31+
const saveTransport: SaveNodeTransport = {
32+
key: "new-node-key",
33+
name: "New Node",
34+
parentNodeKey: packageKey,
35+
type: "VIEW",
36+
configuration: { someKey: "someValue" },
37+
};
38+
39+
it("Should create node and print result", async () => {
40+
mockAxiosPost(apiUrl, createdNode);
41+
42+
await new NodeService(testContext).createNode(packageKey, JSON.stringify(saveTransport), false, false);
43+
44+
expect(loggingTestTransport.logMessages.length).toBe(11);
45+
expect(loggingTestTransport.logMessages[0].message).toContain(`ID: ${createdNode.id}`);
46+
expect(loggingTestTransport.logMessages[1].message).toContain(`Key: ${createdNode.key}`);
47+
expect(loggingTestTransport.logMessages[2].message).toContain(`Name: ${createdNode.name}`);
48+
});
49+
50+
it("Should create node and return as JSON", async () => {
51+
mockAxiosPost(apiUrl, createdNode);
52+
53+
await new NodeService(testContext).createNode(packageKey, JSON.stringify(saveTransport), false, true);
54+
55+
const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1];
56+
57+
expect(mockWriteFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), expectedFileName), expect.any(String), {encoding: "utf-8"});
58+
59+
const savedTransport = JSON.parse(mockWriteFileSync.mock.calls[0][1]) as NodeTransport;
60+
61+
expect(savedTransport).toEqual(createdNode);
62+
});
63+
64+
it("Should send correct request body", async () => {
65+
mockAxiosPost(apiUrl, createdNode);
66+
67+
await new NodeService(testContext).createNode(packageKey, JSON.stringify(saveTransport), false, false);
68+
69+
const requestBody = JSON.parse(mockedPostRequestBodyByUrl.get(apiUrl));
70+
expect(requestBody.key).toBe(saveTransport.key);
71+
expect(requestBody.name).toBe(saveTransport.name);
72+
expect(requestBody.parentNodeKey).toBe(saveTransport.parentNodeKey);
73+
expect(requestBody.type).toBe(saveTransport.type);
74+
});
75+
76+
it("Should call validate-only endpoint when validateOnly=true", async () => {
77+
mockAxiosPost(validateOnlyUrl, undefined);
78+
79+
await new NodeService(testContext).createNode(packageKey, JSON.stringify(saveTransport), true, false);
80+
81+
expect(mockWriteFileSync).not.toHaveBeenCalled();
82+
expect(loggingTestTransport.logMessages.length).toBe(1);
83+
expect(loggingTestTransport.logMessages[0].message).toContain(
84+
`Validation successful for node ${saveTransport.key} in package ${packageKey}.`
85+
);
86+
});
87+
});

0 commit comments

Comments
 (0)