Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/commands/api/api.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { HttpClient } from "../../core/http/http-client";
import { Context } from "../../core/command/cli-context";
import { FatalError, logger } from "../../core/utils/logger";
import { fileService, FileService } from "../../core/utils/file-service";
import { v4 as uuidv4 } from "uuid";

export class ApiService {
private httpClient: () => HttpClient;

Check warning on line 8 in src/commands/api/api.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'httpClient' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=celonis_content-cli&issues=AZ2L4p9851XKot2eCtCS&open=AZ2L4p9851XKot2eCtCS&pullRequest=336

constructor(context: Context) {
this.httpClient = () => context.httpClient;
}

public async request(path: string, method: string, body?: string, jsonFile?: boolean): Promise<void> {
if (!path.startsWith("/")) {
throw new FatalError("Path must start with /");
}

const validMethods = ["GET", "POST", "PUT", "DELETE"];
if (!validMethods.includes(method)) {
throw new FatalError(`Invalid method '${method}'. Must be one of: ${validMethods.join(", ")}`);
}

logger.info(`${method} ${path}`);

let parsedBody: any;
if (body) {
try {
parsedBody = JSON.parse(body);
} catch {
throw new FatalError("--body must be valid JSON");
}
}

const data = await this.execute(method, path, parsedBody);

if (jsonFile) {
const filename = uuidv4() + ".json";
fileService.writeToFileWithGivenName(JSON.stringify(data, null, 2), filename);
logger.info(FileService.fileDownloadedMessage + filename);
} else {
const output = typeof data === "string" ? data : JSON.stringify(data, null, 2);
logger.info(output);
}
}

private async execute(method: string, path: string, body?: any): Promise<any> {
try {
switch (method) {
case "GET":
return await this.httpClient().get(path);
case "POST":
return await this.httpClient().post(path, body ?? {});
case "PUT":
return await this.httpClient().put(path, body ?? {});
case "DELETE":
return await this.httpClient().delete(path);
}
} catch (e) {
throw new FatalError(`${method} ${path} failed: ${e}`);
}
}
}
31 changes: 31 additions & 0 deletions src/commands/api/module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Configurator, IModule } from "../../core/command/module-handler";
import { Context } from "../../core/command/cli-context";
import { Command, OptionValues } from "commander";
import { ApiService } from "./api.service";

class Module extends IModule {

public register(context: Context, configurator: Configurator): void {
configurator.command("api")
.description("Send raw HTTP requests to Celonis APIs on the configured team (beta — testing only).")
.beta()
.command("request")
.description("Send a request to the given Celonis API path (e.g. /package-manager/api/packages)")
.requiredOption("--path <path>", "API path starting with / (e.g. /package-manager/api/packages)")
.option("--method <method>", "HTTP method: GET, POST, PUT, DELETE", "GET")
.option("--body <body>", "Request body as JSON string (for POST/PUT)")
.option("--json", "Write the response to a JSON file instead of printing it")
.action(this.request);
}

private async request(context: Context, command: Command, options: OptionValues): Promise<void> {
await new ApiService(context).request(
options.path,
(options.method as string).toUpperCase(),
options.body,
!!options.json
);
}
}

export = Module;
86 changes: 86 additions & 0 deletions tests/commands/api/api-request.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { mockAxiosGet, mockAxiosPost, mockAxiosPut } from "../../utls/http-requests-mock";
import { ApiService } from "../../../src/commands/api/api.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";

describe("Api request command", () => {
const sampleResponse = { packages: [{ id: "pkg-1", name: "My Package" }] };

it("Should execute a GET request and print the response", async () => {
mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/v2/packages", sampleResponse);

await new ApiService(testContext).request("/package-manager/api/v2/packages", "GET", undefined, false);

expect(loggingTestTransport.logMessages.length).toBe(2);
expect(loggingTestTransport.logMessages[0].message).toContain("GET /package-manager/api/v2/packages");
expect(loggingTestTransport.logMessages[1].message).toContain("pkg-1");
});

it("Should execute a GET request and save response as JSON file", async () => {
mockAxiosGet("https://myTeam.celonis.cloud/package-manager/api/v2/packages", sampleResponse);

await new ApiService(testContext).request("/package-manager/api/v2/packages", "GET", undefined, true);

const expectedFileName = loggingTestTransport.logMessages[1].message.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.packages[0].id).toBe("pkg-1");
});

it("Should execute a POST request with body", async () => {
const postBody = { name: "New Package" };
mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/v2/packages", { id: "pkg-2" });

await new ApiService(testContext).request(
"/package-manager/api/v2/packages", "POST", JSON.stringify(postBody), false
);

expect(loggingTestTransport.logMessages[0].message).toContain("POST /package-manager/api/v2/packages");
expect(loggingTestTransport.logMessages[1].message).toContain("pkg-2");
});

it("Should execute a PUT request with body", async () => {
const putBody = { name: "Updated Package" };
mockAxiosPut("https://myTeam.celonis.cloud/package-manager/api/v2/packages/pkg-1", { id: "pkg-1", name: "Updated Package" });

await new ApiService(testContext).request(
"/package-manager/api/v2/packages/pkg-1", "PUT", JSON.stringify(putBody), false
);

expect(loggingTestTransport.logMessages[0].message).toContain("PUT /package-manager/api/v2/packages/pkg-1");
expect(loggingTestTransport.logMessages[1].message).toContain("Updated Package");
});

it("Should reject paths that don't start with /", async () => {
await expect(
new ApiService(testContext).request("package-manager/api/v2/packages", "GET", undefined, false)
).rejects.toThrow("Path must start with /");
});

it("Should reject invalid HTTP methods", async () => {
await expect(
new ApiService(testContext).request("/some/path", "PATCH", undefined, false)
).rejects.toThrow("Invalid method");
});

it("Should reject invalid JSON body", async () => {
await expect(
new ApiService(testContext).request("/some/path", "POST", "not-json{", false)
).rejects.toThrow("--body must be valid JSON");
});

it("Should be case-insensitive on method (normalized to upper by module)", async () => {
mockAxiosGet("https://myTeam.celonis.cloud/some/path", { ok: true });

await new ApiService(testContext).request("/some/path", "GET", undefined, false);

expect(loggingTestTransport.logMessages[0].message).toContain("GET /some/path");
});
});
Loading