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
76 changes: 76 additions & 0 deletions app/src/services/importers/openapi-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2026 Atharva Kusumbia
*
* This source code is licensed under the Apache 2.0 license found in the
* LICENSE file in the "app" directory of this source tree.
*/

import type { SkippedItem } from "./types";

export type RefResolver = (ref: string) => unknown;

/**
* Helpers shared by the two OpenAPI/Swagger parsers. They are structural clones of
* each other, which is how both ended up with the same unguarded `parameters` spread
* and the same hardcoded `skipped: []`; a second copy of these three would drift the
* same way.
*/

/**
* Resolve a Path Item Object that is itself `{"$ref": "..."}` - legal in 3.0 and 3.1,
* and what bundlers emit when they hoist a shared path item into `components.pathItems`.
* Such an item carries no method keys, so an unresolved one drops every operation under
* that path.
*
* Single-hop, like the parameter and `requestBody` refs the parsers already resolve: a
* ref-to-a-ref is not a shape generators emit, and chasing one needs a cycle guard.
* Returns `undefined` when there is nothing iterable to read methods off, so the caller
* records the drop instead of silently looping over a non-object.
*/
export function resolvePathItem(
pathItem: unknown,
resolveRef: RefResolver
): Record<string, unknown> | undefined {
if (!pathItem || typeof pathItem !== "object") return undefined;
const ref = (pathItem as { $ref?: unknown }).$ref;
if (typeof ref !== "string") return pathItem as Record<string, unknown>;
let resolved: unknown;
try {
resolved = resolveRef(ref);
} catch {
return undefined;
}
return resolved && typeof resolved === "object"
? (resolved as Record<string, unknown>)
: undefined;
}

/**
* Running count of what a parse had to drop, emitted as `meta.skipped` so the import
* preview (`ImportModal`) can name it. Insertion-ordered, and only non-zero kinds are
* emitted - an empty tally yields `[]`, exactly what both parsers used to hardcode.
*/
export class SkipTally {
private readonly counts = new Map<SkippedItem["kind"], number>();

add(kind: SkippedItem["kind"], count = 1): void {
if (count <= 0) return;
this.counts.set(kind, (this.counts.get(kind) ?? 0) + count);
}

/**
* The `parameters` of a path item or operation, guarded. The spec says array; a
* missing `-` in hand-written YAML makes it a mapping, and spreading that threw
* `not iterable` and aborted the whole file. Absent is normal and not counted;
* present-but-not-an-array is stepped over and tallied.
*/
params(parameters: unknown): unknown[] {
if (Array.isArray(parameters)) return parameters;
if (parameters != null) this.add("malformed_spec");
return [];
}

items(): SkippedItem[] {
return [...this.counts].map(([kind, count]) => ({ kind, count }));
}
}
101 changes: 101 additions & 0 deletions app/src/services/importers/openapi-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,107 @@ describe("OpenApiV2Parser", () => {
]);
});

const formSpec = (consumes?: string[]) => ({
swagger: "2.0",
info: { title: "Form API" },
paths: {
"/login": {
post: {
summary: "Log in",
...(consumes ? { consumes } : {}),
parameters: [
{ name: "username", in: "formData", type: "string" },
{ name: "password", in: "formData", type: "string" },
],
},
},
},
});

const formBody = (consumes?: string[]) => {
const spec = formSpec(consumes);
return p
.parse(spec, JSON.stringify(spec), opts)
.collections[0].requests.find((r) => r.name === "Log in")!.body;
};

it("maps formData to urlencoded or multipart per consumes", () => {
expect(formBody(["application/x-www-form-urlencoded"]).mode).toBe("x-www-form-urlencoded");
expect(formBody(["application/x-www-form-urlencoded; charset=utf-8"]).mode).toBe(
"x-www-form-urlencoded"
);
expect(formBody(["multipart/form-data"]).mode).toBe("form-data");
// Multipart wins when both are offered - it is the only one that can carry a file.
expect(formBody(["application/x-www-form-urlencoded", "multipart/form-data"]).mode).toBe(
"form-data"
);
// An absent or unrelated consumes keeps the historical multipart default.
expect(formBody().mode).toBe("form-data");
expect(formBody(["application/json"]).mode).toBe("form-data");
});

it("keeps the formData field rows whichever encoding is chosen", () => {
const body = formBody(["application/x-www-form-urlencoded"]);
expect(body).toEqual({
mode: "x-www-form-urlencoded",
fields: [
{ key: "username", value: "", enabled: true },
{ key: "password", value: "", enabled: true },
],
});
});

it("falls back to the spec-level consumes for the form encoding", () => {
const spec = { ...formSpec(), consumes: ["application/x-www-form-urlencoded"] };
const body = p
.parse(spec, JSON.stringify(spec), opts)
.collections[0].requests.find((r) => r.name === "Log in")!.body;
expect(body.mode).toBe("x-www-form-urlencoded");
});

it("resolves a $ref'd path item instead of dropping every operation under it", () => {
const spec = {
swagger: "2.0",
info: { title: "Ref Path API" },
paths: {
"/users/{id}": { $ref: "#/x-pathItems/UserOps" },
"/health": { get: { summary: "Health" } },
},
"x-pathItems": { UserOps: { get: { summary: "Get user" } } },
};
const result = p.parse(spec, JSON.stringify(spec), opts);
expect(result.collections[0].requests.map((r) => r.name)).toEqual(["Get user", "Health"]);
expect(result.meta.requestCount).toBe(2);
expect(result.meta.skipped).toEqual([]);
});

it("steps over a non-array parameters block instead of aborting the file", () => {
const spec = {
swagger: "2.0",
info: { title: "Malformed API" },
paths: {
"/items": {
parameters: { name: "shared", in: "query" },
get: { summary: "List items" },
},
"/other": { get: { summary: "Other", parameters: [{ name: "ok", in: "query" }] } },
},
};
const result = p.parse(spec, JSON.stringify(spec), opts);
expect(result.meta.requestCount).toBe(2);
expect(result.collections[0].requests.find((r) => r.name === "List items")!.params).toEqual(
[]
);
expect(result.collections[0].requests.find((r) => r.name === "Other")!.params).toEqual([
{ key: "ok", value: "", enabled: true },
]);
expect(result.meta.skipped).toEqual([{ kind: "malformed_spec", count: 1 }]);
});

it("records nothing skipped for a spec it can represent whole", () => {
expect(p.parse(parsed, raw, opts).meta.skipped).toEqual([]);
});

it("treats charset json consume as json body", () => {
const spec = {
swagger: "2.0",
Expand Down
42 changes: 34 additions & 8 deletions app/src/services/importers/openapi-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,20 @@ import type {
import { sampleSchema } from "./schema-sampler";
import { normalizeVars } from "./var-normalize";
import { mapSwaggerOAuth2 } from "./oauth2-import";
import { resolvePathItem, SkipTally } from "./openapi-shared";

const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const;

/** A `consumes` entry stripped of its parameters, e.g. `"multipart/form-data; boundary=x"`. */
const mediaType = (c: unknown): string =>
String(c ?? "")
.split(";")[0]
.trim()
.toLowerCase();
const isUrlEncodedType = (c: unknown): boolean =>
mediaType(c) === "application/x-www-form-urlencoded";
const isMultipartType = (c: unknown): boolean => mediaType(c) === "multipart/form-data";

export function swaggerSchemeToAuth(scheme: any): Exclude<RequestAuth, { mode: "inherit" }> {
if (!scheme || !scheme.type) return { mode: "none" };
if (scheme.type === "basic") return { mode: "basic", username: "", password: "" };
Expand Down Expand Up @@ -64,15 +75,21 @@ export class OpenApiV2Parser implements ImportParser {

const tagCollections = new Map<string, CollectionDraft>();
const rootRequests: RequestDraft[] = [];
const tally = new SkipTally();
let requestCount = 0;

for (const [path, pathItem] of Object.entries(spec.paths ?? {})) {
const pathParams = (pathItem as any)?.parameters ?? [];
for (const [path, rawPathItem] of Object.entries(spec.paths ?? {})) {
const pathItem = resolvePathItem(rawPathItem, resolveRef);
if (!pathItem) {
tally.add("malformed_spec");
continue;
}
const pathParams = tally.params(pathItem.parameters);
for (const method of HTTP_METHODS) {
const op = (pathItem as any)?.[method];
const op = (pathItem as any)[method];
if (!op) continue;
requestCount += 1;
const req = buildSwaggerOp(method, path, op, spec, resolveRef, pathParams);
const req = buildSwaggerOp(method, path, op, spec, resolveRef, pathParams, tally);
const tag = op.tags?.[0];
if (tag) {
if (!tagCollections.has(tag)) {
Expand Down Expand Up @@ -116,7 +133,7 @@ export class OpenApiV2Parser implements ImportParser {
folderCount: tagCollections.size,
environmentCount: 0,
globalCount: 0,
skipped: [],
skipped: tally.items(),
nonExecutableAuth: 0,
},
};
Expand All @@ -129,7 +146,8 @@ function buildSwaggerOp(
op: any,
spec: any,
resolveRef: (r: string) => unknown,
pathParams: any[] = []
pathParams: unknown[],
tally: SkipTally
): RequestDraft {
const params: KeyValueEntry[] = [];
const headers: KeyValueEntry[] = [];
Expand All @@ -143,10 +161,18 @@ function buildSwaggerOp(
(c) =>
c === "application/json" || c.startsWith("application/json;") || c.endsWith("+json")
);
// Swagger 2.0 ties `formData` encoding to `consumes` - urlencoded and multipart are
// distinct wire encodings, and Vayu models them as distinct body modes. Multipart wins
// when both are listed (only it can carry a `type: file` field), and a `consumes` that
// names neither keeps the historical multipart default.
const formMode: "form-data" | "x-www-form-urlencoded" =
consumes.some(isUrlEncodedType) && !consumes.some(isMultipartType)
? "x-www-form-urlencoded"
: "form-data";

// Resolve $ref params and dedupe by name+in (operation overrides path-item).
const byKey = new Map<string, any>();
for (const param of [...pathParams, ...(op.parameters ?? [])]) {
for (const param of [...pathParams, ...tally.params(op.parameters)] as any[]) {
const resolved = param?.$ref ? (resolveRef(param.$ref) as any) : param;
if (!resolved || !resolved.in || !resolved.name) continue;
byKey.set(`${resolved.in}:${resolved.name}`, resolved);
Expand Down Expand Up @@ -180,7 +206,7 @@ function buildSwaggerOp(
break;
}
}
if (formFields.length > 0) body = { mode: "form-data", fields: formFields };
if (formFields.length > 0) body = { mode: formMode, fields: formFields };

return {
name: op.summary ?? op.operationId ?? `${method.toUpperCase()} ${path}`,
Expand Down
Loading
Loading