Skip to content

Postman Collection v2.1 / v2.0

Parses exported Postman Collection JSON (schema v2.1.0 and v2.0.0) into the Vayu draft model. Both versions share the same parse implementation; the only differences are detection and the shape of the url/auth objects (handled transparently by the shared helpers).

  • Source: engine/src/core/import_document.cpp
  • Exports:

The parse moved engine-side (issue #877). Every rule on this page is the same rule it always was - the corpus in engine/tests/fixtures/import-conformance.json was recorded from the parser this replaced and is asserted against on every build - it is simply read by engine/src/core/import_document.cpp now, behind POST /import/parse, rather than in the renderer. Module names in C++ style below name that file's functions; the app holds no parser.

Class formatName formatKey
PostmanV21Parser Postman Collection v2.1 postman-v21
PostmanV20Parser Postman Collection v2.0 postman-v20

Both implement ImportParser (detect + parse) from ./types.

Detection

The factory (core::parse_import) parses the raw string once (JSON, then YAML fallback) and runs each parser's detect(parsed, raw) in registration order until one returns true.

Class detect() logic
PostmanV21Parser parsed.info.schema is a string containing "v2.1.0".
PostmanV20Parser parsed.info.schema is a string containing "v2.0.0"; or parsed.info is present, parsed.item is an array, and schema == null (no schema field at all → treated as v2.0).

The match is a substring check (schema.includes(...)), so the full schema URL (e.g. https://schema.getpostman.com/json/collection/v2.1.0/collection.json) is accepted.

Why v2.1 is tried first: the factory's PARSERS array lists PostmanV21Parser before PostmanV20Parser. v2.1 has the stricter test (exact v2.1.0 substring), and v2.0's fallback branch is permissive (it claims any info + item[] document with no schema). Ordering v2.1 first ensures a true v2.1 file is never swallowed by v2.0's loose fallback.

Parse flow

parse() on either class delegates to the module-level parsePostman(parsed, opts, formatName), which:

  1. Creates a mutable Ctx ({ opts, requestCount, folderCount, nonExecutableAuth, skippedFileBody, skippedMalformed, skippedUnsupportedMethod, skippedUnsupportedAuth, skippedPathVariables, skippedUrlWithoutRaw, skippedVariableMetadata, pathVariables }) threaded through the whole walk to accumulate counters, plus the path variables collected off every request's URL (see URL handling).
  2. Calls pmFolder(parsed, ctx) on the top-level collection object itself - the root collection is just a folder whose info carries the collection name/description.
  3. Merges counts.pathVariables into the root collection's variables, skipping a key the collection already declared explicitly.
  4. Builds meta, pushing a SkippedItem for each counter that is greater than zero.

Tree walk - pmFolder

pmFolder(node, ctx) walks node.item[]. For each child:

  • Folder (Array.isArray(child.item) is true) → ctx.folderCount += 1, recurse via pmFolder(child, ctx), push into children.
  • Request (child.request is present) → pmRequest(child, ctx), push into requests.
  • Not an object at all (null, a string, a number) → skipped, counting toward ctx.skippedMalformed. Hand-edited or script-filtered JSON can contain these, and the v2.0 detector's permissive fallback accepts such a file; dereferencing the entry used to throw a bare TypeError: Cannot read properties of null that failed the whole import naming neither the format nor an item. event[] entries are filtered the same way (pmEvents).
  • Anything else (an object with no item[] and no request) is silently ignored.

Folder vs request discrimination is purely structural: presence of an item array makes a node a folder, otherwise presence of a request makes it a request. Nesting is unbounded (direct recursion).

The returned CollectionDraft carries name, description, variables, auth, the two scripts, and its children/requests. The root and every folder are built by the same function - the root is simply the outermost pmFolder result and becomes collections[0] (the only root; parentId = null).

Request build - pmRequest

pmRequest(item, ctx) reads item.request, derives url/params via pmUrl, maps auth via map_postman_auth, increments ctx.requestCount, and (if the request auth mode is digest/aws/ntlm) increments ctx.nonExecutableAuth. Scripts come from item.event[] (prerequest, test); redirect settings come from item.protocolProfileBehavior (see Redirect settings).

Field mapping

Collection (root)

The root is produced by pmFolder(parsed, ctx); parsed is the whole collection object.

Postman Vayu CollectionDraft Notes
info.namename (fallback name"Imported Collection") name info.name ?? name ?? "Imported Collection"
info.description (fallback description) description string used directly; if object, .content is used; else ""
variable[] variables via to_var_record
auth auth via collectionAuth (see Auth)
event[] (prerequest) preRequestScript via join_exec; "" when importScripts is false
event[] (test) postRequestScript via join_exec; "" when importScripts is false
nested item[] (folders) children recursion
item[] (requests) requests

Collection (folder)

Same pmFolder mapping. A folder node has name/description/variable/auth/event at the top level (no info wrapper), but the code reads node.info?.name ?? node.name and node.info?.description ?? node.description, so both shapes work. Each nested folder increments ctx.folderCount.

Request

Postman (item / item.request) Vayu RequestDraft Notes
item.name name fallback "Untitled"
request.description description string used directly; if object, .content; else ""
request.method method toMethod: upper-cased; if not one of GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS → GET, counted as unsupported_method (a custom verb such as PROPFIND or PURGE)
request.url url, params via pmUrl (see URL handling)
request.header[] headers via map_key_values
request.body body via pmBody (see Body mapping)
request.auth auth via map_postman_auth; inherit allowed for requests
item.event[] (prerequest) preRequestScript via join_exec; "" when importScripts is false
item.event[] (test) postRequestScript via join_exec; "" when importScripts is false
item.protocolProfileBehavior.followRedirects followRedirects only when it is a boolean; otherwise absent (engine default true)
item.protocolProfileBehavior.maxRedirects maxRedirects only when it is a finite number; otherwise absent (engine default 10)
item.protocolProfileBehavior.strictSSL verifySSL only when it is a boolean; otherwise absent (engine default true)
item.response[] examples via pmExamples (see Saved responses); absent when the item saved none
request.certificate clientCertificates[] (top-level, not a request field) resolved into a client_certificates registry entry keyed on the request's own host, applied best-effort by POST /import/apply after the rest of the tree commits (issue #1656) - Vayu's client certificates belong to a host, not a request. A candidate is resolved only when it would pass the same check POST /client-certificates runs: a readable PEM pair or PKCS#12 file, a literal (not {{var}}) host, and no earlier request in this import already claiming a different certificate for that (host, port). Anything else counts as certificate instead
request.proxy - not imported - Vayu has no per-request proxy override, and none is planned: TransportPolicy is workspace/run-scoped (engine/CLAUDE.md), so this stays a permanent tally rather than a mapping (issue #1656's own recorded decision); counted as proxy_config

Saved responses

Postman stores a request's recorded responses in item.response[]. They were read by nothing until the engine had a table for them (issue #481), so importing a collection whose value was its documented responses produced a collection with none - and the loss was not even counted.

pmExamples(item, ctx) maps each entry to an ExampleDraft:

Postman (item.response[] entry) Vayu ExampleDraft Notes
name name fallback "Example"
code status only when it is a finite number; otherwise 200, which is what Postman shows for a saved response with no code
header[] headers via map_key_values - source order and duplicates (Set-Cookie) preserved
body body stored verbatim
header[] Content-Type contentType "" when the recorded response carried none

_postman_previewlanguage is deliberately not read into contentType: it is an editor mode ("json", "html"), not a media type, and storing it would put a value in that field which is not one.

An entry that is not an object counts toward malformed_item, the same treatment pmFolder gives a malformed item. A request that saved no responses omits examples entirely rather than sending [] - the orchestrator forwards presence, and an empty array reads as "this request documents no responses".

meta.exampleCount totals what survived, counted off the drafts by count_examples (the same read-the-result approach unattached_file_parts uses), and the import preview shows it.

Redirect settings

Postman writes item-level protocolProfileBehavior exactly when the user overrides redirect or TLS handling for that request, so it is present precisely where it matters. pmRedirects(item) reads the three fields Vayu stores per request and the orchestrator forwards them on POST /import/apply.

All three fields are optional on the draft and omitted from the payload when the source did not state them - the engine then applies its own defaults (followRedirects: true, maxRedirects: 10, verifySSL: true). An absent field must not look like a stated true: the engine follows redirects and verifies certificates by default, so dropping a source false silently follows the 3xx the request exists to inspect, or trusts a host the export deliberately did not.

Values of the wrong type are ignored rather than coerced (a "false" string would read as the user's setting while being its opposite). Collection- and folder-level protocolProfileBehavior is not read: Vayu stores these settings per request only, so there is nowhere to put it.

URL handling

pmUrl(url) handles both shapes:

  • String url (v2.0, sometimes v2.1): if there is no ?, the whole string is the base URL (normalize_template_vars applied), params = []. If there is a ?, the substring before ? is the base and the query string goes through queryEntries: split on &, each key=value pair URL-decoded, with value run through normalize_template_vars; missing = yields an empty value. All extracted params are enabled: true.
  • Object url (v2.1): url.raw is split at the first ? to get the base (normalize_template_vars applied); query parameters come from url.query[] via map_key_values (so disabled query params and descriptions are preserved). When query[] is absent or empty and raw carries a query string, raw's query is parsed instead via the same queryEntries - schema-legal and produced by hand-written or script-generated collections that populate only raw, where the query used to be discarded silently. When query[] has entries it always wins, since it carries disabled state and descriptions raw cannot.
  • Object url with no raw (schema-legal, rare - most exports always write raw): host_path_url assembles a base from protocol (default https), host[] (or a bare string) joined with ., an optional port, and path[] (string or {value} variable entries) joined with /. Counted as url_without_raw, informational rather than lossy - the URL is built, not dropped.

Decoding never aborts the import. queryEntries decodes through safeDecode, which returns the still-encoded text when decodeURIComponent throws. Postman does not percent-validate a typed URL, so a literal % in a value (?discount=50%, a LIKE pattern) is realistic - and a bare decodeURIComponent used to raise URIError: URI malformed out of parseImport, failing an entire file with no pointer to the offending request.

That still-encoded text is not lossless on rejoin. joinParamsIntoUrls (below) re-encodes every param through encodeURIComponent before appending it back onto url, and a literal % that safeDecode gave back unchanged (%ZZ) gets percent-encoded like any other reserved character, into %25ZZ - the params table still reads %ZZ, but the stored URL no longer matches the source text (issue #1460). queryEntries counts this as invalid_percent_encoding rather than changing the value silently.

Path variables (url.variable[]): the base URL's path is walked one /-delimited segment at a time, and a segment that is exactly :key for a declared key is rewritten to Vayu's {{key}} template - every occurrence, not just the first, so /:id/copies/:id rewrites both - with the entry's value recorded once per key. Every recorded key is merged into the root collection's variables after the whole tree is walked, skipping a key the collection already declared explicitly - so {{userId}} has something to resolve against without a hand-edit. Counted as path_variables (once per request whose URL carried a substitution), informational rather than lossy: the value survives as a variable.

The split is pmUrl's output, not the stored shape. parseImport rejoins each request's enabled params onto its url afterwards, because that is where every execution path reads the query from - see The url/params invariant. So {{baseUrl}}/users?page=1&trace=1 with trace disabled parses to base + two params here, and is stored as {{baseUrl}}/users?page=1 with both params still in the table.

Body mapping

pmBody(body, ctx) switches on body.mode. A missing body or missing body.mode{ mode: "none" }. body.disabled: true ("prevent request body from being sent") is checked first and also imports as { mode: "none" } regardless of mode - counted as disabled_body (issue #1444) rather than sending a body the user had turned off.

Postman body.mode Vayu RequestBody Notes
raw rawBody(body.raw, body.options.raw.language) see raw sniffing below
urlencoded { mode: "x-www-form-urlencoded", fields } fields = mapKeyValues(body.urlencoded)
formdata { mode: "form-data", fields } text entries via map_key_values; a type: "file" entry becomes a file row per path in src (a string or an array - Postman allows several files per field), marked unresolved. Only a file entry naming no path adds to ctx.skippedFileBody.
graphql { mode: "graphql", content } via graphqlContent - the graphql object is serialized to JSON with variables parsed (see below); operationName rides along, and the request gains a Content-Type (see below)
file { mode: "none" } adds 1 to ctx.skippedFileBody - a whole-body file is a shape Vayu has no mode for (unlike a multipart file part, which imports)
anything else { mode: "none" }

GraphQL variables (graphqlContent): Postman stores body.graphql as { query, variables } where variables is the text of the Variables pane - a JSON-encoded string. Vayu's own serializeGraphQLBody writes variables as an object, and the engine sends the stored content verbatim, so the string is parsed here; embedding it as-is put "variables": "{\"limit\": 10}" on the wire (spec-invalid) and showed a double-escaped blob in the Variables pane. Two deliberate fallbacks: a variables string that is not valid JSON is kept as text (the pane text is the only copy of the user's work, so an import that deletes it is worse than one that shows it unparsed - and the pane now shows a string-typed variables verbatim rather than as an escaped blob, converting it to an object once an edit makes it parse), and an empty or whitespace-only string drops the key entirely, which is what Vayu writes for an empty pane. Every other key on the object rides along untouched.

GraphQL operationName: preserved verbatim, like every other key on the object. It names which operation in a multi-operation document to execute, and Vayu's GraphQL panes carry it through an edit and expose it as an operation picker above the query pane - so an imported request keeps running the operation it was imported with.

GraphQL Content-Type (with_required_content_type in import_document.cpp): a GraphQL body is a JSON envelope, so the request needs Content-Type: application/json - and Vayu's request builder adds that header only when you pick GraphQL, which an import never does. The header was therefore absent, and libcurl defaults to application/x-www-form-urlencoded, which most GraphQL servers answer with a 400; nothing in the app said why. The header is now written at import, through the same contentTypeToAdd rule the mode picker uses: a Content-Type the collection declares wins (including a deliberate application/graphql), and a disabled row does not count as declaring one.

Raw language sniffing (raw_body in import_document.cpp):

options.raw.language Result
"json" { mode: "json", content }
"text" { mode: "text", content }
"xml" { mode: "xml", content } - and the request gains Content-Type: application/xml through the same with_required_content_type rule GraphQL uses (below)
absent / other tries JSON.parse(content); success → { mode: "json" }, failure → { mode: "text" }

An unlabelled body is never sniffed into xml: without Postman's language, a <-shaped document is as likely to be HTML, and guessing would hand the request a Content-Type the server may disagree with. "xml" is the only new mapping - "html", "javascript" and the rest still fall through to the sniff.

Dropped: binary/file bodies (mode file) and per-field file uploads inside formdata. Both are counted into ctx.skippedFileBody and surface as a single { kind: "file_body", count } SkippedItem.

Auth mapping

Auth is mapped by mapPostmanAuth(auth) (import_document.cpp). It reads auth.type, then flattens the type-specific detail via authDetail(auth[type]).

Postman auth.type Vayu RequestAuth Notes
(absent / no type) { mode: "inherit" }
bearer { mode: "bearer", token } token normalized
basic { mode: "basic", username, password } both normalized
apikey { mode: "apikey", key, value, in } in = "query" only if detail in === "query", else "header"
oauth2 { mode: "oauth2", config: OAuth2Config } mapped via map_postman_oauth2 (import_document.cpp) - executable; grant normalized, minimal accessToken-only exports become a bearer token. tokenName, when present, is stored as config.credentialsId - Vayu's field for keeping otherwise-identical token-cache entries apart. state is never stored (Vayu generates and validates its own per authorization attempt) and a pre-fetched accessToken alongside an explicit grant config has nowhere to seed a fetch that always runs through that grant; both are counted as oauth2_dropped_field rather than silently discarded (issue #1460)
awsv4 { mode: "aws", config } awsv4 is the schema's enum value for AWS Signature; Vayu's internal mode is aws, so the name is translated rather than passed through. Matching on "aws" here dropped every real SigV4 export to {mode:"none"} and suppressed the nonExecutableAuth warning
digest / ntlm { mode: type, config } config is the raw flattened detail map; not executed by Vayu (counted as nonExecutableAuth per request, as aws is)
inherit { mode: "inherit" }
noauth { mode: "none" } on a request; a collection/folder noauth is terminal - see below; this is the correct mapping, not a drop, so it is not counted
hawk / oauth1 / edgegrid / non-string type { mode: "none" } counted as unsupported_auth - schemes Postman defines that Vayu has no mode for, unlike awsv4/digest/ntlm, which import as data

authDetail - v2.1 array vs v2.0 object: Postman stores auth detail either as an array of { key, value } entries (v2.1) or as a plain object (v2.0). authDetail handles both: arrays are folded into a { key: value } map (skipping entries without key); objects have every entry coerced to a string. The result is the same flat string map regardless of source version, so the rest of map_postman_auth is version-agnostic.

Collection / folder vs request inherit rules:

  • Requests keep map_postman_auth output verbatim - inherit is a valid mode for a RequestDraft and is resolved at execution time. A request's own noauth becomes { mode: "none" }, which already means "send nothing" for a request.
  • Collections and folders go through collectionAuth, which distinguishes two states Postman keeps apart:
Postman collection/folder auth CollectionDraft.auth Inheritance
absent, or {"type":"inherit"} { mode: "none" } transparent - a descendant's inherit keeps climbing
{"type":"noauth"} (explicit No Auth) { mode: "noauth" } terminal - descendants send no credentials
any concrete type that mode the descendant inherits it

Collections never inherit (CollectionDraft.auth excludes inherit), which is why inherit collapses to none. The explicit-noauth case must not collapse with it: the resolution walk steps over none, so a request set to Inherit inside a No Auth folder used to resolve to the root collection's credentials - sending a bearer token to the endpoints the user had marked unauthenticated. The terminal mode is read by resolveAuthSource (renderer) and composeAuth (MCP); see variable resolution → auth inheritance.

nonExecutableAuth counting: only request auth contributes (pmRequest increments the counter), and it keys off the mapped mode, so awsv4 counts as aws. Collection/folder auth in the digest/aws/ntlm family is stored but not counted. oauth2 is executable and never counts.

Variables & environments

Collection- and folder-level variable[] arrays map to CollectionDraft.variables via to_var_record:

  • entries without a key are skipped;
  • enabled state is !disabled if disabled is set, else enabled if set, else true;
  • the value is coerced to a string (as_string) and run through normalize_template_vars;
  • type: "secret" sets secret: true; a description, or any other declared type, is read and discarded - Vayu's variable record has no field for either. Counted once per row as variable_metadata, whether the row carried one of the two or both.

Postman collection files do not embed environments, so this parser always returns environments: [] and meta.environmentCount: 0. Postman exports environments as separate files, which import_document.cpp reads.

Options & lossy behavior

importScripts is honored: when opts.importScripts is false, pmRequest and pmFolder emit "" for both preRequestScript and postRequestScript (the join_exec call is gated behind the flag). When true, join_exec joins the event's script.exec array with \n (or returns the string form, else ""). importEnvironments is accepted but unused by this parser (no environments to import).

meta.skipped - this parser populates: file_body (from formdata file fields and file-mode bodies), malformed_item (non-object item[]/event[] entries), unsupported_method (a custom HTTP verb, falls back to GET), unsupported_auth (hawk/oauth1/edgegrid/a non-string type, falls back to no auth), oauth2_dropped_field (an oauth2 block's state, or a pre-fetched accessToken beside an explicit grant config - see Auth mapping), path_variables and url_without_raw (informational - a URL shape that was mapped rather than dropped, see URL handling), invalid_percent_encoding (a query key or value whose invalid % escape changes when rejoined into the URL, see URL handling), variable_metadata (a collection, folder, environment or globals variable's description or non-secret type), disabled_body (a request body whose own disabled was true - see Body mapping), certificate (a request's own certificate the engine could not resolve into a client_certificates registry candidate - no cert.src/key.src, an unreadable file, an unresolved {{var}} host, or a second, different certificate for a (host, port) an earlier request in this import already claimed; a resolvable one is applied instead, see the field table above and issue #1656), and proxy_config (a request's own proxy override - there is no per-request proxy mechanism to import it into, and none is planned, so this tally is permanent). It does not emit websocket, grpc, api_spec, or unit_test items.

meta.nonExecutableAuth - populated: incremented once per request whose mapped auth mode is digest, aws, or ntlm. These auths are stored on the draft (with their config) but Vayu has no execution path for them. oauth2 is now mapped to an executable config and does not count.

Note: types.ts carries a TODO comment implying skipped/nonExecutableAuth are not yet wired up. That comment is stale for this parser - both fields are populated here as described above (within the limits noted: only file_body, and request-level non-executable auth).

Shared helpers used

All defined in engine/src/core/import_document.cpp (except normalize_template_vars, which is engine/src/core/path_template.cpp); see the index for full reference.

Helper Use in this parser
as_string coerce any scalar to its string form (values are stored as strings) - used inside to_var_record/authDetail
to_var_record collection/folder variable[]CollectionDraft.variables
map_key_values header[], query[], urlencoded[], formdata[]KeyValueEntry[] (preserves disabled + duplicates)
map_postman_auth auth object → RequestAuth (request and, via collectionAuth, collection/folder)
raw_body raw-mode body → RequestBody with JSON/text language sniffing
join_exec event.script.exec → joined script string
normalize_template_vars rewrite {{ x }} / {{ _.x }} template syntax to Vayu {{x}} (path_template.cpp); applied to URLs, values, vars, and auth fields. Called without pathTemplates, so a literal single-brace {x} is left alone - in Postman only {{x}} is a template, and rewriting /tags/{beta} or fields=friends{name} invented a variable that resolved to nothing