From eeee26d77547f2a213895a8fd646c6fd56eb594a Mon Sep 17 00:00:00 2001 From: Atharva Kusumbia Date: Thu, 30 Jul 2026 00:34:45 +0000 Subject: [PATCH] fix(app): openapi importer - resolve $ref'd path items and form bodies, honor consumes Seven gaps in the OpenAPI 3 / Swagger 2 importers, the two silent-loss ones first: - A path item written as `{"$ref": ...}` has no method keys, so every operation under it was skipped with no request built, no `requestCount`, and nothing in `meta.skipped`. Both parsers now resolve it through `resolvePathItem`. - An urlencoded/multipart body schema behind `$ref` or `allOf` has no literal `properties`, so the form branch produced the right body mode with an empty field list. Field names now come from `schemaFieldNames`, which samples the schema - the same `$ref`/`allOf` resolution JSON bodies already got. - Swagger 2 `formData` was unconditionally multipart; it now picks `x-www-form-urlencoded` vs `form-data` from the operation's (else the spec's) `consumes`, matching what the endpoint actually accepts. - The detector claims 3.1, so the sampler now reads the 3.1 keywords it was ignoring: type arrays (`["string", "null"]` samples the first non-null member), `const` (outranking `example`), and the plural `examples`. - A `trace` operation cannot be represented (`HttpMethod` has no `"TRACE"`) and is now counted as an `unsupported_method` SkippedItem instead of vanishing from `requestCount`. - A non-array `parameters` (the missing-`-` YAML mistake) threw `is not iterable` and aborted the whole file. It is stepped over, counted as `malformed_spec`, and every other path still imports. `openapi-shared.ts` holds the three helpers both parsers needed - they are structural clones, which is how both grew the same unguarded spread and the same hardcoded `skipped: []`. Closes #193 --- app/src/services/importers/openapi-shared.ts | 76 +++++++++ app/src/services/importers/openapi-v2.test.ts | 101 +++++++++++ app/src/services/importers/openapi-v2.ts | 42 ++++- app/src/services/importers/openapi-v3.test.ts | 158 ++++++++++++++++++ app/src/services/importers/openapi-v3.ts | 42 +++-- .../services/importers/schema-sampler.test.ts | 63 ++++++- app/src/services/importers/schema-sampler.ts | 32 +++- app/src/services/importers/types.ts | 16 +- docs/app/import-collections/README.md | 21 ++- docs/app/import-collections/openapi-v2.md | 40 ++++- docs/app/import-collections/openapi-v3.md | 37 +++- 11 files changed, 587 insertions(+), 41 deletions(-) create mode 100644 app/src/services/importers/openapi-shared.ts diff --git a/app/src/services/importers/openapi-shared.ts b/app/src/services/importers/openapi-shared.ts new file mode 100644 index 00000000..d191fca1 --- /dev/null +++ b/app/src/services/importers/openapi-shared.ts @@ -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 | undefined { + if (!pathItem || typeof pathItem !== "object") return undefined; + const ref = (pathItem as { $ref?: unknown }).$ref; + if (typeof ref !== "string") return pathItem as Record; + let resolved: unknown; + try { + resolved = resolveRef(ref); + } catch { + return undefined; + } + return resolved && typeof resolved === "object" + ? (resolved as Record) + : 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(); + + 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 })); + } +} diff --git a/app/src/services/importers/openapi-v2.test.ts b/app/src/services/importers/openapi-v2.test.ts index 71087bbd..2970eae3 100644 --- a/app/src/services/importers/openapi-v2.test.ts +++ b/app/src/services/importers/openapi-v2.test.ts @@ -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", diff --git a/app/src/services/importers/openapi-v2.ts b/app/src/services/importers/openapi-v2.ts index a85d2ec2..72f1cd91 100644 --- a/app/src/services/importers/openapi-v2.ts +++ b/app/src/services/importers/openapi-v2.ts @@ -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 { if (!scheme || !scheme.type) return { mode: "none" }; if (scheme.type === "basic") return { mode: "basic", username: "", password: "" }; @@ -64,15 +75,21 @@ export class OpenApiV2Parser implements ImportParser { const tagCollections = new Map(); 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)) { @@ -116,7 +133,7 @@ export class OpenApiV2Parser implements ImportParser { folderCount: tagCollections.size, environmentCount: 0, globalCount: 0, - skipped: [], + skipped: tally.items(), nonExecutableAuth: 0, }, }; @@ -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[] = []; @@ -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(); - 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); @@ -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}`, diff --git a/app/src/services/importers/openapi-v3.test.ts b/app/src/services/importers/openapi-v3.test.ts index 03e781c8..74f9464b 100644 --- a/app/src/services/importers/openapi-v3.test.ts +++ b/app/src/services/importers/openapi-v3.test.ts @@ -87,6 +87,164 @@ describe("OpenApiV3Parser", () => { expect(req.params).toContainEqual({ key: "shared", value: "", enabled: true }); }); + it("records nothing skipped for a spec it can represent whole", () => { + expect(p.parse(parsed, raw, opts).meta.skipped).toEqual([]); + }); + + it("resolves a $ref'd path item instead of dropping every operation under it", () => { + const spec = { + openapi: "3.1.0", + components: { + pathItems: { + UserOps: { + parameters: [{ name: "expand", in: "query", schema: { type: "string" } }], + get: { summary: "Get user" }, + delete: { summary: "Delete user" }, + }, + }, + }, + paths: { "/users/{id}": { $ref: "#/components/pathItems/UserOps" } }, + }; + const result = p.parse(spec, JSON.stringify(spec), opts); + const names = result.collections[0].requests.map((r) => r.name); + expect(names).toEqual(["Get user", "Delete user"]); + expect(result.meta.requestCount).toBe(2); + // The referenced item's shared parameters come along with it. + const get = result.collections[0].requests[0]; + expect(get.params).toEqual([{ key: "expand", value: "", enabled: true }]); + expect(get.url).toBe("{{baseUrl}}/users/{{id}}"); + expect(result.meta.skipped).toEqual([]); + }); + + it("records a path item whose $ref does not resolve, rather than dropping it silently", () => { + const spec = { + openapi: "3.1.0", + paths: { + "/gone": { $ref: "#/components/pathItems/Missing" }, + "/here": { get: { summary: "Here" } }, + }, + }; + const result = p.parse(spec, JSON.stringify(spec), opts); + expect(result.meta.requestCount).toBe(1); + expect(result.meta.skipped).toEqual([{ kind: "malformed_spec", count: 1 }]); + }); + + it("resolves a $ref'd form-body schema to its fields", () => { + const spec = { + openapi: "3.0.0", + components: { + schemas: { + TokenRequest: { + type: "object", + properties: { + grant_type: { type: "string" }, + username: { type: "string" }, + password: { type: "string" }, + }, + }, + }, + }, + paths: { + "/token": { + post: { + summary: "Get token", + requestBody: { + content: { + "application/x-www-form-urlencoded": { + schema: { $ref: "#/components/schemas/TokenRequest" }, + }, + }, + }, + }, + }, + }, + }; + const req = p + .parse(spec, JSON.stringify(spec), opts) + .collections[0].requests.find((r) => r.name === "Get token")!; + expect(req.body).toEqual({ + mode: "x-www-form-urlencoded", + fields: [ + { key: "grant_type", value: "", enabled: true }, + { key: "username", value: "", enabled: true }, + { key: "password", value: "", enabled: true }, + ], + }); + }); + + it("resolves an allOf multipart form-body schema to its fields", () => { + const spec = { + openapi: "3.0.0", + components: { + schemas: { Upload: { type: "object", properties: { file: { type: "string" } } } }, + }, + paths: { + "/upload": { + post: { + summary: "Upload", + requestBody: { + content: { + "multipart/form-data": { + schema: { allOf: [{ $ref: "#/components/schemas/Upload" }] }, + }, + }, + }, + }, + }, + }, + }; + const req = p + .parse(spec, JSON.stringify(spec), opts) + .collections[0].requests.find((r) => r.name === "Upload")!; + expect(req.body).toEqual({ + mode: "form-data", + fields: [{ key: "file", value: "", enabled: true }], + }); + }); + + it("records a TRACE operation as an unsupported method instead of omitting it", () => { + const spec = { + openapi: "3.0.0", + paths: { + "/debug": { + trace: { summary: "Trace it" }, + get: { summary: "Get it" }, + }, + }, + }; + const result = p.parse(spec, JSON.stringify(spec), opts); + expect(result.meta.requestCount).toBe(1); // TRACE cannot be built; the count stays honest + expect(result.collections[0].requests.map((r) => r.name)).toEqual(["Get it"]); + expect(result.meta.skipped).toEqual([{ kind: "unsupported_method", count: 1 }]); + }); + + it("steps over a non-array parameters block instead of aborting the file", () => { + const spec = { + openapi: "3.0.0", + paths: { + // The classic hand-edited-YAML mistake: a missing `-` makes this a mapping. + "/items": { + parameters: { name: "shared", in: "query" }, + get: { summary: "List items", parameters: { name: "q", in: "query" } }, + }, + "/other": { + get: { + summary: "Other", + parameters: [{ name: "ok", in: "query" }], + }, + }, + }, + }; + const result = p.parse(spec, JSON.stringify(spec), opts); + expect(result.meta.requestCount).toBe(2); + const list = result.collections[0].requests.find((r) => r.name === "List items")!; + expect(list.params).toEqual([]); + // Every other path still imports, params and all. + const other = result.collections[0].requests.find((r) => r.name === "Other")!; + expect(other.params).toEqual([{ key: "ok", value: "", enabled: true }]); + expect(result.meta.skipped).toEqual([{ kind: "malformed_spec", count: 2 }]); + }); + it("resolves requestBody.$ref to a referenced request body", () => { const spec = { openapi: "3.0.0", diff --git a/app/src/services/importers/openapi-v3.ts b/app/src/services/importers/openapi-v3.ts index 9553727a..10bb7400 100644 --- a/app/src/services/importers/openapi-v3.ts +++ b/app/src/services/importers/openapi-v3.ts @@ -13,12 +13,20 @@ import type { ImportResult, RequestDraft, } from "./types"; -import { sampleSchema } from "./schema-sampler"; +import { sampleSchema, schemaFieldNames } from "./schema-sampler"; import { normalizeVars } from "./var-normalize"; import { mapOpenApiV3OAuth2 } from "./oauth2-import"; +import { resolvePathItem, SkipTally } from "./openapi-shared"; const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const; +/** + * Path Item Object methods Vayu has no `HttpMethod` for. `trace` is the whole list: + * OpenAPI 3's path item defines exactly the eight methods, and adding `"TRACE"` to + * `HttpMethod` is an execution-model change, not an import fix. + */ +const UNSUPPORTED_METHODS = ["trace"] as const; + /** Map an OpenAPI 3 securityScheme to a concrete collection-level auth (empty secrets). */ export function schemeToAuth(scheme: any): Exclude { if (!scheme || !scheme.type) return { mode: "none" }; @@ -63,15 +71,25 @@ export class OpenApiV3Parser implements ImportParser { const tagCollections = new Map(); 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 unsupported of UNSUPPORTED_METHODS) { + if (pathItem[unsupported] && typeof pathItem[unsupported] === "object") + tally.add("unsupported_method"); + } 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 = buildOperation(method, path, op, resolveRef, pathParams); + const req = buildOperation(method, path, op, resolveRef, pathParams, tally); const tag = op.tags?.[0]; if (tag) { if (!tagCollections.has(tag)) @@ -104,7 +122,7 @@ export class OpenApiV3Parser implements ImportParser { folderCount: tagCollections.size, environmentCount: 0, globalCount: 0, - skipped: [], + skipped: tally.items(), nonExecutableAuth: 0, }, }; @@ -138,12 +156,13 @@ function buildOperation( path: string, op: any, resolveRef: (r: string) => unknown, - pathParams: any[] = [] + pathParams: unknown[], + tally: SkipTally ): RequestDraft { const params: KeyValueEntry[] = []; const headers: KeyValueEntry[] = []; const byKey = new Map(); - 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); // later (operation) wins @@ -198,8 +217,11 @@ function buildBody(requestBody: any, resolveRef: (r: string) => unknown): Reques if (content["text/plain"]) return { mode: "text", content: "" }; for (const ct of ["application/x-www-form-urlencoded", "multipart/form-data"] as const) { if (content[ct]) { - const props = content[ct].schema?.properties ?? {}; - const fields = Object.keys(props).map((k) => ({ key: k, value: "", enabled: true })); + const fields = schemaFieldNames(content[ct].schema, resolveRef).map((k) => ({ + key: k, + value: "", + enabled: true, + })); return { mode: ct === "multipart/form-data" ? "form-data" : "x-www-form-urlencoded", fields, diff --git a/app/src/services/importers/schema-sampler.test.ts b/app/src/services/importers/schema-sampler.test.ts index 84131034..b4f93b02 100644 --- a/app/src/services/importers/schema-sampler.test.ts +++ b/app/src/services/importers/schema-sampler.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { sampleSchema } from "./schema-sampler"; +import { sampleSchema, schemaFieldNames } from "./schema-sampler"; const resolver = (ref: string): unknown => ({ @@ -45,4 +45,65 @@ describe("sampleSchema", () => { const v = sampleSchema({ $ref: "#/components/schemas/Node" }, resolver); expect(v).toEqual({ next: {} }); // first level resolves; the self-$ref collapses to {} }); + + // OpenAPI 3.1 keywords - the detector matches 3.1.x, so the sampler has to as well. + it("samples a 3.1 type array using its first non-null member", () => { + expect(sampleSchema({ type: ["string", "null"] }, resolver)).toBe(""); + expect(sampleSchema({ type: ["null", "integer"] }, resolver)).toBe(0); + expect( + sampleSchema( + { type: ["object", "null"], properties: { a: { type: "boolean" } } }, + resolver + ) + ).toEqual({ a: false }); + }); + it("samples an only-null type as null", () => { + expect(sampleSchema({ type: ["null"] }, resolver)).toBe(null); + expect(sampleSchema({ type: "null" }, resolver)).toBe(null); + }); + it("returns const verbatim, outranking example", () => { + expect(sampleSchema({ type: "string", const: "fixed" }, resolver)).toBe("fixed"); + expect(sampleSchema({ const: "fixed", example: "annotation" }, resolver)).toBe("fixed"); + expect(sampleSchema({ type: "string", const: null }, resolver)).toBe(null); + }); + it("falls back to examples[0] when there is no singular example", () => { + expect(sampleSchema({ type: "string", examples: ["first", "second"] }, resolver)).toBe( + "first" + ); + expect(sampleSchema({ example: "singular", examples: ["array"] }, resolver)).toBe( + "singular" + ); + expect(sampleSchema({ type: "string", examples: [] }, resolver)).toBe(""); + }); +}); + +describe("schemaFieldNames", () => { + it("resolves a $ref'd form schema to its property names", () => { + expect(schemaFieldNames({ $ref: "#/components/schemas/Pet" }, resolver)).toEqual([ + "id", + "name", + "tags", + ]); + }); + it("reads an inline schema's own properties, in order", () => { + expect( + schemaFieldNames( + { type: "object", properties: { grant_type: {}, username: {}, password: {} } }, + resolver + ) + ).toEqual(["grant_type", "username", "password"]); + }); + it("follows the first allOf branch, as JSON bodies do", () => { + expect( + schemaFieldNames({ allOf: [{ $ref: "#/components/schemas/Pet" }, {}] }, resolver) + ).toEqual(["id", "name", "tags"]); + }); + it("has no field names for a missing schema or one that samples to a non-object", () => { + expect(schemaFieldNames(undefined, resolver)).toEqual([]); + expect(schemaFieldNames({ type: "string" }, resolver)).toEqual([]); + expect(schemaFieldNames({ type: "array", items: { type: "string" } }, resolver)).toEqual( + [] + ); + expect(schemaFieldNames({ example: "not an object" }, resolver)).toEqual([]); + }); }); diff --git a/app/src/services/importers/schema-sampler.ts b/app/src/services/importers/schema-sampler.ts index b28a8e15..fb044bb3 100644 --- a/app/src/services/importers/schema-sampler.ts +++ b/app/src/services/importers/schema-sampler.ts @@ -18,6 +18,22 @@ export function sampleSchema(schema: unknown, resolveRef: RefResolver): unknown return walk(schema, resolveRef, 0, new Set()); } +/** + * Field names for a form body, read off the sampled stub rather than `schema.properties`. + * Going through `sampleSchema` is the point: a form schema written as `{$ref: ...}` or + * `allOf` (what generators emit) has no literal `properties`, and reading that key + * directly produced an empty field list. Form bodies now resolve exactly as far as JSON + * bodies do - first branch only for `allOf`/`oneOf`/`anyOf`, since that is what the + * sampler does for both. A schema that samples to a non-object (a scalar, an array, or an + * `example` that is not an object) has no field names to give. + */ +export function schemaFieldNames(schema: unknown, resolveRef: RefResolver): string[] { + if (schema == null) return []; + const sample = sampleSchema(schema, resolveRef); + if (!sample || typeof sample !== "object" || Array.isArray(sample)) return []; + return Object.keys(sample); +} + function walk( node: unknown, resolveRef: RefResolver, @@ -39,14 +55,26 @@ function walk( return walk(resolved, resolveRef, depth + 1, new Set([...seenRefs, schema.$ref])); } + // `const` outranks `example`: JSON Schema (adopted wholesale by OpenAPI 3.1) says the + // value MUST be exactly this, where `example` is only an annotation. + if ("const" in schema) return schema.const; if ("example" in schema) return schema.example; + // 3.1 replaced the singular `example` with an `examples` array. + if (Array.isArray(schema.examples) && schema.examples.length > 0) return schema.examples[0]; const branch = schema.allOf ?? schema.oneOf ?? schema.anyOf; if (Array.isArray(branch) && branch.length > 0) { return walk(branch[0], resolveRef, depth + 1, seenRefs); } - switch (schema.type) { + // 3.1 writes a nullable field as a type array (`["string", "null"]`) where 3.0 wrote + // `nullable: true`. Sample the first non-null member: a typed stub is what the user + // edits, and only an all-`"null"` type has nothing else to offer. + const type = Array.isArray(schema.type) + ? (schema.type.find((t: unknown) => t !== "null") ?? "null") + : schema.type; + + switch (type) { case "string": return Array.isArray(schema.enum) && schema.enum.length ? schema.enum[0] : ""; case "integer": @@ -54,6 +82,8 @@ function walk( return 0; case "boolean": return false; + case "null": + return null; case "array": return schema.items ? [walk(schema.items, resolveRef, depth + 1, seenRefs)] : []; case "object": diff --git a/app/src/services/importers/types.ts b/app/src/services/importers/types.ts index 091cdf03..3d9d4269 100644 --- a/app/src/services/importers/types.ts +++ b/app/src/services/importers/types.ts @@ -12,9 +12,21 @@ export interface ImportOptions { importScripts: boolean; } -/** A request body skipped because Vayu can't represent it (file/binary, ws, grpc, etc.). */ +/** + * Something the parse had to drop: a resource Vayu can't represent (ws, grpc, a + * file/binary body), an operation whose HTTP method it has no `HttpMethod` for + * (`unsupported_method` - OpenAPI 3's `trace`), or a shape the parser stepped over + * to keep the rest of the file importable (`malformed_spec`). + */ export interface SkippedItem { - kind: "websocket" | "grpc" | "api_spec" | "unit_test" | "file_body"; + kind: + | "websocket" + | "grpc" + | "api_spec" + | "unit_test" + | "file_body" + | "unsupported_method" + | "malformed_spec"; count: number; } diff --git a/docs/app/import-collections/README.md b/docs/app/import-collections/README.md index 18e91ccf..68298ff3 100644 --- a/docs/app/import-collections/README.md +++ b/docs/app/import-collections/README.md @@ -139,8 +139,14 @@ execution time), `preRequestScript`, `postRequestScript`. **`ImportMeta`** - `format`, `fileName?`, `requestCount`, `folderCount`, `environmentCount`, `skipped: SkippedItem[]`, `nonExecutableAuth: number`. -**`SkippedItem`** - `{ kind: "websocket" | "grpc" | "api_spec" | "unit_test" | "file_body", count }`. +**`SkippedItem`** - `{ kind: "websocket" | "grpc" | "api_spec" | "unit_test" | "file_body" | +"unsupported_method" | "malformed_spec", count }`. Surfaces work Vayu can't represent so the Preview can warn instead of silently dropping. +The last two are what the OpenAPI parsers emit: `unsupported_method` for an operation whose +HTTP method has no `HttpMethod` (OpenAPI 3's `trace`), `malformed_spec` for a shape the +parser stepped over to keep the rest of the file importable (an unresolvable `$ref`'d path +item, a non-array `parameters`). Counted via `SkipTally` in `openapi-shared.ts`, which both +OpenAPI parsers share - they are structural clones, and a second copy would drift. Supporting value types: - `KeyValueEntry`: `{ key, value, enabled, description? }` - duplicates and `enabled:false` @@ -241,11 +247,18 @@ used to build request-body stubs. It is **bounded and resilient**, not a naive o - Recurses up to `MAX_DEPTH = 6`. - Resolves `$ref` via the injected `resolveRef`, with a per-path `Set` **cycle guard** (a re-seen ref → `{}`); a failed/`null` resolution → `{}`. -- Returns a schema's `example` verbatim when present. +- Returns a pinned value verbatim, `const` → `example` → `examples[0]`. `const` wins because + JSON Schema makes it the only permitted value; `examples` is OpenAPI 3.1's plural form. - For `allOf` / `oneOf` / `anyOf`, walks the **first** branch (precedence `allOf → oneOf → anyOf`). +- Samples a 3.1 type array (`type: ["string", "null"]`) from its first non-`"null"` member. - Type defaults: `string` → `""` (or `enum[0]`), `integer`/`number` → `0`, `boolean` → - `false`, `array` → `[sample(items)]` (or `[]`), `object`/untyped → expands `properties` - recursively (else `{}`). + `false`, `null` → `null`, `array` → `[sample(items)]` (or `[]`), `object`/untyped → expands + `properties` recursively (else `{}`). + +`schemaFieldNames(schema, resolveRef)` in the same module returns the sampled stub's own keys +(`[]` when it samples to a non-object). It is how the v3 parser reads urlencoded / multipart +field names, so a form schema behind `$ref` or `allOf` resolves as far as a JSON body does +instead of reading a `properties` key that isn't there. (`schema-sampler.ts`) diff --git a/docs/app/import-collections/openapi-v2.md b/docs/app/import-collections/openapi-v2.md index e21a22fa..540837a7 100644 --- a/docs/app/import-collections/openapi-v2.md +++ b/docs/app/import-collections/openapi-v2.md @@ -85,13 +85,17 @@ Built by `buildSwaggerOp(method, path, op, spec, resolveRef, pathParams)`. | parameter `in: "query"` | `params` | `{ key: name, value: "", enabled: true, description? }` - `description` included only when present | | parameter `in: "header"` | `headers` | `{ key: name, value: "", enabled: true }` - **no description carried**; `authorization` and `content-type` headers are dropped (case-insensitive) since Vayu manages those | | parameter `in: "body"` | `body` | sampled via `sampleSchema`; JSON vs text decided by `consumes` (see [Parameters & body](#parameters--body)) | -| parameter `in: "formData"` | `body` | collected into `form-data` fields (see below) | +| parameter `in: "formData"` | `body` | collected into form fields; the encoding (`x-www-form-urlencoded` vs `form-data`) comes from `consumes` (see [`consumes` → body mode](#consumes--body-mode)) | | parameter `in: "path"` | - | not emitted as params/headers; path params are represented in the URL via `normalizeVars` | | (none) | `auth` | always `{ mode: "inherit" }` - auth is configured once at the collection level | | (none) | `preRequestScript` / `postRequestScript` | always `""` | **Parameter resolution & merge.** `buildSwaggerOp` concatenates path-item-level `parameters` (passed in as `pathParams`) with operation-level `op.parameters`, resolving any `$ref` entries via `resolveRef`. Each parameter is keyed by `` `${in}:${name}` `` in a `Map` (`byKey`), so an operation-level parameter **overrides** a path-level one with the same `in`+`name` (later writes win). Entries missing `in` or `name` after resolution are skipped. +Both lists go through `SkipTally.params` (`openapi-shared.ts`) first, shared with the v3 parser: a `parameters` value that is present but **not an array** (the missing-`-` YAML mistake) used to throw `is not iterable` and abort the whole file. It is now treated as empty and counted as a `malformed_spec` [`SkippedItem`](./README.md#draft-model-the-parser-output-contract); an absent `parameters` is normal and counted as nothing. + +**Path items.** Each `spec.paths` entry goes through `resolvePathItem` (`openapi-shared.ts`) before its methods are read, so a path item written as `{"$ref": "..."}` contributes its target's operations instead of vanishing (Swagger 2.0 allows a path-item ref; the resolver is generic, so any in-document pointer works). One hop only. A path item that is not an object, or whose `$ref` does not resolve to one, is counted as `malformed_spec` and skipped. + ## Base URL construction The base URL is assembled from three top-level spec fields and stored as the `baseUrl` collection variable on the root only: @@ -134,11 +138,11 @@ Swagger 2.0 has **no `requestBody` object** (unlike v3). Request bodies are expr | `formData` | collect into `formFields` | `{ key: name, value: "", enabled: true }` per field | | (anything else) | ignored | no `default` case action | -After the loop, **form data wins**: if any `formData` fields were collected, `body` is unconditionally replaced with `{ mode: "form-data", fields: formFields }` - overriding any body set by an `in: "body"` parameter. (A spec mixing both would be unusual, but the code resolves it in favor of form-data.) +After the loop, **form data wins**: if any `formData` fields were collected, `body` is unconditionally replaced with `{ mode: formMode, fields: formFields }` - overriding any body set by an `in: "body"` parameter. (A spec mixing both would be unusual, but the code resolves it in favor of the form.) `formMode` comes from `consumes`, below. ### `consumes` → body mode -The JSON-vs-text decision for an `in: "body"` parameter is driven by `consumes`: +`consumes` drives two decisions: JSON-vs-text for an `in: "body"` parameter, and urlencoded-vs-multipart for `formData` fields. ```ts const consumes = op.consumes ?? spec.consumes ?? []; @@ -157,6 +161,19 @@ const isJsonConsume = Note the text branch still serializes the sampled schema to JSON text (it does not blank the body - this differs from v3's `text/plain` handling, which emits an empty string). +#### `consumes` → form encoding + +Swagger 2.0 ties `formData` encoding to `consumes`, and `application/x-www-form-urlencoded` and `multipart/form-data` are distinct wire encodings that Vayu models as distinct body modes. Importing every `formData` operation as multipart (which is what this parser did unconditionally) sent a classic urlencoded login/token endpoint out as multipart, and the server rejected it with a 400/415 that nothing in the import explained. The rule now: + +| `consumes` (operation, else spec-level) | Form body mode | +|---|---| +| lists `application/x-www-form-urlencoded` and **not** `multipart/form-data` | `x-www-form-urlencoded` | +| lists `multipart/form-data` | `form-data` | +| lists **both** | `form-data` - only multipart can carry a `type: file` field | +| absent, or names neither | `form-data` (the historical default is preserved) | + +Entries are compared on the media type alone, so a `charset`/`boundary` parameter (`application/x-www-form-urlencoded; charset=utf-8`) still matches. `type: file` fields themselves are not imported as files - a `formData` parameter always becomes an empty-value text row. + ## `$ref` & schema sampling `resolveRef` resolves any JSON-pointer ref against the whole spec - Swagger model refs are `#/definitions/...`, but the resolver is generic. It strips the leading `#/`, splits on `/`, un-escapes `~1`→`/` and `~0`→`~`, and walks the spec object segment by segment. @@ -165,8 +182,9 @@ Body schemas are turned into stub values by `sampleSchema(schema, resolveRef)` ( - **Depth cap.** `MAX_DEPTH = 6`. Once `depth > 6`, the walker returns `{}`. Non-object / null nodes also return `{}`. - **`$ref` resolution + cycle guard.** A node with a string `$ref` (e.g. `#/definitions/User`) is resolved via `resolveRef` and walked (depth +1). A `Set` of already-visited `$ref` strings is threaded down each branch; re-encountering a `$ref` already on the current path returns `{}` (breaks reference cycles). Resolution failures (`throw` or `null` result) also yield `{}`. -- **`example` preference.** If the schema node has an `example` field, that value is returned verbatim (checked **after** `$ref`, before composition and `type`). This lets authors pin exact sample values. +- **Pinned-value precedence:** `const` → `example` → `examples[0]` (checked **after** `$ref`, before composition and `type`). `const` outranks `example` because JSON Schema makes it the only permitted value; `examples` is the plural form 3.1 introduced. Swagger 2.0 schemas only ever carry `example`, so in practice this parser reads that one - the other two come along because the sampler is shared. - **`allOf` / `oneOf` / `anyOf` - first branch.** If any of these is a non-empty array, the walker recurses into **`branch[0]` only** (precedence `allOf` → `oneOf` → `anyOf`). It does not merge `allOf` members. +- **Type arrays.** A `type` written as an array (a JSON-Schema / OpenAPI 3.1 shape, not legal Swagger 2.0) samples its first non-`"null"` member; an only-`"null"` type samples as `null`. - **Type defaults:** | `schema.type` | Sample value | @@ -174,10 +192,11 @@ Body schemas are turned into stub values by `sampleSchema(schema, resolveRef)` ( | `string` | `enum[0]` if a non-empty `enum` is present, else `""` | | `integer` / `number` | `0` | | `boolean` | `false` | + | `null` | `null` | | `array` | `[ sample(items) ]` if `items` is present, else `[]` (one element) | | `object` (or no/unknown `type`) | walks each entry of `properties`, producing `{ key: sample }`; `{}` if no `properties` | -`sampleSchema` is shared verbatim with the v3 parser - same depth cap, cycle guard, and branch handling. +`sampleSchema` is shared verbatim with the v3 parser - same depth cap, cycle guard, and branch handling. Its `schemaFieldNames` companion is v3-only: Swagger 2.0 form fields come from `formData` parameters, not from a schema. ## `collectionFormat` for array query params @@ -229,9 +248,10 @@ Dropped / not represented: - **`authorization` / `content-type` header parameters:** dropped (Vayu manages them). - **Path parameters as params:** not emitted (path params live in the URL only). - **Multi-tag grouping:** only the first tag groups an operation. -- **`SkippedItem`s:** never emitted - `meta.skipped` is always `[]`. +- **`type: file` form fields:** imported as ordinary empty-value rows, not as file parts. +- **A path item, or a `parameters` list, whose shape the spec does not allow:** stepped over and counted as `malformed_spec` so the rest of the file still imports. -`meta` population: `format = "OpenAPI 2.0 (Swagger)"`, `requestCount` = total operations built, `folderCount` = number of tag collections (`tagCollections.size`), `environmentCount = 0`, `skipped = []`, `nonExecutableAuth = 0` (oauth2 is now executable). +`meta` population: `format = "OpenAPI 2.0 (Swagger)"`, `requestCount` = total operations built, `folderCount` = number of tag collections (`tagCollections.size`), `environmentCount = 0`, `nonExecutableAuth = 0` (oauth2 is now executable), and `skipped` from the shared `SkipTally` - `malformed_spec` is the only kind this parser can emit (Swagger 2.0's Path Item Object has no `trace`, so there is no `unsupported_method` case here). Nothing to report still yields `[]`. ## Differences from OpenAPI 3.0 @@ -244,13 +264,14 @@ See [OpenAPI v3](./openapi-v3.md) for the v3 reference. Key contrasts: | Request body | `in: "body"` / `in: "formData"` parameters | dedicated `op.requestBody` with `content` map | | Body content-type decision | `consumes` (op → spec → JSON default) | media-type keys of `requestBody.content` | | Text/non-JSON body | sampled schema serialized as JSON text | `text/plain` → empty string | -| Form bodies | `in: "formData"` params → `form-data` (overrides body param) | `multipart/form-data` / `x-www-form-urlencoded` from `content` | +| Form bodies | `in: "formData"` params → urlencoded or multipart per `consumes` (overrides body param) | `multipart/form-data` / `x-www-form-urlencoded` from `content`, field names resolved through the sampler | +| Unsupported methods | none - Swagger 2.0 defines no `trace` | `trace` counted as `unsupported_method` | | `$ref` namespace | `#/definitions/...` | `#/components/schemas/...` (resolver is generic in both) | | Auth schemes | `securityDefinitions` (`basic`, `apiKey`, `oauth2`) | `components.securitySchemes` (`http`/bearer/basic, `apiKey`, `oauth2`) | | Auth helper | `swaggerSchemeToAuth` | `schemeToAuth` | | Collection build | inline in `parse` | helper `makeTagCollection` | -Shared between both: tree-by-first-tag, `{{baseUrl}}`-prefixed URLs, `normalizeVars` path conversion, `sampleSchema`, request `auth: inherit`, `ImportOptions` ignored, `meta.skipped` always empty. +Shared between both: tree-by-first-tag, `{{baseUrl}}`-prefixed URLs, `normalizeVars` path conversion, `sampleSchema`, request `auth: inherit`, `ImportOptions` ignored, and the `openapi-shared.ts` helpers (`resolvePathItem`, `SkipTally`) - so a `$ref`'d path item, a malformed `parameters` list, and `meta.skipped` behave identically in both. ## Shared helpers used @@ -258,6 +279,7 @@ Shared between both: tree-by-first-tag, `{{baseUrl}}`-prefixed URLs, `normalizeV |--------|--------|--------------------| | [`normalizeVars`](./README.md#normalizevars) | `var-normalize.ts` | convert Swagger `{param}` path templates → Vayu `{{param}}` in request URLs | | `sampleSchema` | `schema-sampler.ts` | generate a sample JSON body from an `in: "body"` parameter `schema` (bounded, ref-resolving) | +| `resolvePathItem`, `SkipTally` | `openapi-shared.ts` | resolve a `$ref`'d path item; guard `parameters` and tally what was dropped | This parser does **not** use the Postman/Insomnia helpers in `shared.ts` (`asString`, `toVarRecord`, `mapKeyValues`, `mapPostmanAuth`, `rawBody`, `joinExec`); it builds drafts directly. See the [index](./README.md#shared-helpers) for the full shared-helper reference. diff --git a/docs/app/import-collections/openapi-v3.md b/docs/app/import-collections/openapi-v3.md index 562baba6..d798f0b4 100644 --- a/docs/app/import-collections/openapi-v3.md +++ b/docs/app/import-collections/openapi-v3.md @@ -21,7 +21,7 @@ detect(parsed) { } ``` -The top-level `openapi` field must be a string beginning with `"3."` (so `3.0.0`, `3.0.3`, and `3.1.x` all match). Swagger 2.0 (`swagger: "2.0"`, no `openapi` field) is handled by a separate parser - see [OpenAPI v2](./openapi-v2.md). The factory (`factory.ts`) parses the raw text once (JSON, then YAML fallback) and runs each parser's `detect` in registration order. +The top-level `openapi` field must be a string beginning with `"3."` (so `3.0.0`, `3.0.3`, and `3.1.x` all match). The 3.1 claim is honoured on the schema side too: `sampleSchema` reads the JSON-Schema keywords 3.1 adopted - type arrays (`type: ["string", "null"]`), `const`, and the plural `examples` (see [`sampleSchema`](#sampleschema-schema--stub-value)) - and a `{"$ref": ...}` path item, which 3.1 made common, is resolved rather than dropped. Swagger 2.0 (`swagger: "2.0"`, no `openapi` field) is handled by a separate parser - see [OpenAPI v2](./openapi-v2.md). The factory (`factory.ts`) parses the raw text once (JSON, then YAML fallback) and runs each parser's `detect` in registration order. ## Tree structure @@ -32,7 +32,7 @@ The spec maps to a single root collection, with operations grouped into child co - `children`: one child collection per distinct first-tag, in first-encounter order. - **Tag child collections** ← created lazily by `makeTagCollection(spec, tag)` the first time a tag is seen, keyed in a `Map`. Description comes from the matching entry in the top-level `tags[]` array (if any). -Iteration order: `parse` loops over `spec.paths` entries, and for each path item over the fixed `HTTP_METHODS` list (`get, post, put, patch, delete, head, options`). For each present operation it calls `buildOperation(...)`, then routes by tag: +Iteration order: `parse` loops over `spec.paths` entries, resolves each path item through `resolvePathItem` (`openapi-shared.ts`) so a `{"$ref": "#/components/pathItems/X"}` item is read from its target instead of contributing nothing, and then loops over the fixed `HTTP_METHODS` list (`get, post, put, patch, delete, head, options`). A path item that is not an object - or whose `$ref` does not resolve to one - is counted as a `malformed_spec` [`SkippedItem`](./README.md#draft-model-the-parser-output-contract) and skipped, so the drop is named in the preview rather than silent. The ref is followed **one hop only**, like the parameter and `requestBody` refs. For each present operation `parse` calls `buildOperation(...)`, then routes by tag: ```ts const tag = op.tags?.[0]; // ONLY the first tag is used @@ -42,6 +42,8 @@ else rootRequests.push(req); **Multi-tag operations:** only `op.tags[0]` is consulted. An operation with `tags: ["a", "b"]` lands solely in the `a` child collection; `b` is ignored (no duplication, no extra folder). An operation with no `tags` (or `tags: []`) becomes a root request. +**`trace` operations:** OpenAPI 3's Path Item Object also defines `trace`, which is **not** in `HTTP_METHODS` because `HttpMethod` (`types/domain.ts`) has no `"TRACE"` - Vayu cannot execute one. A `trace` operation is therefore not built, is **not** counted in `requestCount`, and is counted as an `unsupported_method` `SkippedItem` so the preview says so. `UNSUPPORTED_METHODS` in `openapi-v3.ts` is the list; `trace` is its only member, since a path item defines exactly the eight methods. + Key internal functions: `buildOperation` (per-operation `RequestDraft`), `makeTagCollection` (per-tag `CollectionDraft`), `buildBody` / `findJsonMedia` (request body), `pickPrimaryScheme` / `schemeToAuth` (collection auth), and a closed-over `resolveRef` for `$ref` resolution. ## Field mapping @@ -92,6 +94,8 @@ Built by `buildOperation(method, path, op, resolveRef, pathParams)`. **Parameter resolution & merge.** `buildOperation` concatenates path-item-level `parameters` with operation-level `op.parameters`, resolving any `$ref` entries via `resolveRef`. Each parameter is keyed by `` `${in}:${name}` `` in a `Map`, so an operation-level parameter **overrides** a path-level one with the same `in`+`name` (later writes win). Entries missing `in` or `name` after resolution are skipped. +Both lists go through `SkipTally.params` (`openapi-shared.ts`) first. `parameters` is an array per the spec, but a missing `-` in hand-written YAML makes it a mapping, and spreading that used to throw `is not iterable` and abort the **whole file**. A present-but-non-array `parameters` is now treated as empty and counted as a `malformed_spec` `SkippedItem` (once per offending list); an absent `parameters` is normal and counted as nothing. Every other path in the file still imports. + ## URL & path parameters - The request `url` is always `` `{{baseUrl}}${normalizeVars(path)}` ``. `{{baseUrl}}` is a Vayu collection variable resolved from `servers[0].url` at import time (defined on the root collection). If the spec has no `servers`, `baseUrl` is absent from the root variables and `{{baseUrl}}` resolves to empty at runtime. @@ -105,20 +109,26 @@ Built by `buildOperation(method, path, op, resolveRef, pathParams)`. |----------------------|--------------------|-------------------------| | `application/json` (also any key starting with `application/json` or ending in `+json`, via `findJsonMedia`) | `{ mode: "json", content }` | `content = JSON.stringify(media.example ?? sampleSchema(media.schema), null, 2)`. The media-object `example` wins over the schema; if neither exists, `{}`. | | `text/plain` | `{ mode: "text", content: "" }` | empty string (the schema is not sampled for text bodies) | -| `application/x-www-form-urlencoded` | `{ mode: "x-www-form-urlencoded", fields }` | `fields` = one `{ key, value: "", enabled: true }` per `schema.properties` key | -| `multipart/form-data` | `{ mode: "form-data", fields }` | same as urlencoded; key per `schema.properties` | +| `application/x-www-form-urlencoded` | `{ mode: "x-www-form-urlencoded", fields }` | `fields` = one `{ key, value: "", enabled: true }` per field name from `schemaFieldNames(schema)` | +| `multipart/form-data` | `{ mode: "form-data", fields }` | same as urlencoded | | no `content`, or none of the above | `{ mode: "none" }` | | JSON is preferred: `findJsonMedia` is checked first and takes precedence over text/form variants. The `x-www-form-urlencoded` / `multipart/form-data` branch only reads property **names** - property schemas, `required`, and nested structure are not sampled into form fields. +**Form field names resolve `$ref` and `allOf`, exactly as far as a JSON body does.** `schemaFieldNames(schema, resolveRef)` (`schema-sampler.ts`) samples the schema and returns the stub's own keys, rather than reading `schema.properties` directly. That key does not exist on a schema written as `{"$ref": "#/components/schemas/TokenRequest"}` or as an `allOf` - the shape generators emit - which used to yield the right body mode with an **empty** field list and no warning anywhere. Consequences of going through the sampler: composition follows the **first** branch only (the same rule JSON bodies get, not an `allOf` merge), and a schema that samples to a non-object (a scalar, an array, or an `example` that is not an object) contributes no field names. + ### `sampleSchema` (schema → stub value) `sampleSchema(schema, resolveRef)` in `schema-sampler.ts` generates a sample JSON value by walking the schema. It is **bounded and recursive** - materially more capable than a one-level stub: - **Depth cap.** `MAX_DEPTH = 6`. Once `depth > 6`, the walker returns `{}`. Non-object / null nodes also return `{}`. - **`$ref` resolution + cycle guard.** A node with a string `$ref` is resolved via `resolveRef` and walked (depth +1). A `Set` of already-visited `$ref` strings is threaded down each branch; re-encountering a `$ref` already on the current path returns `{}` (breaks reference cycles). Resolution failures (`throw` or `null` result) also yield `{}`. -- **`example` preference.** If the schema node has an `example` field, that value is returned verbatim (checked **after** `$ref`, before composition and `type`). This lets authors pin exact sample values. +- **Pinned-value precedence:** `const` → `example` → `examples[0]`, all checked **after** `$ref` and before composition and `type`. + - **`const`** (JSON Schema, so OpenAPI 3.1) is returned verbatim and **outranks `example`**: it says the value MUST be exactly this, where `example` is only an annotation. `const: null` samples as `null`. + - **`example`** is returned verbatim - this is how authors pin exact sample values. + - **`examples`** is 3.1's plural replacement for `example`; its **first** entry is used when there is no singular `example`. An empty `examples: []` is ignored. - **`allOf` / `oneOf` / `anyOf` - first branch.** If any of these is a non-empty array, the walker recurses into **`branch[0]` only** (precedence `allOf` → `oneOf` → `anyOf`). It does not merge `allOf` members; it just samples the first. +- **Type arrays (3.1).** 3.1 writes a nullable field as `type: ["string", "null"]` where 3.0 wrote `nullable: true`. The walker samples the **first non-`"null"`** member, so such a field gets the typed stub the user edits rather than the `{}` an unmatched type used to produce. A type whose only member is `"null"` (and the scalar `type: "null"`) samples as `null`. - **Type defaults:** | `schema.type` | Sample value | @@ -126,11 +136,14 @@ JSON is preferred: `findJsonMedia` is checked first and takes precedence over te | `string` | `enum[0]` if a non-empty `enum` is present, else `""` | | `integer` / `number` | `0` | | `boolean` | `false` | + | `null` | `null` | | `array` | `[ sample(items) ]` if `items` is present, else `[]` (one element) | | `object` (or no/unknown `type`) | walks each entry of `properties`, producing `{ key: sample }`; `{}` if no `properties` | The `object`/default branch is the same fallback used for untyped schemas - a node with `properties` but no `type` is still expanded. +`schemaFieldNames(schema, resolveRef)` is the form-body wrapper over this: it samples the schema and returns `Object.keys()` of the result (`[]` for a non-object sample). See [Request body generation](#request-body-generation). + > Older notes claimed sampling was "one level only" and "`oneOf` → `{}`". That is **not** what the code does: sampling recurses to depth 6, resolves and cycle-guards `$ref`s, honors `example`, and follows the first branch of `oneOf`/`anyOf`/`allOf`. ## Auth / security @@ -167,8 +180,18 @@ Dropped / not represented: - **Cookie parameters** and **path parameters as params**: not emitted (path params live in the URL only). - **`authorization` / `content-type` header parameters:** dropped (Vayu manages them). - **Multi-tag grouping:** only the first tag groups an operation. +- **`trace` operations:** dropped - `HttpMethod` has no `"TRACE"`. Counted as `unsupported_method` (see [Tree structure](#tree-structure)), not silently omitted. +- **A path item, or a `parameters` list, whose shape the spec does not allow:** stepped over and counted as `malformed_spec` so the rest of the file still imports. +- **Form-field property schemas:** only field **names** are imported; `required`, types, and nested structure are not. + +`meta` population: `format = "OpenAPI 3.0"`, `requestCount` = total operations built (TRACE excluded), `folderCount` = number of tag collections, `environmentCount = 0`, `nonExecutableAuth = 0` (oauth2 is now executable), and `skipped` from the `SkipTally`: + +| `SkippedItem.kind` | Counted when | +|--------------------|--------------| +| `unsupported_method` | a path item carries a `trace` operation | +| `malformed_spec` | a path item is not an object / its `$ref` does not resolve; or a `parameters` value is present but not an array | -`meta` population: `format = "OpenAPI 3.0"`, `requestCount` = total operations built, `folderCount` = number of tag collections, `environmentCount = 0`, `skipped = []` (this parser never emits `SkippedItem`s), `nonExecutableAuth = 0` (oauth2 is now executable). +An import with nothing to report still yields `skipped: []` - only non-zero kinds are emitted. ## Shared helpers used @@ -176,6 +199,8 @@ Dropped / not represented: |--------|--------|--------------------| | [`normalizeVars`](./README.md#normalizevars) | `var-normalize.ts` | convert OpenAPI `{param}` path templates → Vayu `{{param}}` in request URLs | | `sampleSchema` | `schema-sampler.ts` | generate a sample JSON body from a request `schema` (bounded, ref-resolving) | +| `schemaFieldNames` | `schema-sampler.ts` | field names for an urlencoded / multipart body, resolved through the sampler | +| `resolvePathItem`, `SkipTally` | `openapi-shared.ts` | resolve a `$ref`'d path item; guard `parameters` and tally what was dropped | This parser does **not** use the Postman/Insomnia helpers in `shared.ts` (`asString`, `toVarRecord`, `mapKeyValues`, `mapPostmanAuth`, `rawBody`, `joinExec`); it builds drafts directly. See the [index](./README.md#shared-helpers) for the full shared-helper reference.