diff --git a/README.md b/README.md index dd7f9f2..8c49773 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,18 @@ npx openapi-to-cli onboard \ --openapi-spec https://api.example.com/openapi.json ``` +### Broader spec support + +`ocli` now handles a wider range of real-world OpenAPI and Swagger documents: + +- OAS 3 `requestBody` for JSON payloads +- Swagger 2 `body` and `formData` parameters +- path-level parameters inherited by operations +- local `$ref` references for parameters and request bodies +- header and cookie parameters in generated commands + +In practice this improves compatibility with APIs that define inputs outside simple path/query parameters, especially for `POST`, `PUT`, and `PATCH` operations. + ### Command search ```bash diff --git a/src/cli.ts b/src/cli.ts index a0187aa..9c5b8e5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -151,28 +151,20 @@ async function runApiCommand( } const url = buildRequestUrl(profile, command, flags); - const headers = buildHeaders(profile); - - const knownOptionNames = new Set(command.options.map((o) => o.name)); - const body: Record = {}; - - Object.keys(flags).forEach((key) => { - if (!knownOptionNames.has(key)) { - body[key] = parseBodyFlagValue(flags[key]); - } - }); + const headers = buildHeaders(profile, command, flags); + const payload = buildRequestPayload(command, flags); const method = command.method.toUpperCase(); - const hasBody = Object.keys(body).length > 0 && (method === "POST" || method === "PUT" || method === "PATCH"); + const hasBody = payload.data !== undefined; const requestConfig: AxiosRequestConfig = { method, url, headers: { ...headers, - ...(hasBody ? { "Content-Type": "application/json" } : {}), + ...(hasBody && payload.contentType ? { "Content-Type": payload.contentType } : {}), }, - ...(hasBody ? { data: body } : {}), + ...(hasBody ? { data: payload.data } : {}), }; const response = await httpClient.request(requestConfig); @@ -262,7 +254,7 @@ function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record { +function buildHeaders(profile: Profile, command: CliCommand, flags: Record): Record { const headers: Record = {}; if (profile.customHeaders) { @@ -276,9 +268,89 @@ function buildHeaders(profile: Profile): Record { headers.Authorization = `Bearer ${profile.apiBearerToken}`; } + const cookiePairs: string[] = []; + command.options + .filter((opt) => opt.location === "header" || opt.location === "cookie") + .forEach((opt) => { + const value = flags[opt.name]; + if (value === undefined) { + return; + } + + if (opt.location === "header") { + headers[opt.name] = value; + return; + } + + cookiePairs.push(`${encodeURIComponent(opt.name)}=${encodeURIComponent(value)}`); + }); + + if (cookiePairs.length > 0) { + headers.Cookie = cookiePairs.join("; "); + } + return headers; } +function buildRequestPayload( + command: CliCommand, + flags: Record +): { + data?: unknown; + contentType?: string; +} { + const knownOptionNames = new Set(command.options.map((o) => o.name)); + const bodyOptions = command.options.filter((opt) => opt.location === "body"); + const formOptions = command.options.filter((opt) => opt.location === "formData"); + const extraBodyEntries = Object.entries(flags) + .filter(([key]) => !knownOptionNames.has(key)) + .map(([key, value]) => [key, parseBodyFlagValue(value)] as const); + + if (bodyOptions.length === 1 && bodyOptions[0].name === "body" && flags.body !== undefined) { + return { + data: parseBodyFlagValue(flags.body), + contentType: command.requestContentType ?? "application/json", + }; + } + + if (formOptions.length > 0) { + const formEntries = formOptions + .filter((opt) => flags[opt.name] !== undefined) + .map((opt) => [opt.name, String(flags[opt.name])] as const); + + if (formEntries.length === 0) { + return {}; + } + + if (command.requestContentType === "application/x-www-form-urlencoded") { + const params = new URLSearchParams(); + formEntries.forEach(([key, value]) => params.append(key, value)); + return { + data: params, + contentType: "application/x-www-form-urlencoded", + }; + } + + return { + data: Object.fromEntries(formEntries), + contentType: command.requestContentType ?? "multipart/form-data", + }; + } + + const declaredBodyEntries = bodyOptions + .filter((opt) => flags[opt.name] !== undefined) + .map((opt) => [opt.name, parseBodyFlagValue(flags[opt.name])] as const); + + if (declaredBodyEntries.length > 0 || extraBodyEntries.length > 0) { + return { + data: Object.fromEntries([...declaredBodyEntries, ...extraBodyEntries]), + contentType: command.requestContentType ?? "application/json", + }; + } + + return {}; +} + export async function run(argv: string[], options?: RunOptions): Promise { const cwd = options?.cwd ?? process.cwd(); const configLocator = options?.configLocator ?? new ConfigLocator(); diff --git a/src/openapi-to-commands.ts b/src/openapi-to-commands.ts index 51f12fc..186872b 100644 --- a/src/openapi-to-commands.ts +++ b/src/openapi-to-commands.ts @@ -1,6 +1,6 @@ import { Profile } from "./profile-store"; -export type ParameterLocation = "path" | "query"; +export type ParameterLocation = "path" | "query" | "header" | "cookie" | "body" | "formData"; export interface CliCommandOption { name: string; @@ -16,9 +16,10 @@ export interface CliCommand { path: string; options: CliCommandOption[]; description?: string; + requestContentType?: string; } -type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "head" | "options"; +type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "head" | "options" | "trace"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type OpenapiSpecLike = any; @@ -26,21 +27,39 @@ export type OpenapiSpecLike = any; interface PathOperation { path: string; method: HttpMethod; + pathParameters?: unknown[]; operation: { summary?: string; description?: string; - parameters?: Array<{ - name: string; - in: string; - required?: boolean; - schema?: { - type?: string; - }; - description?: string; - }>; + parameters?: unknown[]; + requestBody?: unknown; + consumes?: string[]; }; } +interface ParameterLike { + name?: string; + in?: string; + required?: boolean; + schema?: SchemaLike; + type?: string; + description?: string; +} + +interface SchemaLike { + type?: string; + description?: string; + required?: string[]; + properties?: Record; + items?: SchemaLike; + $ref?: string; +} + +interface RequestBodyLike { + required?: boolean; + content?: Record; +} + export class OpenapiToCommands { buildCommands(spec: OpenapiSpecLike, profile: Profile): CliCommand[] { const operations = this.collectOperations(spec); @@ -54,7 +73,7 @@ export class OpenapiToCommands { } const filtered = this.applyFilters(operations, profile); - const commands = this.toCliCommands(filtered, methodsByPath); + const commands = this.toCliCommands(filtered, methodsByPath, spec); const prefix = profile.commandPrefix; if (prefix) { @@ -70,16 +89,18 @@ export class OpenapiToCommands { const result: PathOperation[] = []; const paths = spec.paths ?? {}; - const methods: HttpMethod[] = ["get", "post", "put", "delete", "patch", "head", "options"]; + const methods: HttpMethod[] = ["get", "post", "put", "delete", "patch", "head", "options", "trace"]; for (const pathKey of Object.keys(paths)) { const pathItem = paths[pathKey]; + const pathParameters = Array.isArray(pathItem?.parameters) ? pathItem.parameters : []; for (const method of methods) { const op = pathItem[method]; if (op) { result.push({ path: pathKey, method, + pathParameters, operation: op, }); } @@ -111,7 +132,11 @@ export class OpenapiToCommands { }); } - private toCliCommands(operations: PathOperation[], methodsByPath: Record>): CliCommand[] { + private toCliCommands( + operations: PathOperation[], + methodsByPath: Record>, + spec: OpenapiSpecLike + ): CliCommand[] { const byPath: Record = {}; for (const op of operations) { @@ -131,7 +156,7 @@ export class OpenapiToCommands { for (const op of ops) { const name = multipleMethods ? `${baseName}_${op.method}` : baseName; - const options = this.extractOptions(op); + const { options, requestContentType } = this.extractOptions(op, spec); const description = op.operation.summary ?? op.operation.description; commands.push({ @@ -140,6 +165,7 @@ export class OpenapiToCommands { path: op.path, options, description, + requestContentType, }); } } @@ -147,12 +173,38 @@ export class OpenapiToCommands { return commands; } - private extractOptions(op: PathOperation): CliCommandOption[] { - const params = op.operation.parameters ?? []; + private extractOptions( + op: PathOperation, + spec: OpenapiSpecLike + ): { + options: CliCommandOption[]; + requestContentType?: string; + } { + const params = this.mergeParameters(op.pathParameters ?? [], op.operation.parameters ?? [], spec); const result: CliCommandOption[] = []; for (const param of params) { - if (param.in !== "path" && param.in !== "query") { + if (!param.name || !param.in) { + continue; + } + + if (param.in === "body") { + result.push(...this.extractSchemaOptions(param.schema, { + spec, + location: "body", + required: Boolean(param.required), + fallbackName: "body", + })); + continue; + } + + if ( + param.in !== "path" && + param.in !== "query" && + param.in !== "header" && + param.in !== "cookie" && + param.in !== "formData" + ) { continue; } @@ -160,12 +212,35 @@ export class OpenapiToCommands { name: param.name, location: param.in, required: param.in === "path" ? true : Boolean(param.required), - schemaType: param.schema?.type, + schemaType: this.getParameterSchemaType(param), description: param.description, }); } - return result; + const requestBody = this.resolveRequestBody(op.operation.requestBody, spec); + if (!requestBody) { + return { + options: result, + requestContentType: this.pickSwaggerConsumesType(op.operation.consumes), + }; + } + + const requestContentType = this.pickRequestBodyContentType(requestBody); + if (!requestContentType) { + return { options: result }; + } + + const bodySchema = requestBody.content?.[requestContentType]?.schema; + result.push(...this.extractSchemaOptions(bodySchema, { + spec, + location: requestContentType === "application/x-www-form-urlencoded" || requestContentType === "multipart/form-data" + ? "formData" + : "body", + required: Boolean(requestBody.required), + fallbackName: "body", + })); + + return { options: result, requestContentType }; } private commandBaseNameFromPath(pathValue: string): string { @@ -186,4 +261,202 @@ export class OpenapiToCommands { private endpointKey(method: HttpMethod, pathValue: string): string { return `${method}:${pathValue}`; } + + private mergeParameters(pathParameters: unknown[], operationParameters: unknown[], spec: OpenapiSpecLike): ParameterLike[] { + const merged = new Map(); + + for (const rawParam of pathParameters) { + const param = this.resolveParameter(rawParam, spec); + if (!param.name || !param.in) { + continue; + } + merged.set(`${param.in}:${param.name}`, param); + } + + for (const rawParam of operationParameters) { + const param = this.resolveParameter(rawParam, spec); + if (!param.name || !param.in) { + continue; + } + merged.set(`${param.in}:${param.name}`, param); + } + + return Array.from(merged.values()); + } + + private resolveParameter(rawParam: unknown, spec: OpenapiSpecLike): ParameterLike { + return this.resolveValue(rawParam, spec) as ParameterLike; + } + + private resolveRequestBody(rawBody: unknown, spec: OpenapiSpecLike): RequestBodyLike | undefined { + if (!rawBody) { + return undefined; + } + return this.resolveValue(rawBody, spec) as RequestBodyLike; + } + + private resolveSchema(schema: SchemaLike | undefined, spec: OpenapiSpecLike): SchemaLike | undefined { + if (!schema) { + return undefined; + } + return this.resolveValue(schema, spec) as SchemaLike; + } + + private resolveValue(value: unknown, spec: OpenapiSpecLike, seenRefs?: Set): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.resolveValue(item, spec, seenRefs)); + } + + if (!value || typeof value !== "object") { + return value; + } + + const record = value as Record; + const ref = record.$ref; + + if (typeof ref === "string" && ref.startsWith("#/")) { + const localSeen = seenRefs ?? new Set(); + if (localSeen.has(ref)) { + return value; + } + localSeen.add(ref); + const resolvedTarget = this.resolveValue(this.getLocalRef(spec, ref), spec, localSeen); + const siblingEntries = Object.entries(record).filter(([key]) => key !== "$ref"); + if (siblingEntries.length === 0) { + localSeen.delete(ref); + return resolvedTarget; + } + const resolvedSiblings = Object.fromEntries( + siblingEntries.map(([key, siblingValue]) => [key, this.resolveValue(siblingValue, spec, localSeen)]) + ); + localSeen.delete(ref); + + if (resolvedTarget && typeof resolvedTarget === "object" && !Array.isArray(resolvedTarget)) { + return { + ...(resolvedTarget as Record), + ...resolvedSiblings, + }; + } + + return resolvedSiblings; + } + + const resolvedEntries = Object.entries(record).map(([key, nested]) => [key, this.resolveValue(nested, spec, seenRefs)]); + return Object.fromEntries(resolvedEntries); + } + + private getLocalRef(spec: OpenapiSpecLike, ref: string): unknown { + const parts = ref + .slice(2) + .split("/") + .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~")); + + let current: unknown = spec; + for (const part of parts) { + if (!current || typeof current !== "object" || !(part in (current as Record))) { + return undefined; + } + current = (current as Record)[part]; + } + return current; + } + + private extractSchemaOptions( + schema: SchemaLike | undefined, + context: { + spec: OpenapiSpecLike; + location: "body" | "formData"; + required: boolean; + fallbackName: string; + } + ): CliCommandOption[] { + const resolvedSchema = this.resolveSchema(schema, context.spec); + if (!resolvedSchema) { + return []; + } + + const properties = resolvedSchema.properties ?? {}; + const propertyNames = Object.keys(properties); + + if (resolvedSchema.type === "object" || propertyNames.length > 0) { + if (propertyNames.length === 0) { + return [{ + name: context.fallbackName, + location: context.location, + required: context.required, + schemaType: resolvedSchema.type, + description: resolvedSchema.description, + }]; + } + + const required = new Set(resolvedSchema.required ?? []); + return propertyNames.map((propertyName) => { + const propertySchema = this.resolveSchema(properties[propertyName], context.spec); + return { + name: propertyName, + location: context.location, + required: required.has(propertyName), + schemaType: propertySchema?.type, + description: propertySchema?.description, + }; + }); + } + + return [{ + name: context.fallbackName, + location: context.location, + required: context.required, + schemaType: resolvedSchema.type, + description: resolvedSchema.description, + }]; + } + + private getParameterSchemaType(param: ParameterLike): string | undefined { + if (param.schema?.type) { + return param.schema.type; + } + return param.type; + } + + private pickRequestBodyContentType(requestBody: RequestBodyLike): string | undefined { + const content = requestBody.content ?? {}; + const contentTypes = Object.keys(content); + if (contentTypes.length === 0) { + return undefined; + } + + const preferred = [ + "application/json", + "application/x-www-form-urlencoded", + "multipart/form-data", + ]; + + for (const candidate of preferred) { + if (candidate in content) { + return candidate; + } + } + + return contentTypes[0]; + } + + private pickSwaggerConsumesType(consumes?: string[]): string | undefined { + if (!Array.isArray(consumes) || consumes.length === 0) { + return undefined; + } + + const preferred = [ + "application/json", + "application/x-www-form-urlencoded", + "multipart/form-data", + ]; + + for (const candidate of preferred) { + if (consumes.includes(candidate)) { + return candidate; + } + } + + return consumes[0]; + } } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 07b2e00..1fe2dd6 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -750,6 +750,204 @@ describe("cli", () => { ).rejects.toThrow("Invalid JSON body value"); }); + it("builds JSON request body from declared OAS3 requestBody properties", async () => { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cachePath = `${localDir}/specs/oas3-body-api.json`; + + const spec = { + openapi: "3.0.0", + components: { + parameters: { + RepoSlug: { + name: "repo_slug", + in: "path", + required: true, + schema: { type: "string" }, + }, + }, + }, + paths: { + "/repos/{repo_slug}/dispatch": { + parameters: [ + { $ref: "#/components/parameters/RepoSlug" }, + ], + post: { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["event_type"], + properties: { + event_type: { type: "string" }, + draft: { type: "boolean" }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const iniContent = [ + "[oas3-body-api]", + "api_base_url = https://api.example.com", + "api_basic_auth = ", + "api_bearer_token = tok", + "openapi_spec_source = /spec.json", + `openapi_spec_cache = ${cachePath}`, + "include_endpoints = ", + "exclude_endpoints = ", + "", + ].join("\n"); + + const capturedConfigs: unknown[] = []; + const fakeHttpClient: HttpClient = { + request: async (config: any) => { + capturedConfigs.push(config); + return { status: 200, statusText: "OK", headers: {}, config, data: { ok: true } }; + }, + }; + + const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, { + [profilesPath]: iniContent, + [cachePath]: JSON.stringify(spec), + [`${localDir}/current`]: "oas3-body-api", + }); + + await run( + [ + "repos_repo_slug_dispatch", + "--repo_slug", "hello", + "--event_type", "deploy", + "--draft", "true", + ], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + const config = capturedConfigs[0] as { headers: Record; data: Record }; + expect(config.headers["Content-Type"]).toBe("application/json"); + expect(config.data).toEqual({ + event_type: "deploy", + draft: true, + }); + }); + + it("builds Swagger 2 form payload from formData parameters", async () => { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cachePath = `${localDir}/specs/swagger-form-api.json`; + + const spec = { + swagger: "2.0", + paths: { + "/uploads": { + post: { + consumes: ["application/x-www-form-urlencoded"], + parameters: [ + { name: "title", in: "formData", required: true, type: "string" }, + { name: "draft", in: "formData", required: false, type: "string" }, + ], + }, + }, + }, + }; + + const iniContent = [ + "[swagger-form-api]", + "api_base_url = https://api.example.com", + "api_basic_auth = ", + "api_bearer_token = tok", + "openapi_spec_source = /spec.json", + `openapi_spec_cache = ${cachePath}`, + "include_endpoints = ", + "exclude_endpoints = ", + "", + ].join("\n"); + + const capturedConfigs: unknown[] = []; + const fakeHttpClient: HttpClient = { + request: async (config: any) => { + capturedConfigs.push(config); + return { status: 200, statusText: "OK", headers: {}, config, data: { ok: true } }; + }, + }; + + const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, { + [profilesPath]: iniContent, + [cachePath]: JSON.stringify(spec), + [`${localDir}/current`]: "swagger-form-api", + }); + + await run( + ["uploads", "--title", "Quarterly report", "--draft", "yes"], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + const config = capturedConfigs[0] as { headers: Record; data: URLSearchParams }; + expect(config.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + expect(config.data.toString()).toBe("title=Quarterly+report&draft=yes"); + }); + + it("injects header and cookie parameters into the request", async () => { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cachePath = `${localDir}/specs/headers-and-cookies-api.json`; + + const spec = { + openapi: "3.0.0", + paths: { + "/me": { + get: { + parameters: [ + { name: "X-Request-Id", in: "header", required: true, schema: { type: "string" } }, + { name: "session_id", in: "cookie", required: true, schema: { type: "string" } }, + ], + }, + }, + }, + }; + + const iniContent = [ + "[headers-and-cookies-api]", + "api_base_url = https://api.example.com", + "api_basic_auth = ", + "api_bearer_token = tok", + "openapi_spec_source = /spec.json", + `openapi_spec_cache = ${cachePath}`, + "include_endpoints = ", + "exclude_endpoints = ", + "", + ].join("\n"); + + const capturedConfigs: unknown[] = []; + const fakeHttpClient: HttpClient = { + request: async (config: any) => { + capturedConfigs.push(config); + return { status: 200, statusText: "OK", headers: {}, config, data: { ok: true } }; + }, + }; + + const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, { + [profilesPath]: iniContent, + [cachePath]: JSON.stringify(spec), + [`${localDir}/current`]: "headers-and-cookies-api", + }); + + await run( + ["me", "--X-Request-Id", "req-123", "--session_id", "cookie-abc"], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + const config = capturedConfigs[0] as { headers: Record }; + expect(config.headers["X-Request-Id"]).toBe("req-123"); + expect(config.headers.Cookie).toBe("session_id=cookie-abc"); + }); + it("sends custom headers from profile in API requests", async () => { const localDir = `${cwd}/.ocli`; const profilesPath = `${localDir}/profiles.ini`; diff --git a/tests/openapi-to-commands.test.ts b/tests/openapi-to-commands.test.ts index 320b8e9..0883f1a 100644 --- a/tests/openapi-to-commands.test.ts +++ b/tests/openapi-to-commands.test.ts @@ -181,4 +181,144 @@ describe("OpenapiToCommands", () => { const opt = commands[0].options.find((o) => o.name === "user_id"); expect(opt?.required).toBe(true); }); + + it("merges path-level and operation-level parameters", () => { + const spec: OpenapiSpecLike = { + openapi: "3.0.0", + paths: { + "/teams/{team_id}/members": { + parameters: [ + { + name: "team_id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Team ID", + }, + ], + get: { + parameters: [ + { + name: "limit", + in: "query", + schema: { type: "integer" }, + }, + ], + }, + }, + }, + }; + + const commands = openapiToCommands.buildCommands(spec, baseProfile); + expect(commands).toHaveLength(1); + expect(commands[0].options.map((o) => o.name).sort()).toEqual(["limit", "team_id"]); + }); + + it("resolves local parameter and requestBody refs", () => { + const spec: OpenapiSpecLike = { + openapi: "3.0.0", + components: { + parameters: { + OrgSlug: { + name: "org_slug", + in: "path", + required: true, + schema: { type: "string" }, + }, + }, + requestBodies: { + TriggerWorkflow: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["revision"], + properties: { + revision: { type: "string", description: "Git revision" }, + draft: { type: "boolean" }, + }, + }, + }, + }, + }, + }, + }, + paths: { + "/orgs/{org_slug}/trigger": { + post: { + parameters: [ + { $ref: "#/components/parameters/OrgSlug" }, + ], + requestBody: { + $ref: "#/components/requestBodies/TriggerWorkflow", + }, + }, + }, + }, + }; + + const commands = openapiToCommands.buildCommands(spec, baseProfile); + expect(commands).toHaveLength(1); + expect(commands[0].requestContentType).toBe("application/json"); + expect(commands[0].options.map((o) => `${o.location}:${o.name}`).sort()).toEqual([ + "body:draft", + "body:revision", + "path:org_slug", + ]); + + const revision = commands[0].options.find((o) => o.name === "revision"); + expect(revision?.required).toBe(true); + }); + + it("extracts Swagger 2 body and formData parameters", () => { + const spec: OpenapiSpecLike = { + swagger: "2.0", + paths: { + "/upload/{file_id}": { + post: { + consumes: ["multipart/form-data"], + parameters: [ + { + name: "file_id", + in: "path", + required: true, + type: "string", + }, + { + name: "meta", + in: "body", + required: true, + schema: { + type: "object", + required: ["title"], + properties: { + title: { type: "string" }, + tags: { type: "array" }, + }, + }, + }, + { + name: "file", + in: "formData", + required: true, + type: "string", + description: "File contents", + }, + ], + }, + }, + }, + }; + + const commands = openapiToCommands.buildCommands(spec, baseProfile); + expect(commands).toHaveLength(1); + expect(commands[0].requestContentType).toBe("multipart/form-data"); + expect(commands[0].options.map((o) => `${o.location}:${o.name}`).sort()).toEqual([ + "body:tags", + "body:title", + "formData:file", + "path:file_id", + ]); + }); });