diff --git a/src/cli/dynamic-commands.ts b/src/cli/dynamic-commands.ts index e3f438b..fd6b3eb 100644 --- a/src/cli/dynamic-commands.ts +++ b/src/cli/dynamic-commands.ts @@ -5,6 +5,7 @@ import { validateResponse } from "../validator/schema.js"; import { filterPii } from "@lucianfialho/pii-filter"; import { printDryRun } from "./dry-run.js"; import { simplifyName } from "./agent-help.js"; +import { sanitizeCommandName, uniqueName } from "./sanitize.js"; import type { RuntimeConfig } from "../executor/types.js"; import type { OperationGroup, OpenAPISpec } from "../parser/types.js"; @@ -14,8 +15,10 @@ export function buildDynamicCommands( config: RuntimeConfig, spec?: OpenAPISpec ): void { + const usedNames = new Set(); for (const group of groups) { - const groupCmd = prog.command(group.tag).description(group.description); + const groupName = uniqueName(sanitizeCommandName(group.tag), usedNames); + const groupCmd = prog.command(groupName).description(group.description); for (const op of group.operations) { const cmdName = simplifyName(op.id, group.tag); diff --git a/src/cli/sanitize.ts b/src/cli/sanitize.ts new file mode 100644 index 0000000..54db9f3 --- /dev/null +++ b/src/cli/sanitize.ts @@ -0,0 +1,11 @@ +export function sanitizeCommandName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "group"; +} + +export function uniqueName(name: string, used: Set): string { + let candidate = name; + let n = 2; + while (used.has(candidate)) candidate = `${name}-${n++}`; + used.add(candidate); + return candidate; +} diff --git a/src/executor/commander-builder.json-params.test.ts b/src/executor/commander-builder.json-params.test.ts new file mode 100644 index 0000000..de49731 --- /dev/null +++ b/src/executor/commander-builder.json-params.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; +import { loadSpec } from "../parser/loader.js"; +import { buildCommands } from "./commander-builder.js"; +import type { RuntimeConfig } from "./types.js"; +import path from "node:path"; + +const FIXTURE = path.resolve("test/fixtures/petstore.yaml"); + +const config: RuntimeConfig = { + specPath: FIXTURE, + baseUrl: "https://example.com", + auth: { type: "none", value: "" }, + output: "json", + verbose: false, + quiet: false, + dryRun: false, + validate: false, +}; + +describe("collectParams JSON parsing", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("parses object params from JSON strings", async () => { + const op: Operation = { + id: "createVideos", + method: "POST", + path: "/videos", + summary: "Create video", + description: "", + params: [ + { name: "name", in: "body", type: "string", required: true, description: "" }, + { name: "content", in: "body", type: "object", required: true, description: "" }, + ], + bodyRequired: true, + security: [], + }; + + let capturedBody: unknown; + vi.stubGlobal("fetch", vi.fn().mockImplementation((_url: string, init: RequestInit) => { + capturedBody = JSON.parse(init.body as string); + return Promise.resolve({ + status: 200, + statusText: "OK", + headers: new Map([["content-type", "application/json"]]), + text: () => Promise.resolve("{}"), + }); + })); + + const spec = await loadSpec(FIXTURE); + const program = new Command(); + program.exitOverride(); + buildCommands(program, [{ tag: "Videos", description: "", operations: [op] }], config, spec); + + await program.parseAsync(["node", "test", "videos", "create", "--name", "Test", "--content", '{"text":"hello"}']); + + expect(capturedBody).toEqual({ name: "Test", content: { text: "hello" } }); + + vi.unstubAllGlobals(); + }); + + it("parses array params from JSON strings", async () => { + const op: Operation = { + id: "createItem", + method: "POST", + path: "/items", + summary: "Create items", + description: "", + params: [ + { name: "tags", in: "body", type: "array", required: true, description: "" }, + ], + bodyRequired: true, + security: [], + }; + + let capturedBody: unknown; + vi.stubGlobal("fetch", vi.fn().mockImplementation((_url: string, init: RequestInit) => { + capturedBody = JSON.parse(init.body as string); + return Promise.resolve({ + status: 200, + statusText: "OK", + headers: new Map([["content-type", "application/json"]]), + text: () => Promise.resolve("{}"), + }); + })); + + const spec = await loadSpec(FIXTURE); + const program = new Command(); + program.exitOverride(); + buildCommands(program, [{ tag: "Items", description: "", operations: [op] }], config, spec); + + await program.parseAsync(["node", "test", "items", "create", "--tags", '["a","b"]']); + + expect(capturedBody).toEqual({ tags: ["a", "b"] }); + + vi.unstubAllGlobals(); + }); + + it("falls back to string when JSON parse fails", async () => { + const op: Operation = { + id: "createThings", + method: "POST", + path: "/things", + summary: "Create thing", + description: "", + params: [ + { name: "data", in: "body", type: "object", required: true, description: "" }, + ], + bodyRequired: true, + security: [], + }; + + let capturedBody: unknown; + vi.stubGlobal("fetch", vi.fn().mockImplementation((_url: string, init: RequestInit) => { + capturedBody = JSON.parse(init.body as string); + return Promise.resolve({ + status: 200, + statusText: "OK", + headers: new Map([["content-type", "application/json"]]), + text: () => Promise.resolve("{}"), + }); + })); + + const spec = await loadSpec(FIXTURE); + const program = new Command(); + program.exitOverride(); + buildCommands(program, [{ tag: "Things", description: "", operations: [op] }], config, spec); + + await program.parseAsync(["node", "test", "things", "create", "--data", "not-json"]); + + expect(capturedBody).toEqual({ data: "not-json" }); + + vi.unstubAllGlobals(); + }); +}); diff --git a/src/executor/commander-builder.test.ts b/src/executor/commander-builder.test.ts index 07bed14..0a67538 100644 --- a/src/executor/commander-builder.test.ts +++ b/src/executor/commander-builder.test.ts @@ -107,120 +107,3 @@ describe("buildCommands", () => { }); }); -describe("collectParams JSON parsing", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("parses object params from JSON strings", async () => { - const op: Operation = { - id: "createVideos", - method: "POST", - path: "/videos", - summary: "Create video", - description: "", - params: [ - { name: "name", in: "body", type: "string", required: true, description: "" }, - { name: "content", in: "body", type: "object", required: true, description: "" }, - ], - bodyRequired: true, - security: [], - }; - - let capturedBody: unknown; - vi.stubGlobal("fetch", vi.fn().mockImplementation((_url: string, init: RequestInit) => { - capturedBody = JSON.parse(init.body as string); - return Promise.resolve({ - status: 200, - statusText: "OK", - headers: new Map([["content-type", "application/json"]]), - text: () => Promise.resolve("{}"), - }); - })); - - const spec = await loadSpec(FIXTURE); - const program = new Command(); - program.exitOverride(); - buildCommands(program, [{ tag: "Videos", description: "", operations: [op] }], config, spec); - - await program.parseAsync(["node", "test", "Videos", "create", "--name", "Test", "--content", '{"text":"hello"}']); - - expect(capturedBody).toEqual({ name: "Test", content: { text: "hello" } }); - - vi.unstubAllGlobals(); - }); - - it("parses array params from JSON strings", async () => { - const op: Operation = { - id: "createItem", - method: "POST", - path: "/items", - summary: "Create items", - description: "", - params: [ - { name: "tags", in: "body", type: "array", required: true, description: "" }, - ], - bodyRequired: true, - security: [], - }; - - let capturedBody: unknown; - vi.stubGlobal("fetch", vi.fn().mockImplementation((_url: string, init: RequestInit) => { - capturedBody = JSON.parse(init.body as string); - return Promise.resolve({ - status: 200, - statusText: "OK", - headers: new Map([["content-type", "application/json"]]), - text: () => Promise.resolve("{}"), - }); - })); - - const spec = await loadSpec(FIXTURE); - const program = new Command(); - program.exitOverride(); - buildCommands(program, [{ tag: "Items", description: "", operations: [op] }], config, spec); - - await program.parseAsync(["node", "test", "Items", "create", "--tags", '["a","b"]']); - - expect(capturedBody).toEqual({ tags: ["a", "b"] }); - - vi.unstubAllGlobals(); - }); - - it("falls back to string when JSON parse fails", async () => { - const op: Operation = { - id: "createThings", - method: "POST", - path: "/things", - summary: "Create thing", - description: "", - params: [ - { name: "data", in: "body", type: "object", required: true, description: "" }, - ], - bodyRequired: true, - security: [], - }; - - let capturedBody: unknown; - vi.stubGlobal("fetch", vi.fn().mockImplementation((_url: string, init: RequestInit) => { - capturedBody = JSON.parse(init.body as string); - return Promise.resolve({ - status: 200, - statusText: "OK", - headers: new Map([["content-type", "application/json"]]), - text: () => Promise.resolve("{}"), - }); - })); - - const spec = await loadSpec(FIXTURE); - const program = new Command(); - program.exitOverride(); - buildCommands(program, [{ tag: "Things", description: "", operations: [op] }], config, spec); - - await program.parseAsync(["node", "test", "Things", "create", "--data", "not-json"]); - - expect(capturedBody).toEqual({ data: "not-json" }); - - vi.unstubAllGlobals(); - }); -}); diff --git a/src/executor/commander-builder.ts b/src/executor/commander-builder.ts index cc539f6..01d1ea1 100644 --- a/src/executor/commander-builder.ts +++ b/src/executor/commander-builder.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import type { OperationGroup, Operation, OpenAPISpec } from "../parser/types.js"; import type { RuntimeConfig } from "./types.js"; import { executeRequest } from "./http.js"; +import { sanitizeCommandName, uniqueName } from "../cli/sanitize.js"; export function buildCommands( program: Command, @@ -9,9 +10,11 @@ export function buildCommands( config: RuntimeConfig, spec: OpenAPISpec ): void { + const usedNames = new Set(); for (const group of groups) { + const groupName = uniqueName(sanitizeCommandName(group.tag), usedNames); const groupCmd = program - .command(group.tag) + .command(groupName) .description(group.description); for (const op of group.operations) { @@ -128,3 +131,4 @@ function simplifyName(operationId: string, tag: string): string { return operationId.toLowerCase(); } +