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.jsonwas recorded from the parser this replaced and is asserted against on every build - it is simply read byengine/src/core/import_document.cppnow, behindPOST /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:
- 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). - Calls
pmFolder(parsed, ctx)on the top-level collection object itself - the root collection is just a folder whoseinfocarries the collection name/description. - Merges
counts.pathVariablesinto the root collection'svariables, skipping a key the collection already declared explicitly. - Builds
meta, pushing aSkippedItemfor 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 viapmFolder(child, ctx), push intochildren. - Request (
child.requestis present) →pmRequest(child, ctx), push intorequests. - Not an object at all (
null, a string, a number) → skipped, counting towardctx.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 bareTypeError: Cannot read properties of nullthat 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 norequest) 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.name → name (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_varsapplied),params = []. If there is a?, the substring before?is the base and the query string goes throughqueryEntries: split on&, eachkey=valuepair URL-decoded, withvaluerun throughnormalize_template_vars; missing=yields an empty value. All extracted params areenabled: true. - Object url (v2.1):
url.rawis split at the first?to get the base (normalize_template_varsapplied); query parameters come fromurl.query[]viamap_key_values(so disabled query params and descriptions are preserved). Whenquery[]is absent or empty andrawcarries a query string,raw's query is parsed instead via the samequeryEntries- schema-legal and produced by hand-written or script-generated collections that populate onlyraw, where the query used to be discarded silently. Whenquery[]has entries it always wins, since it carries disabled state and descriptionsrawcannot. - Object url with no
raw(schema-legal, rare - most exports always writeraw):host_path_urlassembles a base fromprotocol(defaulthttps),host[](or a bare string) joined with., an optionalport, andpath[](string or{value}variable entries) joined with/. Counted asurl_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_authoutput verbatim -inheritis a valid mode for aRequestDraftand is resolved at execution time. A request's ownnoauthbecomes{ 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
keyare skipped; - enabled state is
!disabledifdisabledis set, elseenabledif set, elsetrue; - the value is coerced to a string (
as_string) and run throughnormalize_template_vars; type: "secret"setssecret: true; adescription, or any other declaredtype, is read and discarded - Vayu's variable record has no field for either. Counted once per row asvariable_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.tscarries a TODO comment implyingskipped/nonExecutableAuthare not yet wired up. That comment is stale for this parser - both fields are populated here as described above (within the limits noted: onlyfile_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 |