diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiSchemaType.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiSchemaType.java
index 5712ca04..3dc2b742 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiSchemaType.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiSchemaType.java
@@ -1,12 +1,16 @@
package com.bablsoft.accessflow.apigov.api;
/**
- * Type of an uploaded API schema document, dispatched to the matching parser
- * (OpenAPI → swagger-parser, WSDL → wsdl4j, GraphQL SDL → graphql-java, gRPC proto → wire-schema).
+ * Type of an uploaded API schema document, dispatched to the matching parser (OpenAPI →
+ * swagger-parser; WSDL → JDK DOM; GraphQL SDL and gRPC proto → lightweight regex parsers; Postman
+ * collection → JSON tree). {@code POSTMAN_COLLECTION} accepts a Postman Collection v2.x export and
+ * is the only source whose request/response schemas are inferred from examples rather than
+ * declared.
*/
public enum ApiSchemaType {
OPENAPI,
WSDL,
GRAPHQL_SDL,
- GRPC_PROTO
+ GRPC_PROTO,
+ POSTMAN_COLLECTION
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiSchemaView.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiSchemaView.java
index 4b7d1891..fb98a52c 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiSchemaView.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiSchemaView.java
@@ -7,6 +7,9 @@
* Read view of an uploaded schema document (raw content is not echoed back in lists).
* {@code operationCount} is the post-filter (kept) count surfaced everywhere; {@code totalOperationCount}
* is the number of operations the document defines before {@code operationFilter} is applied.
+ * {@code detectedAuthMethod} is the auth scheme the uploaded document declared, when the format
+ * carries one ({@code null} otherwise) — an admin hint only; no credential from the document is
+ * ever read or stored.
*/
public record ApiSchemaView(
UUID id,
@@ -16,5 +19,6 @@ public record ApiSchemaView(
int operationCount,
int totalOperationCount,
OperationFilter operationFilter,
+ ApiAuthMethod detectedAuthMethod,
Instant createdAt) {
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ParsedApiSchema.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ParsedApiSchema.java
new file mode 100644
index 00000000..0bb6d89f
--- /dev/null
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ParsedApiSchema.java
@@ -0,0 +1,26 @@
+package com.bablsoft.accessflow.apigov.api;
+
+import java.util.List;
+
+/**
+ * Result of parsing an uploaded API schema document.
+ *
+ *
{@code detectedAuthMethod} is the authentication scheme the document itself declares, when the
+ * format carries one (Postman collections do; OpenAPI/WSDL/SDL/proto currently do not) — a hint for
+ * the admin only. The credential values are never read.
+ *
+ *
{@code sanitizedContent} lets a parser replace what gets persisted as the schema's raw content.
+ * {@code null} means "store the uploaded document unchanged"; a non-null value is stored instead.
+ * Postman exports routinely carry live tokens and arbitrary pre-request/test JavaScript, neither of
+ * which may be retained.
+ */
+public record ParsedApiSchema(
+ List operations,
+ ApiAuthMethod detectedAuthMethod,
+ String sanitizedContent) {
+
+ /** For parsers whose format declares no auth and needs no sanitization. */
+ public ParsedApiSchema(List operations) {
+ this(operations, null, null);
+ }
+}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiSchemaService.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiSchemaService.java
index 0f02d444..0a876034 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiSchemaService.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiSchemaService.java
@@ -51,14 +51,18 @@ public ApiSchemaView upload(UUID connectorId, UUID organizationId, ApiSchemaType
requireConnector(connectorId, organizationId);
var content = (rawContent == null || rawContent.isBlank()) && sourceUrl != null && !sourceUrl.isBlank()
? fetch(sourceUrl) : rawContent;
- var operations = parserRegistry.parse(schemaType, content);
+ var parsed = parserRegistry.parse(schemaType, content);
+ var operations = parsed.operations();
var effectiveFilter = filter == null ? OperationFilter.EMPTY : filter;
var kept = filterMatcher.apply(operations, effectiveFilter);
var entity = new ApiSchemaEntity();
entity.setId(UUID.randomUUID());
entity.setConnectorId(connectorId);
entity.setSchemaType(schemaType);
- entity.setRawContent(content);
+ // A parser may hand back a redacted document to persist in place of the upload — Postman
+ // exports carry live credentials and pre-request/test scripts that must never be stored.
+ entity.setRawContent(parsed.sanitizedContent() != null ? parsed.sanitizedContent() : content);
+ entity.setDetectedAuthMethod(parsed.detectedAuthMethod());
entity.setSourceUrl(sourceUrl);
entity.setParsedOperations(objectMapper.writeValueAsString(operations));
entity.setOperationFilter(effectiveFilter.isEmpty() ? null : objectMapper.writeValueAsString(effectiveFilter));
@@ -105,7 +109,7 @@ public OperationFilterPreview previewFilter(UUID connectorId, UUID organizationI
requireConnector(connectorId, organizationId);
var content = (rawContent == null || rawContent.isBlank()) && sourceUrl != null && !sourceUrl.isBlank()
? fetch(sourceUrl) : rawContent;
- var operations = parserRegistry.parse(schemaType, content);
+ var operations = parserRegistry.parse(schemaType, content).operations();
var effectiveFilter = filter == null ? OperationFilter.EMPTY : filter;
var kept = filterMatcher.apply(operations, effectiveFilter);
var excluded = operations.stream().filter(op -> !kept.contains(op)).toList();
@@ -170,7 +174,8 @@ private void requireConnector(UUID connectorId, UUID organizationId) {
private ApiSchemaView toView(ApiSchemaEntity e) {
return new ApiSchemaView(e.getId(), e.getConnectorId(), e.getSchemaType(), e.getSourceUrl(),
- e.getOperationCount(), totalOperationCount(e), readFilter(e), e.getCreatedAt());
+ e.getOperationCount(), totalOperationCount(e), readFilter(e), e.getDetectedAuthMethod(),
+ e.getCreatedAt());
}
/** Array length only — avoids materializing every {@link ApiOperation} just to count them. */
diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiSchemaEntity.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiSchemaEntity.java
index fd103b28..103712f6 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiSchemaEntity.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiSchemaEntity.java
@@ -1,5 +1,6 @@
package com.bablsoft.accessflow.apigov.internal.persistence.entity;
+import com.bablsoft.accessflow.apigov.api.ApiAuthMethod;
import com.bablsoft.accessflow.apigov.api.ApiSchemaType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@@ -53,6 +54,13 @@ public class ApiSchemaEntity {
@Column(name = "operation_filter", columnDefinition = "jsonb")
private String operationFilter;
+ // The auth scheme the uploaded document declared, when its format carries one (Postman does).
+ // A hint for the admin — the credential values themselves are never read or stored.
+ @Enumerated(EnumType.STRING)
+ @JdbcType(PostgreSQLEnumJdbcType.class)
+ @Column(name = "detected_auth_method", columnDefinition = "api_auth_method")
+ private ApiAuthMethod detectedAuthMethod;
+
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt = Instant.now();
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/ApiSchemaParser.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/ApiSchemaParser.java
index 6020f4bd..37dbf812 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/ApiSchemaParser.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/ApiSchemaParser.java
@@ -1,12 +1,10 @@
package com.bablsoft.accessflow.apigov.internal.schema;
-import com.bablsoft.accessflow.apigov.api.ApiOperation;
import com.bablsoft.accessflow.apigov.api.ApiSchemaType;
-
-import java.util.List;
+import com.bablsoft.accessflow.apigov.api.ParsedApiSchema;
/**
- * SPI for parsing an uploaded API schema document into a normalized {@link ApiOperation} catalog.
+ * SPI for parsing an uploaded API schema document into a normalized operation catalog.
* One implementation per {@link ApiSchemaType}; the {@code SchemaParserRegistry} dispatches by type.
*/
public interface ApiSchemaParser {
@@ -14,9 +12,10 @@ public interface ApiSchemaParser {
ApiSchemaType supportedType();
/**
- * Parses {@code content} into a normalized operation catalog.
+ * Parses {@code content} into a normalized operation catalog, optionally reporting the auth
+ * scheme the document declares and a sanitized document to persist in its place.
*
* @throws com.bablsoft.accessflow.apigov.api.ApiSchemaParseException if the document is invalid
*/
- List parse(String content);
+ ParsedApiSchema parse(String content);
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/GraphQlSchemaParser.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/GraphQlSchemaParser.java
index 8254ef35..fb74aa2a 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/GraphQlSchemaParser.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/GraphQlSchemaParser.java
@@ -3,6 +3,7 @@
import com.bablsoft.accessflow.apigov.api.ApiOperation;
import com.bablsoft.accessflow.apigov.api.ApiSchemaParseException;
import com.bablsoft.accessflow.apigov.api.ApiSchemaType;
+import com.bablsoft.accessflow.apigov.api.ParsedApiSchema;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
@@ -30,7 +31,7 @@ public ApiSchemaType supportedType() {
}
@Override
- public List parse(String content) {
+ public ParsedApiSchema parse(String content) {
if (content == null || content.isBlank()) {
throw new ApiSchemaParseException("Empty GraphQL SDL document");
}
@@ -40,7 +41,7 @@ public List parse(String content) {
if (operations.isEmpty()) {
throw new ApiSchemaParseException("GraphQL SDL defines no Query or Mutation fields");
}
- return operations;
+ return new ParsedApiSchema(operations);
}
private void extract(String content, Pattern block, String verb, boolean write,
diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/JsonShapeInferrer.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/JsonShapeInferrer.java
new file mode 100644
index 00000000..d15e8d18
--- /dev/null
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/JsonShapeInferrer.java
@@ -0,0 +1,98 @@
+package com.bablsoft.accessflow.apigov.internal.schema;
+
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.node.ObjectNode;
+
+import java.util.Map;
+
+/**
+ * Infers a compact JSON-Schema-shaped description from an example payload. Postman
+ * collections carry saved examples rather than declared schemas, so a shape derived here is a
+ * best-effort approximation: an array's element type comes from its first element, and a field
+ * absent from the example is absent from the shape.
+ */
+class JsonShapeInferrer {
+
+ /** Guards against a pathological nesting depth in an attacker-influenced document. */
+ private static final int MAX_DEPTH = 12;
+
+ private final ObjectMapper objectMapper;
+
+ JsonShapeInferrer(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ /** Infers the shape of a JSON document, or returns {@code null} when it is absent/unparseable. */
+ String inferFromJson(String rawBody) {
+ if (rawBody == null || rawBody.isBlank()) {
+ return null;
+ }
+ JsonNode root;
+ try {
+ root = objectMapper.readTree(rawBody);
+ } catch (RuntimeException ex) {
+ // A non-JSON example (XML, plain text, a body still full of {{placeholders}}) simply
+ // yields no inferred shape — it is never a reason to reject the collection.
+ return null;
+ }
+ return objectMapper.writeValueAsString(shapeOf(root, 0));
+ }
+
+ /** Infers the shape of a flat key/value body (urlencoded, form-data) as string properties. */
+ String inferFromFields(Map fields) {
+ if (fields == null || fields.isEmpty()) {
+ return null;
+ }
+ var properties = objectMapper.createObjectNode();
+ fields.keySet().forEach(key -> properties.set(key, typeNode("string")));
+ var shape = objectMapper.createObjectNode();
+ shape.put("type", "object");
+ shape.set("properties", properties);
+ return objectMapper.writeValueAsString(shape);
+ }
+
+ private JsonNode shapeOf(JsonNode node, int depth) {
+ if (depth >= MAX_DEPTH) {
+ return typeNode("object");
+ }
+ if (node.isObject()) {
+ var properties = objectMapper.createObjectNode();
+ node.propertyStream().forEach(e -> properties.set(e.getKey(), shapeOf(e.getValue(), depth + 1)));
+ var shape = objectMapper.createObjectNode();
+ shape.put("type", "object");
+ shape.set("properties", properties);
+ return shape;
+ }
+ if (node.isArray()) {
+ var shape = objectMapper.createObjectNode();
+ shape.put("type", "array");
+ // An empty array carries no element type; anything else is inferred from element 0.
+ shape.set("items", node.isEmpty() ? objectMapper.createObjectNode() : shapeOf(node.get(0), depth + 1));
+ return shape;
+ }
+ return typeNode(scalarType(node));
+ }
+
+ private static String scalarType(JsonNode node) {
+ if (node.isBoolean()) {
+ return "boolean";
+ }
+ if (node.isIntegralNumber()) {
+ return "integer";
+ }
+ if (node.isNumber()) {
+ return "number";
+ }
+ if (node.isNull()) {
+ return "null";
+ }
+ return "string";
+ }
+
+ private ObjectNode typeNode(String type) {
+ var node = objectMapper.createObjectNode();
+ node.put("type", type);
+ return node;
+ }
+}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/OpenApiSchemaParser.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/OpenApiSchemaParser.java
index 5dcf0b54..c3e2991e 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/OpenApiSchemaParser.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/OpenApiSchemaParser.java
@@ -3,6 +3,7 @@
import com.bablsoft.accessflow.apigov.api.ApiOperation;
import com.bablsoft.accessflow.apigov.api.ApiSchemaParseException;
import com.bablsoft.accessflow.apigov.api.ApiSchemaType;
+import com.bablsoft.accessflow.apigov.api.ParsedApiSchema;
import io.swagger.parser.OpenAPIParser;
import io.swagger.v3.parser.core.models.SwaggerParseResult;
import org.springframework.stereotype.Component;
@@ -26,7 +27,7 @@ public ApiSchemaType supportedType() {
}
@Override
- public List parse(String content) {
+ public ParsedApiSchema parse(String content) {
SwaggerParseResult result;
try {
result = new OpenAPIParser().readContents(content, null, null);
@@ -53,6 +54,6 @@ public List parse(String content) {
if (operations.isEmpty()) {
throw new ApiSchemaParseException("OpenAPI document defines no operations");
}
- return operations;
+ return new ParsedApiSchema(operations);
}
}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/PostmanCollectionParser.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/PostmanCollectionParser.java
new file mode 100644
index 00000000..ec6a0e7d
--- /dev/null
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/PostmanCollectionParser.java
@@ -0,0 +1,314 @@
+package com.bablsoft.accessflow.apigov.internal.schema;
+
+import com.bablsoft.accessflow.apigov.api.ApiAuthMethod;
+import com.bablsoft.accessflow.apigov.api.ApiOperation;
+import com.bablsoft.accessflow.apigov.api.ApiSchemaParseException;
+import com.bablsoft.accessflow.apigov.api.ApiSchemaType;
+import com.bablsoft.accessflow.apigov.api.ParsedApiSchema;
+import org.springframework.stereotype.Component;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.node.ObjectNode;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Parses a Postman Collection v2.x export into a normalized operation catalog.
+ *
+ *
Postman carries examples, not schemas: {@code requestSchema} / {@code responseSchema}
+ * are inferred from the saved example bodies, which is a real fidelity gap versus OpenAPI. Folders
+ * are flattened into a slugified, deterministic {@code operationId} ({@code billing/invoices/
+ * create-invoice}); collection-level {@code variable[]} entries are substituted into paths and every
+ * remaining {@code {{var}}} becomes a {@code {var}} template.
+ *
+ *
Security: exports frequently contain live tokens and arbitrary pre-request/test JavaScript.
+ * Only the auth type is read — never a credential value — and {@code event} blocks are
+ * ignored entirely. The document persisted as the schema's raw content is the redacted one returned
+ * as {@link ParsedApiSchema#sanitizedContent()}.
+ */
+@Component
+public class PostmanCollectionParser implements ApiSchemaParser {
+
+ private static final Set READ_METHODS = Set.of("GET", "HEAD", "OPTIONS");
+ /** Mirrors DefaultApiSchemaService.MAX_FETCH_BYTES — bounds an attacker-influenced document. */
+ private static final int MAX_DOCUMENT_CHARS = 5 * 1024 * 1024;
+ private static final int MAX_OPERATIONS = 2000;
+ /** Depth bound on folder recursion; well beyond any collection a human would author. */
+ private static final int MAX_FOLDER_DEPTH = 32;
+
+ private static final Pattern POSTMAN_VARIABLE = Pattern.compile("\\{\\{\\s*([^{}]+?)\\s*}}");
+ private static final Pattern NON_SLUG = Pattern.compile("[^a-z0-9]+");
+
+ private final ObjectMapper objectMapper;
+ private final JsonShapeInferrer shapeInferrer;
+
+ public PostmanCollectionParser(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ this.shapeInferrer = new JsonShapeInferrer(objectMapper);
+ }
+
+ @Override
+ public ApiSchemaType supportedType() {
+ return ApiSchemaType.POSTMAN_COLLECTION;
+ }
+
+ @Override
+ public ParsedApiSchema parse(String content) {
+ if (content == null || content.isBlank()) {
+ throw new ApiSchemaParseException("Empty Postman collection document");
+ }
+ if (content.length() > MAX_DOCUMENT_CHARS) {
+ throw new ApiSchemaParseException("Postman collection exceeds the maximum allowed size");
+ }
+ JsonNode root;
+ try {
+ root = objectMapper.readTree(content);
+ } catch (RuntimeException ex) {
+ throw new ApiSchemaParseException("Invalid Postman collection JSON: " + ex.getMessage());
+ }
+ if (!root.isObject()) {
+ throw new ApiSchemaParseException("Postman collection must be a JSON object");
+ }
+ requireSupportedVersion(root);
+
+ var variables = collectionVariables(root);
+ var operations = new ArrayList();
+ var usedIds = new HashMap();
+ walk(root.path("item"), "", variables, operations, usedIds, 0);
+ if (operations.isEmpty()) {
+ throw new ApiSchemaParseException("Postman collection defines no requests");
+ }
+ return new ParsedApiSchema(operations, detectAuthMethod(root.path("auth")), sanitize(root));
+ }
+
+ /**
+ * Postman v1 has no {@code info.schema} and stores a flat {@code requests} array. v2.0 and v2.1
+ * are identical for everything this parser reads, so both are accepted.
+ */
+ private static void requireSupportedVersion(JsonNode root) {
+ var schema = root.path("info").path("schema").asString("");
+ if (!schema.contains("/v2.")) {
+ throw new ApiSchemaParseException(
+ "Unsupported Postman collection format — export the collection as Collection v2.1 "
+ + "(Postman: Collection ... > Export > Collection v2.1)");
+ }
+ }
+
+ private static Map collectionVariables(JsonNode root) {
+ var variables = new LinkedHashMap();
+ for (var variable : root.path("variable")) {
+ var key = variable.path("key").asString("");
+ if (!key.isBlank()) {
+ variables.put(key, variable.path("value").asString(""));
+ }
+ }
+ return variables;
+ }
+
+ private void walk(JsonNode items, String prefix, Map variables,
+ List out, Map usedIds, int depth) {
+ if (!items.isArray() || depth > MAX_FOLDER_DEPTH) {
+ return;
+ }
+ for (var item : items) {
+ if (out.size() >= MAX_OPERATIONS) {
+ throw new ApiSchemaParseException(
+ "Postman collection defines more than " + MAX_OPERATIONS + " requests");
+ }
+ var segment = slugify(item.path("name").asString(""));
+ var qualified = prefix.isEmpty() ? segment : prefix + "/" + segment;
+ if (item.has("item")) {
+ walk(item.path("item"), qualified, variables, out, usedIds, depth + 1);
+ } else if (item.has("request")) {
+ out.add(toOperation(item, uniqueId(qualified, usedIds), variables));
+ }
+ }
+ }
+
+ private ApiOperation toOperation(JsonNode item, String operationId, Map variables) {
+ var request = item.path("request");
+ var verb = request.path("method").asString("GET").toUpperCase(Locale.ROOT);
+ var summary = text(request.path("description"));
+ return new ApiOperation(
+ operationId,
+ verb,
+ pathOf(request.path("url"), variables),
+ summary,
+ !READ_METHODS.contains(verb),
+ inferRequestSchema(request.path("body")),
+ inferResponseSchema(item.path("response")),
+ null,
+ null);
+ }
+
+ /**
+ * Builds a path template from the URL's {@code path} segments when present, else from
+ * {@code raw} with scheme and host stripped — the connector's own {@code base_url} supplies the
+ * origin. A {@code description} object (Postman wraps some strings) collapses to its content.
+ */
+ private static String pathOf(JsonNode url, Map variables) {
+ if (url.isTextual()) {
+ return normalizePath(stripOrigin(url.asString("")), variables);
+ }
+ var segments = url.path("path");
+ if (segments.isArray() && !segments.isEmpty()) {
+ var joined = new StringBuilder();
+ for (var segment : segments) {
+ joined.append('/').append(segment.isTextual() ? segment.asString("") : text(segment));
+ }
+ return normalizePath(joined.toString(), variables);
+ }
+ return normalizePath(stripOrigin(url.path("raw").asString("")), variables);
+ }
+
+ private static String stripOrigin(String raw) {
+ var withoutQuery = raw.split("[?#]", 2)[0];
+ // Strip scheme://host, including the {{baseUrl}}/... form Postman exports use by default.
+ var schemeless = withoutQuery.replaceFirst("^[A-Za-z][A-Za-z0-9+.-]*://", "");
+ var slash = schemeless.indexOf('/');
+ if (schemeless.startsWith("{{")) {
+ return slash >= 0 ? schemeless.substring(slash) : "";
+ }
+ if (withoutQuery.length() != schemeless.length()) {
+ return slash >= 0 ? schemeless.substring(slash) : "";
+ }
+ return withoutQuery;
+ }
+
+ /**
+ * Substitutes collection-level variables, turns any remaining {@code {{var}}} into a {@code
+ * {var}} template, and normalizes Postman's {@code :id} path params to the same {@code {id}}
+ * form the rest of the catalog uses.
+ */
+ private static String normalizePath(String path, Map variables) {
+ var matcher = POSTMAN_VARIABLE.matcher(path);
+ var resolved = matcher.replaceAll(match -> {
+ var name = match.group(1);
+ var value = variables.get(name);
+ return java.util.regex.Matcher.quoteReplacement(
+ value != null && !value.isBlank() ? value : "{" + name + "}");
+ });
+ // A resolved variable may itself have carried an origin (baseUrl=https://api.example.com).
+ resolved = stripOrigin(resolved);
+ var segments = resolved.split("/", -1);
+ var rebuilt = new StringBuilder();
+ for (var segment : segments) {
+ if (segment.isEmpty()) {
+ continue;
+ }
+ rebuilt.append('/').append(segment.startsWith(":") ? "{" + segment.substring(1) + "}" : segment);
+ }
+ return rebuilt.isEmpty() ? "/" : rebuilt.toString();
+ }
+
+ private String inferRequestSchema(JsonNode body) {
+ return switch (body.path("mode").asString("")) {
+ case "raw" -> shapeInferrer.inferFromJson(body.path("raw").asString(""));
+ case "urlencoded" -> shapeInferrer.inferFromFields(keyValueFields(body.path("urlencoded")));
+ case "formdata" -> shapeInferrer.inferFromFields(keyValueFields(body.path("formdata")));
+ default -> null;
+ };
+ }
+
+ /** Uses the first saved example response; collections often save none at all. */
+ private String inferResponseSchema(JsonNode responses) {
+ if (!responses.isArray()) {
+ return null;
+ }
+ for (var response : responses) {
+ var shape = shapeInferrer.inferFromJson(response.path("body").asString(""));
+ if (shape != null) {
+ return shape;
+ }
+ }
+ return null;
+ }
+
+ private static Map keyValueFields(JsonNode entries) {
+ var fields = new LinkedHashMap();
+ for (var entry : entries) {
+ if (entry.path("disabled").asBoolean(false)) {
+ continue;
+ }
+ var key = entry.path("key").asString("");
+ if (!key.isBlank()) {
+ fields.put(key, entry.path("value").asString(""));
+ }
+ }
+ return fields;
+ }
+
+ /** Reads the declared auth type only. Credential values are never touched. */
+ private static ApiAuthMethod detectAuthMethod(JsonNode auth) {
+ return switch (auth.path("type").asString("").toLowerCase(Locale.ROOT)) {
+ case "apikey" -> ApiAuthMethod.API_KEY;
+ case "bearer", "jwt" -> ApiAuthMethod.BEARER_TOKEN;
+ case "basic" -> ApiAuthMethod.BASIC;
+ case "oauth2" -> ApiAuthMethod.OAUTH2_CLIENT_CREDENTIALS;
+ case "noauth" -> ApiAuthMethod.NONE;
+ default -> null;
+ };
+ }
+
+ /**
+ * Returns the collection with every credential value and every {@code event} script removed, so
+ * that what is persisted as raw content can never leak a token exported from Postman.
+ */
+ private String sanitize(JsonNode root) {
+ var copy = root.deepCopy();
+ redact(copy, 0);
+ return objectMapper.writeValueAsString(copy);
+ }
+
+ private static void redact(JsonNode node, int depth) {
+ if (depth > MAX_FOLDER_DEPTH) {
+ return;
+ }
+ if (node instanceof ObjectNode object) {
+ // Pre-request / test JavaScript is never stored, evaluated, or shown to the analyzer.
+ object.remove("event");
+ if (object.get("auth") instanceof ObjectNode auth) {
+ // Keep only the declared type; drop every scheme's credential array wholesale.
+ var type = auth.path("type").asString("");
+ auth.removeAll();
+ if (!type.isBlank()) {
+ auth.put("type", type);
+ }
+ }
+ }
+ for (var child : node) {
+ redact(child, depth + 1);
+ }
+ }
+
+ private static String uniqueId(String candidate, Map usedIds) {
+ var base = candidate.isBlank() ? "request" : candidate;
+ var seen = usedIds.merge(base, 1, Integer::sum);
+ return seen == 1 ? base : base + "-" + seen;
+ }
+
+ private static String slugify(String name) {
+ var slug = NON_SLUG.matcher(name.toLowerCase(Locale.ROOT)).replaceAll("-");
+ slug = slug.replaceAll("^-+|-+$", "");
+ return slug.isBlank() ? "request" : slug;
+ }
+
+ private static String text(JsonNode node) {
+ if (node.isTextual()) {
+ var value = node.asString("");
+ return value.isBlank() ? null : value;
+ }
+ if (node.isObject()) {
+ var content = node.path("content").asString("");
+ return content.isBlank() ? null : content;
+ }
+ return null;
+ }
+}
diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/ProtoSchemaParser.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/ProtoSchemaParser.java
index ccd312a5..8accb8ea 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/ProtoSchemaParser.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/ProtoSchemaParser.java
@@ -3,6 +3,7 @@
import com.bablsoft.accessflow.apigov.api.ApiOperation;
import com.bablsoft.accessflow.apigov.api.ApiSchemaParseException;
import com.bablsoft.accessflow.apigov.api.ApiSchemaType;
+import com.bablsoft.accessflow.apigov.api.ParsedApiSchema;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
@@ -34,7 +35,7 @@ public ApiSchemaType supportedType() {
}
@Override
- public List parse(String content) {
+ public ParsedApiSchema parse(String content) {
if (content == null || content.isBlank()) {
throw new ApiSchemaParseException("Empty proto document");
}
@@ -53,7 +54,7 @@ public List parse(String content) {
if (operations.isEmpty()) {
throw new ApiSchemaParseException("proto document defines no service rpc methods");
}
- return operations;
+ return new ParsedApiSchema(operations);
}
private static boolean isWrite(String method) {
diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/SchemaParserRegistry.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/SchemaParserRegistry.java
index 628e14fa..7c0b074d 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/SchemaParserRegistry.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/SchemaParserRegistry.java
@@ -1,8 +1,8 @@
package com.bablsoft.accessflow.apigov.internal.schema;
-import com.bablsoft.accessflow.apigov.api.ApiOperation;
import com.bablsoft.accessflow.apigov.api.ApiSchemaParseException;
import com.bablsoft.accessflow.apigov.api.ApiSchemaType;
+import com.bablsoft.accessflow.apigov.api.ParsedApiSchema;
import org.springframework.stereotype.Component;
import java.util.EnumMap;
@@ -21,7 +21,7 @@ public SchemaParserRegistry(List parserBeans) {
}
}
- public List parse(ApiSchemaType type, String content) {
+ public ParsedApiSchema parse(ApiSchemaType type, String content) {
var parser = parsers.get(type);
if (parser == null) {
throw new ApiSchemaParseException("No parser registered for schema type " + type);
diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/WsdlSchemaParser.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/WsdlSchemaParser.java
index 6e7fb49d..047491a6 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/WsdlSchemaParser.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/schema/WsdlSchemaParser.java
@@ -3,6 +3,7 @@
import com.bablsoft.accessflow.apigov.api.ApiOperation;
import com.bablsoft.accessflow.apigov.api.ApiSchemaParseException;
import com.bablsoft.accessflow.apigov.api.ApiSchemaType;
+import com.bablsoft.accessflow.apigov.api.ParsedApiSchema;
import org.springframework.stereotype.Component;
import org.w3c.dom.Element;
@@ -30,7 +31,7 @@ public ApiSchemaType supportedType() {
}
@Override
- public List parse(String content) {
+ public ParsedApiSchema parse(String content) {
if (content == null || content.isBlank()) {
throw new ApiSchemaParseException("Empty WSDL document");
}
@@ -58,7 +59,7 @@ public List parse(String content) {
if (operations.isEmpty()) {
throw new ApiSchemaParseException("WSDL defines no portType operations");
}
- return operations;
+ return new ParsedApiSchema(operations);
} catch (ApiSchemaParseException ex) {
throw ex;
} catch (Exception ex) {
diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiSchemaResponse.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiSchemaResponse.java
index 8f87b0e4..d3fde2a2 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiSchemaResponse.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiSchemaResponse.java
@@ -1,5 +1,6 @@
package com.bablsoft.accessflow.apigov.internal.web;
+import com.bablsoft.accessflow.apigov.api.ApiAuthMethod;
import com.bablsoft.accessflow.apigov.api.ApiSchemaType;
import com.bablsoft.accessflow.apigov.api.ApiSchemaView;
@@ -13,10 +14,12 @@ public record ApiSchemaResponse(
int operationCount,
int totalOperationCount,
OperationFilterResponse operationFilter,
+ ApiAuthMethod detectedAuthMethod,
Instant createdAt) {
static ApiSchemaResponse from(ApiSchemaView v) {
return new ApiSchemaResponse(v.id(), v.schemaType(), v.sourceUrl(), v.operationCount(),
- v.totalOperationCount(), OperationFilterResponse.from(v.operationFilter()), v.createdAt());
+ v.totalOperationCount(), OperationFilterResponse.from(v.operationFilter()),
+ v.detectedAuthMethod(), v.createdAt());
}
}
diff --git a/backend/src/main/resources/db/migration/V122__add_postman_collection_schema_type.sql b/backend/src/main/resources/db/migration/V122__add_postman_collection_schema_type.sql
new file mode 100644
index 00000000..bb887579
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V122__add_postman_collection_schema_type.sql
@@ -0,0 +1,5 @@
+-- ALTER TYPE ... ADD VALUE cannot run inside a transaction block on PostgreSQL.
+-- The matching V122__add_postman_collection_schema_type.sql.conf sets executeInTransaction=false so
+-- Flyway runs this statement autocommit. The new value lets a Postman Collection v2.x export act as
+-- a connector schema source, parsed by PostmanCollectionParser (issue #612).
+ALTER TYPE api_schema_type ADD VALUE IF NOT EXISTS 'POSTMAN_COLLECTION';
diff --git a/backend/src/main/resources/db/migration/V122__add_postman_collection_schema_type.sql.conf b/backend/src/main/resources/db/migration/V122__add_postman_collection_schema_type.sql.conf
new file mode 100644
index 00000000..73bd53a1
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V122__add_postman_collection_schema_type.sql.conf
@@ -0,0 +1 @@
+executeInTransaction=false
diff --git a/backend/src/main/resources/db/migration/V123__add_api_schema_detected_auth_method.sql b/backend/src/main/resources/db/migration/V123__add_api_schema_detected_auth_method.sql
new file mode 100644
index 00000000..15fe3912
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V123__add_api_schema_detected_auth_method.sql
@@ -0,0 +1,5 @@
+-- #612: the auth scheme an uploaded schema document declared, when its format carries one.
+-- A Postman collection names its auth type (bearer/basic/apikey/oauth2); the parser reads the type
+-- only and never the credential values, which are re-entered by the admin on the connector itself.
+-- NULL for every other schema type and for schemas uploaded before this column existed.
+ALTER TABLE api_schemas ADD COLUMN detected_auth_method api_auth_method;
diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiSchemaServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiSchemaServiceTest.java
index e85de42a..63c2bbc6 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiSchemaServiceTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiSchemaServiceTest.java
@@ -1,10 +1,12 @@
package com.bablsoft.accessflow.apigov.internal;
+import com.bablsoft.accessflow.apigov.api.ApiAuthMethod;
import com.bablsoft.accessflow.apigov.api.ApiConnectorNotFoundException;
import com.bablsoft.accessflow.apigov.api.ApiOperation;
import com.bablsoft.accessflow.apigov.api.ApiSchemaNotFoundException;
import com.bablsoft.accessflow.apigov.api.ApiSchemaType;
import com.bablsoft.accessflow.apigov.api.OperationFilter;
+import com.bablsoft.accessflow.apigov.api.ParsedApiSchema;
import com.bablsoft.accessflow.apigov.internal.persistence.entity.ApiConnectorEntity;
import com.bablsoft.accessflow.apigov.internal.persistence.entity.ApiSchemaEntity;
import com.bablsoft.accessflow.apigov.internal.persistence.repo.ApiConnectorRepository;
@@ -57,9 +59,9 @@ private void connectorExists() {
@Test
void uploadParsesAndPersistsCatalog() {
connectorExists();
- when(parserRegistry.parse(eq(ApiSchemaType.OPENAPI), any())).thenReturn(List.of(
+ when(parserRegistry.parse(eq(ApiSchemaType.OPENAPI), any())).thenReturn(new ParsedApiSchema(List.of(
new ApiOperation("listPets", "GET", "/pets", null, false, null, null),
- new ApiOperation("createPet", "POST", "/pets", null, true, null, null)));
+ new ApiOperation("createPet", "POST", "/pets", null, true, null, null))));
when(schemaRepository.save(any())).thenAnswer(i -> i.getArgument(0));
var view = service.upload(connectorId, orgId, ApiSchemaType.OPENAPI, "spec", null,
@@ -74,9 +76,9 @@ void uploadParsesAndPersistsCatalog() {
@Test
void uploadAppliesFilterToOperationCountAndPersistsFullCatalog() {
connectorExists();
- when(parserRegistry.parse(eq(ApiSchemaType.OPENAPI), any())).thenReturn(List.of(
+ when(parserRegistry.parse(eq(ApiSchemaType.OPENAPI), any())).thenReturn(new ParsedApiSchema(List.of(
new ApiOperation("listPets", "GET", "/pets", null, false, null, null),
- new ApiOperation("internalSync", "POST", "/internal/sync", null, true, null, null)));
+ new ApiOperation("internalSync", "POST", "/internal/sync", null, true, null, null))));
var saved = new java.util.concurrent.atomic.AtomicReference();
when(schemaRepository.save(any())).thenAnswer(i -> {
saved.set(i.getArgument(0));
@@ -94,11 +96,52 @@ void uploadAppliesFilterToOperationCountAndPersistsFullCatalog() {
assertThat(saved.get().getOperationFilter()).contains("/internal/**");
}
+ @Test
+ void uploadPersistsTheParserSanitizedContentAndDetectedAuthMethod() {
+ connectorExists();
+ when(parserRegistry.parse(eq(ApiSchemaType.POSTMAN_COLLECTION), any())).thenReturn(new ParsedApiSchema(
+ List.of(new ApiOperation("ping", "GET", "/ping", null, false, null, null)),
+ ApiAuthMethod.BEARER_TOKEN, "{\"redacted\":true}"));
+ var saved = new java.util.concurrent.atomic.AtomicReference();
+ when(schemaRepository.save(any())).thenAnswer(i -> {
+ saved.set(i.getArgument(0));
+ return i.getArgument(0);
+ });
+
+ var view = service.upload(connectorId, orgId, ApiSchemaType.POSTMAN_COLLECTION,
+ "{\"token\":\"SUPER-SECRET\"}", null, OperationFilter.EMPTY);
+
+ // The uploaded document is never what gets stored when the parser redacted it.
+ assertThat(saved.get().getRawContent()).isEqualTo("{\"redacted\":true}").doesNotContain("SUPER-SECRET");
+ assertThat(saved.get().getDetectedAuthMethod()).isEqualTo(ApiAuthMethod.BEARER_TOKEN);
+ assertThat(view.detectedAuthMethod()).isEqualTo(ApiAuthMethod.BEARER_TOKEN);
+ }
+
+ @Test
+ void uploadStoresTheOriginalDocumentWhenTheParserDidNotSanitize() {
+ connectorExists();
+ when(parserRegistry.parse(eq(ApiSchemaType.OPENAPI), any())).thenReturn(new ParsedApiSchema(
+ List.of(new ApiOperation("listPets", "GET", "/pets", null, false, null, null))));
+ var saved = new java.util.concurrent.atomic.AtomicReference();
+ when(schemaRepository.save(any())).thenAnswer(i -> {
+ saved.set(i.getArgument(0));
+ return i.getArgument(0);
+ });
+
+ var view = service.upload(connectorId, orgId, ApiSchemaType.OPENAPI, "spec", null,
+ OperationFilter.EMPTY);
+
+ assertThat(saved.get().getRawContent()).isEqualTo("spec");
+ assertThat(saved.get().getDetectedAuthMethod()).isNull();
+ assertThat(view.detectedAuthMethod()).isNull();
+ }
+
@Test
void uploadFetchesFromSourceUrlWhenRawContentBlank() throws Exception {
connectorExists();
when(parserRegistry.parse(eq(ApiSchemaType.OPENAPI), eq("fetched-spec")))
- .thenReturn(List.of(new ApiOperation("listPets", "GET", "/pets", null, false, null, null)));
+ .thenReturn(new ParsedApiSchema(
+ List.of(new ApiOperation("listPets", "GET", "/pets", null, false, null, null))));
when(schemaRepository.save(any())).thenAnswer(i -> i.getArgument(0));
var server = com.sun.net.httpserver.HttpServer.create(
new java.net.InetSocketAddress("127.0.0.1", 0), 0);
@@ -222,9 +265,9 @@ void updateFilterRejectsUnknownSchema() {
@Test
void previewFilterReportsCountsWithoutPersisting() {
connectorExists();
- when(parserRegistry.parse(eq(ApiSchemaType.OPENAPI), any())).thenReturn(List.of(
+ when(parserRegistry.parse(eq(ApiSchemaType.OPENAPI), any())).thenReturn(new ParsedApiSchema(List.of(
new ApiOperation("listPets", "GET", "/pets", null, false, null, null),
- new ApiOperation("internalSync", "POST", "/internal/sync", null, true, null, null)));
+ new ApiOperation("internalSync", "POST", "/internal/sync", null, true, null, null))));
var filter = new OperationFilter(null, List.of("/internal/**"), null, null, null, null, null, null, false);
var preview = service.previewFilter(connectorId, orgId, ApiSchemaType.OPENAPI, "spec", null, filter);
diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/GraphQlSchemaParserTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/GraphQlSchemaParserTest.java
index c8ae63f5..3c466803 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/GraphQlSchemaParserTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/GraphQlSchemaParserTest.java
@@ -29,7 +29,7 @@ void supportedTypeIsGraphQlSdl() {
@Test
void parsesQueryAndMutationFields() {
- var ops = parser.parse(SDL);
+ var ops = parser.parse(SDL).operations();
var query = ops.stream().filter(o -> o.operationId().equals("users")).findFirst().orElseThrow();
assertThat(query.verb()).isEqualTo("query");
diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/JsonShapeInferrerTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/JsonShapeInferrerTest.java
new file mode 100644
index 00000000..29d1cf12
--- /dev/null
+++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/JsonShapeInferrerTest.java
@@ -0,0 +1,108 @@
+package com.bablsoft.accessflow.apigov.internal.schema;
+
+import org.junit.jupiter.api.Test;
+import tools.jackson.databind.ObjectMapper;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class JsonShapeInferrerTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private final JsonShapeInferrer inferrer = new JsonShapeInferrer(objectMapper);
+
+ @Test
+ void infersScalarTypesFromAnObjectExample() {
+ var shape = inferrer.inferFromJson("""
+ { "name": "ada", "age": 36, "score": 9.5, "active": true, "note": null }""");
+
+ assertThat(shape).isEqualTo("""
+ {"type":"object","properties":{\
+ "name":{"type":"string"},\
+ "age":{"type":"integer"},\
+ "score":{"type":"number"},\
+ "active":{"type":"boolean"},\
+ "note":{"type":"null"}}}""");
+ }
+
+ @Test
+ void infersNestedObjects() {
+ var shape = inferrer.inferFromJson("{ \"user\": { \"email\": \"a@b.c\" } }");
+
+ assertThat(shape).isEqualTo("""
+ {"type":"object","properties":{"user":{"type":"object","properties":{\
+ "email":{"type":"string"}}}}}""");
+ }
+
+ @Test
+ void infersArrayElementTypeFromTheFirstElement() {
+ var shape = inferrer.inferFromJson("{ \"tags\": [\"a\", \"b\"] }");
+
+ assertThat(shape).isEqualTo("""
+ {"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}}}}""");
+ }
+
+ @Test
+ void leavesAnEmptyArrayWithoutAnElementType() {
+ var shape = inferrer.inferFromJson("{ \"tags\": [] }");
+
+ assertThat(shape).isEqualTo("""
+ {"type":"object","properties":{"tags":{"type":"array","items":{}}}}""");
+ }
+
+ @Test
+ void infersATopLevelArray() {
+ var shape = inferrer.inferFromJson("[{ \"id\": 1 }]");
+
+ assertThat(shape).isEqualTo("""
+ {"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"}}}}""");
+ }
+
+ @Test
+ void infersATopLevelScalar() {
+ assertThat(inferrer.inferFromJson("\"plain\"")).isEqualTo("{\"type\":\"string\"}");
+ }
+
+ @Test
+ void stopsDescendingAtTheDepthBound() {
+ // 20 levels of nesting; the inferrer collapses everything past level 12 to a bare object.
+ var deep = new StringBuilder();
+ for (int i = 0; i < 20; i++) {
+ deep.append("{\"a\":");
+ }
+ deep.append("1");
+ deep.append("}".repeat(20));
+
+ var shape = inferrer.inferFromJson(deep.toString());
+
+ assertThat(shape).endsWith("{\"type\":\"object\"}" + "}}".repeat(11) + "}}");
+ assertThat(shape).doesNotContain("\"integer\"");
+ }
+
+ @Test
+ void returnsNullForAbsentBlankOrNonJsonInput() {
+ assertThat(inferrer.inferFromJson(null)).isNull();
+ assertThat(inferrer.inferFromJson(" ")).isNull();
+ assertThat(inferrer.inferFromJson("")).isNull();
+ assertThat(inferrer.inferFromJson("{ unclosed")).isNull();
+ }
+
+ @Test
+ void infersFlatFieldsAsStringProperties() {
+ var fields = new LinkedHashMap();
+ fields.put("grant_type", "client_credentials");
+ fields.put("scope", "read");
+
+ assertThat(inferrer.inferFromFields(fields)).isEqualTo("""
+ {"type":"object","properties":{\
+ "grant_type":{"type":"string"},"scope":{"type":"string"}}}""");
+ }
+
+ @Test
+ void returnsNullForAbsentOrEmptyFields() {
+ assertThat(inferrer.inferFromFields(null)).isNull();
+ assertThat(inferrer.inferFromFields(Map.of())).isNull();
+ }
+}
diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/OpenApiSchemaParserTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/OpenApiSchemaParserTest.java
index 2bee8806..dc79ea4f 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/OpenApiSchemaParserTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/OpenApiSchemaParserTest.java
@@ -42,7 +42,7 @@ void supportedTypeIsOpenApi() {
@Test
void parsesOperationsWithReadWriteClassification() {
- var ops = parser.parse(SPEC);
+ var ops = parser.parse(SPEC).operations();
assertThat(ops).hasSize(3);
var get = ops.stream().filter(o -> o.operationId().equals("listPets")).findFirst().orElseThrow();
@@ -59,7 +59,7 @@ void parsesOperationsWithReadWriteClassification() {
@Test
void capturesTagsAndDeprecatedForOpenApi() {
- var ops = parser.parse(SPEC);
+ var ops = parser.parse(SPEC).operations();
var get = ops.stream().filter(o -> o.operationId().equals("listPets")).findFirst().orElseThrow();
assertThat(get.tags()).containsExactlyInAnyOrder("pets", "public");
diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/PostmanCollectionParserTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/PostmanCollectionParserTest.java
new file mode 100644
index 00000000..9c018171
--- /dev/null
+++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/PostmanCollectionParserTest.java
@@ -0,0 +1,356 @@
+package com.bablsoft.accessflow.apigov.internal.schema;
+
+import com.bablsoft.accessflow.apigov.api.ApiAuthMethod;
+import com.bablsoft.accessflow.apigov.api.ApiOperation;
+import com.bablsoft.accessflow.apigov.api.ApiSchemaParseException;
+import com.bablsoft.accessflow.apigov.api.ApiSchemaType;
+import org.junit.jupiter.api.Test;
+import tools.jackson.databind.ObjectMapper;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class PostmanCollectionParserTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private final PostmanCollectionParser parser = new PostmanCollectionParser(objectMapper);
+
+ private static final String V21_HEADER = """
+ "info": {
+ "name": "Billing",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+ }""";
+
+ private static final String COLLECTION = """
+ {
+ %s,
+ "variable": [{ "key": "baseUrl", "value": "https://api.example.com" }],
+ "auth": { "type": "bearer", "bearer": [{ "key": "token", "value": "SUPER-SECRET" }] },
+ "item": [
+ {
+ "name": "Invoices",
+ "item": [
+ {
+ "name": "Create Invoice",
+ "event": [{ "listen": "prerequest", "script": { "exec": ["pm.environment.set('x', 1)"] } }],
+ "request": {
+ "method": "POST",
+ "description": "Creates an invoice",
+ "url": { "raw": "{{baseUrl}}/v1/invoices", "path": ["v1", "invoices"] },
+ "body": { "mode": "raw", "raw": "{\\"amount\\": 10, \\"paid\\": false}" }
+ },
+ "response": [{ "body": "{\\"id\\": \\"inv_1\\"}" }]
+ },
+ {
+ "name": "Get Invoice",
+ "request": {
+ "method": "GET",
+ "url": { "raw": "{{baseUrl}}/v1/invoices/:id", "path": ["v1", "invoices", ":id"] }
+ }
+ }
+ ]
+ },
+ {
+ "name": "Ping",
+ "request": { "method": "GET", "url": "{{baseUrl}}/{{stage}}/ping" }
+ }
+ ]
+ }""".formatted(V21_HEADER);
+
+ private static String collectionWith(String body) {
+ return """
+ { %s, "item": [{ "name": "R", "request": { "method": "POST",
+ "url": { "path": ["x"] }, "body": %s } }] }""".formatted(V21_HEADER, body);
+ }
+
+ private ApiOperation operation(List ops, String id) {
+ return ops.stream().filter(o -> o.operationId().equals(id)).findFirst().orElseThrow();
+ }
+
+ @Test
+ void supportedTypeIsPostmanCollection() {
+ assertThat(parser.supportedType()).isEqualTo(ApiSchemaType.POSTMAN_COLLECTION);
+ }
+
+ @Test
+ void flattensFoldersIntoSlugifiedOperationIds() {
+ var ops = parser.parse(COLLECTION).operations();
+
+ assertThat(ops).extracting(ApiOperation::operationId)
+ .containsExactly("invoices/create-invoice", "invoices/get-invoice", "ping");
+ }
+
+ @Test
+ void mapsVerbSummaryAndReadWriteClassification() {
+ var ops = parser.parse(COLLECTION).operations();
+
+ var create = operation(ops, "invoices/create-invoice");
+ assertThat(create.verb()).isEqualTo("POST");
+ assertThat(create.summary()).isEqualTo("Creates an invoice");
+ assertThat(create.write()).isTrue();
+ assertThat(operation(ops, "invoices/get-invoice").write()).isFalse();
+ }
+
+ @Test
+ void resolvesCollectionVariablesAndStripsTheOrigin() {
+ var ops = parser.parse(COLLECTION).operations();
+
+ assertThat(operation(ops, "invoices/create-invoice").path()).isEqualTo("/v1/invoices");
+ }
+
+ @Test
+ void normalizesUnresolvedVariablesAndPathParamsToTemplates() {
+ var ops = parser.parse(COLLECTION).operations();
+
+ // :id is Postman's path-param form; {{stage}} has no collection-level value.
+ assertThat(operation(ops, "invoices/get-invoice").path()).isEqualTo("/v1/invoices/{id}");
+ assertThat(operation(ops, "ping").path()).isEqualTo("/{stage}/ping");
+ }
+
+ @Test
+ void infersRequestAndResponseSchemasFromExamples() {
+ var ops = parser.parse(COLLECTION).operations();
+
+ var create = operation(ops, "invoices/create-invoice");
+ assertThat(create.requestSchema())
+ .contains("\"amount\"").contains("\"integer\"")
+ .contains("\"paid\"").contains("\"boolean\"");
+ assertThat(create.responseSchema()).contains("\"id\"").contains("\"string\"");
+ }
+
+ @Test
+ void leavesResponseSchemaNullWhenNoExampleIsSaved() {
+ var ops = parser.parse(COLLECTION).operations();
+
+ assertThat(operation(ops, "invoices/get-invoice").responseSchema()).isNull();
+ }
+
+ @Test
+ void infersUrlencodedAndFormdataBodiesAsStringProperties() {
+ var urlencoded = parser.parse(collectionWith(
+ """
+ { "mode": "urlencoded", "urlencoded": [
+ { "key": "grant_type", "value": "client_credentials" },
+ { "key": "skipped", "value": "x", "disabled": true }] }"""))
+ .operations().getFirst();
+ var formdata = parser.parse(collectionWith(
+ """
+ { "mode": "formdata", "formdata": [{ "key": "file", "value": "@a.png" }] }"""))
+ .operations().getFirst();
+
+ assertThat(urlencoded.requestSchema()).contains("\"grant_type\"").doesNotContain("skipped");
+ assertThat(formdata.requestSchema()).contains("\"file\"").contains("\"string\"");
+ }
+
+ @Test
+ void leavesRequestSchemaNullForUnsupportedOrNonJsonBodies() {
+ assertThat(parser.parse(collectionWith("{ \"mode\": \"file\", \"file\": {} }"))
+ .operations().getFirst().requestSchema()).isNull();
+ assertThat(parser.parse(collectionWith("{ \"mode\": \"raw\", \"raw\": \"\" }"))
+ .operations().getFirst().requestSchema()).isNull();
+ }
+
+ @Test
+ void leavesOpenApiOnlyFilterDimensionsUnset() {
+ var create = operation(parser.parse(COLLECTION).operations(), "invoices/create-invoice");
+
+ assertThat(create.tags()).isNull();
+ assertThat(create.deprecated()).isNull();
+ }
+
+ @Test
+ void detectsTheDeclaredAuthTypeWithoutReadingCredentials() {
+ assertThat(parser.parse(COLLECTION).detectedAuthMethod()).isEqualTo(ApiAuthMethod.BEARER_TOKEN);
+ }
+
+ @Test
+ void mapsEveryRecognizedAuthType() {
+ assertThat(authOf("apikey")).isEqualTo(ApiAuthMethod.API_KEY);
+ assertThat(authOf("basic")).isEqualTo(ApiAuthMethod.BASIC);
+ assertThat(authOf("jwt")).isEqualTo(ApiAuthMethod.BEARER_TOKEN);
+ assertThat(authOf("oauth2")).isEqualTo(ApiAuthMethod.OAUTH2_CLIENT_CREDENTIALS);
+ assertThat(authOf("noauth")).isEqualTo(ApiAuthMethod.NONE);
+ assertThat(authOf("hawk")).isNull();
+ }
+
+ private ApiAuthMethod authOf(String type) {
+ return parser.parse("""
+ { %s, "auth": { "type": "%s" },
+ "item": [{ "name": "R", "request": { "method": "GET", "url": { "path": ["x"] } } }] }"""
+ .formatted(V21_HEADER, type)).detectedAuthMethod();
+ }
+
+ @Test
+ void reportsNoAuthMethodWhenTheCollectionDeclaresNone() {
+ var parsed = parser.parse("""
+ { %s, "item": [{ "name": "R", "request": { "method": "GET", "url": { "path": ["x"] } } }] }"""
+ .formatted(V21_HEADER));
+
+ assertThat(parsed.detectedAuthMethod()).isNull();
+ }
+
+ @Test
+ void sanitizedContentDropsCredentialValuesAndEventScripts() {
+ var sanitized = parser.parse(COLLECTION).sanitizedContent();
+
+ assertThat(sanitized)
+ .doesNotContain("SUPER-SECRET")
+ .doesNotContain("pm.environment.set")
+ .doesNotContain("\"event\"")
+ .contains("\"type\":\"bearer\"")
+ .contains("Create Invoice");
+ }
+
+ @Test
+ void sanitizedContentAlsoDropsPerRequestAuthAndNestedFolderScripts() {
+ var collection = """
+ { %s, "item": [{ "name": "Folder",
+ "event": [{ "listen": "test", "script": { "exec": ["console.log('folder')"] } }],
+ "item": [{ "name": "R", "request": {
+ "method": "GET", "url": { "path": ["x"] },
+ "auth": { "type": "apikey", "apikey": [{ "key": "value", "value": "REQ-SECRET" }] } } }] }] }"""
+ .formatted(V21_HEADER);
+
+ var sanitized = parser.parse(collection).sanitizedContent();
+
+ assertThat(sanitized)
+ .doesNotContain("REQ-SECRET")
+ .doesNotContain("console.log")
+ .contains("\"type\":\"apikey\"");
+ }
+
+ @Test
+ void deduplicatesCollidingOperationIdsDeterministically() {
+ var collection = """
+ { %s, "item": [
+ { "name": "Get User", "request": { "method": "GET", "url": { "path": ["a"] } } },
+ { "name": "get user", "request": { "method": "GET", "url": { "path": ["b"] } } },
+ { "name": "GET USER", "request": { "method": "GET", "url": { "path": ["c"] } } }] }"""
+ .formatted(V21_HEADER);
+
+ var ops = parser.parse(collection).operations();
+
+ assertThat(ops).extracting(ApiOperation::operationId)
+ .containsExactly("get-user", "get-user-2", "get-user-3");
+ }
+
+ @Test
+ void fallsBackToRawUrlWhenNoPathArrayIsPresent() {
+ var collection = """
+ { %s, "item": [{ "name": "R", "request": { "method": "GET",
+ "url": { "raw": "https://api.example.com/v2/things?limit=5" } } }] }"""
+ .formatted(V21_HEADER);
+
+ assertThat(parser.parse(collection).operations().getFirst().path()).isEqualTo("/v2/things");
+ }
+
+ @Test
+ void handlesAnObjectValuedDescriptionAndAnEmptyPath() {
+ var collection = """
+ { %s, "item": [{ "name": "Root", "request": { "method": "GET",
+ "description": { "content": "Root ping" }, "url": { "raw": "{{baseUrl}}" } } }] }"""
+ .formatted(V21_HEADER);
+
+ var op = parser.parse(collection).operations().getFirst();
+
+ assertThat(op.summary()).isEqualTo("Root ping");
+ assertThat(op.path()).isEqualTo("/");
+ }
+
+ @Test
+ void defaultsAMissingRequestMethodToGet() {
+ var collection = """
+ { %s, "item": [{ "name": "R", "request": { "url": { "path": ["x"] } } }] }"""
+ .formatted(V21_HEADER);
+
+ var op = parser.parse(collection).operations().getFirst();
+
+ assertThat(op.verb()).isEqualTo("GET");
+ assertThat(op.write()).isFalse();
+ assertThat(op.summary()).isNull();
+ }
+
+ @Test
+ void namesAnUnnamedRequestDeterministically() {
+ var collection = """
+ { %s, "item": [{ "name": "***", "request": { "method": "GET", "url": { "path": ["x"] } } }] }"""
+ .formatted(V21_HEADER);
+
+ assertThat(parser.parse(collection).operations().getFirst().operationId()).isEqualTo("request");
+ }
+
+ @Test
+ void rejectsBlankInput() {
+ assertThatThrownBy(() -> parser.parse(" "))
+ .isInstanceOf(ApiSchemaParseException.class)
+ .hasMessageContaining("Empty");
+ assertThatThrownBy(() -> parser.parse(null)).isInstanceOf(ApiSchemaParseException.class);
+ }
+
+ @Test
+ void rejectsMalformedJson() {
+ assertThatThrownBy(() -> parser.parse("{ not json"))
+ .isInstanceOf(ApiSchemaParseException.class)
+ .hasMessageContaining("Invalid Postman collection JSON");
+ }
+
+ @Test
+ void rejectsANonObjectDocument() {
+ assertThatThrownBy(() -> parser.parse("[]"))
+ .isInstanceOf(ApiSchemaParseException.class)
+ .hasMessageContaining("must be a JSON object");
+ }
+
+ @Test
+ void rejectsACollectionV1Export() {
+ var v1 = """
+ { "id": "abc", "name": "Legacy", "order": [],
+ "requests": [{ "method": "GET", "url": "https://api.example.com/v1/things" }] }""";
+
+ assertThatThrownBy(() -> parser.parse(v1))
+ .isInstanceOf(ApiSchemaParseException.class)
+ .hasMessageContaining("Collection v2.1");
+ }
+
+ @Test
+ void acceptsAV20Export() {
+ var v20 = """
+ { "info": { "name": "X", "schema":
+ "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" },
+ "item": [{ "name": "R", "request": { "method": "GET", "url": { "path": ["x"] } } }] }""";
+
+ assertThat(parser.parse(v20).operations()).hasSize(1);
+ }
+
+ @Test
+ void rejectsACollectionWithNoRequests() {
+ var empty = """
+ { %s, "item": [{ "name": "Empty folder", "item": [] }] }""".formatted(V21_HEADER);
+
+ assertThatThrownBy(() -> parser.parse(empty))
+ .isInstanceOf(ApiSchemaParseException.class)
+ .hasMessageContaining("no requests");
+ }
+
+ @Test
+ void rejectsAnOversizedDocument() {
+ var oversized = "{\"info\":{\"schema\":\"/v2.1/\"},\"pad\":\"" + "x".repeat(5 * 1024 * 1024) + "\"}";
+
+ assertThatThrownBy(() -> parser.parse(oversized))
+ .isInstanceOf(ApiSchemaParseException.class)
+ .hasMessageContaining("maximum allowed size");
+ }
+
+ @Test
+ void rejectsACollectionExceedingTheOperationCap() {
+ var item = "{ \"name\": \"R\", \"request\": { \"method\": \"GET\", \"url\": { \"path\": [\"x\"] } } }";
+ var many = "{ %s, \"item\": [%s] }".formatted(V21_HEADER,
+ String.join(",", java.util.Collections.nCopies(2001, item)));
+
+ assertThatThrownBy(() -> parser.parse(many))
+ .isInstanceOf(ApiSchemaParseException.class)
+ .hasMessageContaining("more than 2000");
+ }
+}
diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/ProtoSchemaParserTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/ProtoSchemaParserTest.java
index f680cc28..13c8514c 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/ProtoSchemaParserTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/ProtoSchemaParserTest.java
@@ -28,7 +28,7 @@ void supportedTypeIsProto() {
@Test
void parsesRpcsWithServicePrefixAndClassification() {
- var ops = parser.parse(PROTO);
+ var ops = parser.parse(PROTO).operations();
assertThat(ops).hasSize(3);
var get = ops.stream().filter(o -> o.operationId().equals("UserService.GetUser")).findFirst().orElseThrow();
diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/SchemaParserRegistryTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/SchemaParserRegistryTest.java
index 24576783..5ced3dc3 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/SchemaParserRegistryTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/SchemaParserRegistryTest.java
@@ -1,8 +1,10 @@
package com.bablsoft.accessflow.apigov.internal.schema;
+import com.bablsoft.accessflow.apigov.api.ApiAuthMethod;
import com.bablsoft.accessflow.apigov.api.ApiSchemaParseException;
import com.bablsoft.accessflow.apigov.api.ApiSchemaType;
import org.junit.jupiter.api.Test;
+import tools.jackson.databind.ObjectMapper;
import java.util.List;
@@ -15,9 +17,26 @@ class SchemaParserRegistryTest {
void dispatchesToTheParserForTheType() {
var registry = new SchemaParserRegistry(List.of(new GraphQlSchemaParser(), new ProtoSchemaParser()));
- var ops = registry.parse(ApiSchemaType.GRAPHQL_SDL, "type Query { ping: String }");
+ var parsed = registry.parse(ApiSchemaType.GRAPHQL_SDL, "type Query { ping: String }");
- assertThat(ops).extracting("operationId").containsExactly("ping");
+ assertThat(parsed.operations()).extracting("operationId").containsExactly("ping");
+ assertThat(parsed.detectedAuthMethod()).isNull();
+ assertThat(parsed.sanitizedContent()).isNull();
+ }
+
+ @Test
+ void dispatchesToThePostmanParserAndCarriesItsAuthAndSanitizedContent() {
+ var registry = new SchemaParserRegistry(
+ List.of(new GraphQlSchemaParser(), new PostmanCollectionParser(new ObjectMapper())));
+
+ var parsed = registry.parse(ApiSchemaType.POSTMAN_COLLECTION, """
+ { "info": { "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" },
+ "auth": { "type": "basic", "basic": [{ "key": "password", "value": "hunter2" }] },
+ "item": [{ "name": "Ping", "request": { "method": "GET", "url": { "path": ["ping"] } } }] }""");
+
+ assertThat(parsed.operations()).extracting("operationId").containsExactly("ping");
+ assertThat(parsed.detectedAuthMethod()).isEqualTo(ApiAuthMethod.BASIC);
+ assertThat(parsed.sanitizedContent()).doesNotContain("hunter2");
}
@Test
diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/WsdlSchemaParserTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/WsdlSchemaParserTest.java
index b030e3a7..2d1f8c33 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/WsdlSchemaParserTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/schema/WsdlSchemaParserTest.java
@@ -28,7 +28,7 @@ void supportedTypeIsWsdl() {
@Test
void parsesPortTypeOperationsWithClassification() {
- var ops = parser.parse(WSDL);
+ var ops = parser.parse(WSDL).operations();
assertThat(ops).hasSize(2);
var get = ops.stream().filter(o -> o.operationId().equals("GetPrice")).findFirst().orElseThrow();
diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiSchemaControllerTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiSchemaControllerTest.java
index 3226e899..e5fa37fb 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiSchemaControllerTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiSchemaControllerTest.java
@@ -57,7 +57,7 @@ private Authentication auth(UserRoleType role) {
private ApiSchemaView schemaView() {
return new ApiSchemaView(UUID.randomUUID(), connectorId, ApiSchemaType.OPENAPI, null, 3, 5,
- OperationFilter.EMPTY, Instant.now());
+ OperationFilter.EMPTY, null, Instant.now());
}
@Test
diff --git a/docs/03-data-model.md b/docs/03-data-model.md
index 253428b2..6aa57c85 100644
--- a/docs/03-data-model.md
+++ b/docs/03-data-model.md
@@ -1676,17 +1676,19 @@ plus the outbound-OAuth2 enums (#506) `oauth2_grant_type`
## api_schemas (AF-500)
Uploaded schema documents per connector; the normalized operation catalog is cached on the row.
-Enum `api_schema_type` (`OPENAPI`/`WSDL`/`GRAPHQL_SDL`/`GRPC_PROTO`).
+Enum `api_schema_type` (`OPENAPI`/`WSDL`/`GRAPHQL_SDL`/`GRPC_PROTO`/`POSTMAN_COLLECTION` — the last
+added by V122, #612).
| Column | Type | Notes |
|--------|------|-------|
| `id` | UUID PK | |
| `connector_id` | UUID | FK → `api_connectors` `ON DELETE CASCADE`. |
| `schema_type` | `api_schema_type` | |
-| `raw_content` / `source_url` | TEXT | One of: uploaded body or fetched URL. |
-| `parsed_operations` | JSONB | Cached `ApiOperation[]` (operationId, verb, path, summary, write, tags, deprecated — the last two OpenAPI-only, null elsewhere). Always the **complete** parsed catalog, even when an import filter is set. Default `[]`. |
+| `raw_content` / `source_url` | TEXT | One of: uploaded body or fetched URL. For `POSTMAN_COLLECTION` the stored body is the parser's **redacted** copy — every `event` script block and every auth credential array removed (#612) — never the export as uploaded. |
+| `parsed_operations` | JSONB | Cached `ApiOperation[]` (operationId, verb, path, summary, write, requestSchema, responseSchema, tags, deprecated — the last two OpenAPI-only, null elsewhere; the two schemas are populated by the gRPC-proto and Postman parsers, and are *inferred from examples* for Postman). Always the **complete** parsed catalog, even when an import filter is set. Default `[]`. |
| `operation_filter` | JSONB | Nullable (AF-614). Import-time operation filter — `includePaths`/`excludePaths`, `includeVerbs`/`excludeVerbs`, `includeOperationIds`/`excludeOperationIds`, `includeTags`/`excludeTags` (all string arrays), `excludeDeprecated` (bool). Applied on the read path by `listOperations`; re-editable without re-uploading. `NULL` = no filter = pre-AF-614 behaviour. |
| `operation_count` | INTEGER | **Post-filter** (kept) operation count. Equals the full catalog size when `operation_filter` is `NULL`. |
+| `detected_auth_method` | `api_auth_method` | Nullable (V123, #612). The auth scheme the uploaded document itself declared — Postman collections carry one; `NULL` for every other schema type and for rows predating the column. A hint telling the admin what to configure on the connector; **no credential value from the document is ever stored**. |
| `created_at` | TIMESTAMPTZ | |
## api_connector_user_permissions (AF-500)
diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md
index d8a4014e..7d5d6c2f 100644
--- a/docs/04-api-spec.md
+++ b/docs/04-api-spec.md
@@ -4658,9 +4658,9 @@ secrets themselves are never returned.
| Method | Path | Description |
|--------|------|-------------|
-| `POST` | `/api-connectors/{id}/schemas` | **Admin.** Upload + parse a schema (`schemaType` ∈ `OPENAPI`/`WSDL`/`GRAPHQL_SDL`/`GRPC_PROTO`). Three ingestion modes (#517): paste `rawContent`, upload a file (frontend reads it into `rawContent`), or set `sourceUrl` (server fetches it, http(s), bounded size/timeout — `422 API_SCHEMA_FETCH_ERROR` on failure). Parses immediately into a normalized operation catalog; `422 API_SCHEMA_PARSE_ERROR` on parse failure. Optional `filter` object (AF-614) narrows the governed catalog at import — see **Operation import filter** below. |
+| `POST` | `/api-connectors/{id}/schemas` | **Admin.** Upload + parse a schema (`schemaType` ∈ `OPENAPI`/`WSDL`/`GRAPHQL_SDL`/`GRPC_PROTO`/`POSTMAN_COLLECTION`). Three ingestion modes (#517): paste `rawContent`, upload a file (frontend reads it into `rawContent`), or set `sourceUrl` (server fetches it, http(s), bounded size/timeout — `422 API_SCHEMA_FETCH_ERROR` on failure). Parses immediately into a normalized operation catalog; `422 API_SCHEMA_PARSE_ERROR` on parse failure. Optional `filter` object (AF-614) narrows the governed catalog at import — see **Operation import filter** below. See **Postman collection import** below for the `POSTMAN_COLLECTION` specifics (#612). |
| `POST` | `/api-connectors/{id}/schemas/preview` | **Admin** (AF-614). Dry-runs the same body's `filter` against the parsed document **without persisting**. Returns `{ totalCount, keptCount, excluded[] }` so the admin sees what a pattern drops before committing. |
-| `GET` | `/api-connectors/{id}/schemas` | List a connector's uploaded schemas (raw content not echoed). Each row carries `operationCount` (post-filter), `totalOperationCount` (pre-filter), and `operationFilter` (null when unset). |
+| `GET` | `/api-connectors/{id}/schemas` | List a connector's uploaded schemas (raw content not echoed). Each row carries `operationCount` (post-filter), `totalOperationCount` (pre-filter), `operationFilter` (null when unset), and `detectedAuthMethod` (the auth type the document itself declared — Postman collections carry one; `null` for every other schema type). |
| `PUT` | `/api-connectors/{id}/schemas/{schemaId}/filter` | **Admin** (AF-614). Replaces a schema's operation filter **without re-uploading the document** and recomputes `operationCount` from the stored full catalog. Body is the filter object. |
| `DELETE` | `/api-connectors/{id}/schemas/{schemaId}` | **Admin.** Delete a schema (`204`). |
| `GET` | `/api-connectors/{id}/operations` | The **governed** operation catalog from the connector's latest schema (post-filter) — `operationId`, `verb`, `path`, `summary`, `write` (read/write classification), plus `tags` / `deprecated` (OpenAPI-only, null elsewhere). Filtered-out operations are unreachable here, and therefore also from `/api-editor`, text-to-API, and the `allowedOperations` grant picker. |
@@ -4699,6 +4699,43 @@ The document itself is always stored complete in `parsed_operations`, so the fil
via `PUT .../filter` without re-fetching a remote `sourceUrl`. `API_SCHEMA_UPLOADED` audit metadata
records the filter, `total_operation_count`, and `excluded_count` for both upload and filter edits.
+#### Postman collection import (#612)
+
+`schemaType=POSTMAN_COLLECTION` accepts a **Postman Collection v2.x export** (v2.0 and v2.1; a v1
+export is rejected `422` with a reason pointing at Postman's Collection v2.1 export). A public
+collection link is a natural `sourceUrl`.
+
+| Postman v2.x | `ApiOperation` |
+|---|---|
+| folder path + `item[].name`, slugified and `/`-joined | `operationId` — e.g. `billing/invoices/create-invoice` |
+| `item[].request.method` | `verb` |
+| `url.path[]` (else `url.raw` with scheme+host stripped) | `path` |
+| `item[].request.description` | `summary` |
+| verb ∈ `GET`/`HEAD`/`OPTIONS` | `write=false`, else `true` |
+| `item[].request.body` (raw JSON / urlencoded / formdata) | `requestSchema` — **inferred** |
+| first `item[].response[].body` | `responseSchema` — **inferred**, null when no example is saved |
+
+- **Fidelity gap.** Postman carries *examples, not schemas*, so `requestSchema`/`responseSchema` are
+ inferred from the example payloads (JSON shape → types) rather than declared. They are less
+ precise than an OpenAPI document; the upload UI states this.
+- **Folders flatten** into the `operationId`; `ApiOperation` is unchanged. Colliding names get a
+ deterministic `-2`/`-3` suffix so ids are stable across re-imports.
+- **Variables.** Collection-level `variable[]` values are substituted; every remaining `{{var}}`
+ normalizes to a `{var}` path template, as does Postman's `:id` path-param form. The connector's
+ own `base_url` is the admin's to set — an Environment export is not consumed.
+- **Auth.** The collection's declared auth *type* maps onto `ApiAuthMethod` (`apikey`→`API_KEY`,
+ `bearer`/`jwt`→`BEARER_TOKEN`, `basic`→`BASIC`, `oauth2`→`OAUTH2_CLIENT_CREDENTIALS`,
+ `noauth`→`NONE`) and is surfaced as `detectedAuthMethod`. **No credential value is ever read or
+ stored** — exports frequently contain live tokens, and the admin re-enters credentials through the
+ connector's own authentication settings.
+- **`event` blocks** (arbitrary pre-request/test JavaScript) are ignored entirely: never stored,
+ never evaluated, never fed to the AI analyzer. The document persisted as the schema's raw content
+ is a **redacted** copy with every `event` block and every auth credential array removed.
+- **Bounds.** A collection over 5 MiB or defining more than 2000 requests is rejected `422`.
+- Read/write classification drives the existing `require_review_reads` / `require_review_writes`
+ connector gates unchanged, and the import filter applies exactly as it does to other formats
+ (`tags`/`deprecated` are OpenAPI-only, so the operation-id glob is the load-bearing dimension).
+
### Permissions ("share with team")
| Method | Path | Description |
diff --git a/docs/05-backend.md b/docs/05-backend.md
index 19bc2ea8..6c4b6e72 100644
--- a/docs/05-backend.md
+++ b/docs/05-backend.md
@@ -2085,7 +2085,8 @@ The full REST contract is in `docs/04-api-spec.md` → "API Keys".
The **`apigov/` module** governs outbound API calls (REST / SOAP / GraphQL / gRPC) with the same
review/approval/audit model as a database query. The foundation shipped today: connector
management (CRUD + encrypted auth secret + test-connection probe), schema ingestion (OpenAPI via
-swagger-parser; GraphQL/proto/WSDL via dependency-free parsers behind the `ApiSchemaParser` SPI)
+swagger-parser; GraphQL/proto/WSDL/**Postman collection** via dependency-free parsers behind the
+`ApiSchemaParser` SPI — see **Postman collection import** below)
producing a normalized operation catalog with read/write classification, an **import-time operation
filter** (AF-614, below), and per-user "share-with-team" permissions. The governed-call pipeline is implemented end-to-end:
submit (`POST /api/v1/api-requests`) → async rate-limited AI risk scoring (`ai.api.ApiCallAnalyzer`,
@@ -2123,6 +2124,37 @@ persisting or auditing. Upload **and** filter edits both write `API_SCHEMA_UPLOA
carrying the filter, `total_operation_count`, and `excluded_count` (the edit path is distinguished by
`action=filter_updated`). An absent/empty filter is exactly the pre-AF-614 behaviour.
+**Postman collection import (#612).** Plenty of teams have no OpenAPI/WSDL/SDL/proto document but do
+have a Postman collection that is already the de-facto contract. `PostmanCollectionParser`
+(`apigov/internal/schema/`) accepts a **Collection v2.x export** (v2.0 and v2.1 — a v1 export, which
+has no `info.schema` and a flat `requests` array, is rejected 422 pointing at Postman's v2.1 export).
+It is discovered by `SchemaParserRegistry` from its `supportedType()` like every other parser, so
+neither the registry nor `POST /api-connectors/{id}/schemas` changed. Folders are flattened into a
+slugified, deterministic `operationId` (`billing/invoices/create-invoice`, collisions suffixed
+`-2`/`-3`) — `ApiOperation` is unchanged; collection-level `variable[]` values are substituted into
+paths and every remaining `{{var}}` (and Postman's `:id` param form) normalizes to a `{var}`
+template, leaving the connector's `base_url` to the admin. Read/write classification is the usual
+safe-method rule, so the `require_review_reads`/`require_review_writes` gates apply unchanged.
+
+Postman carries **examples, not schemas**, so `requestSchema`/`responseSchema` are *inferred* from
+the saved example bodies by `JsonShapeInferrer` (JSON shape → a compact JSON-Schema-shaped document;
+arrays typed from their first element, depth-bounded; urlencoded/form-data bodies become string
+properties). That fidelity gap is real and is stated in the upload UI.
+
+Two security properties shape the design. First, **no secret from an export is ever persisted**:
+only the declared auth *type* is read, mapped onto `ApiAuthMethod` and surfaced as the schema row's
+`detected_auth_method` so the admin knows what to re-enter on the connector. Second, `event` blocks
+carry arbitrary pre-request/test **JavaScript** and are ignored entirely — never stored, evaluated,
+or fed to the AI analyzer. Because `api_schemas.raw_content` persists the uploaded document verbatim,
+neither property could hold by parsing alone, so the `ApiSchemaParser` SPI returns
+`apigov.api.ParsedApiSchema` (operations + `detectedAuthMethod` + `sanitizedContent`) instead of a
+bare operation list: the Postman parser hands back a redacted copy of the collection — every `event`
+block and every auth credential array stripped — and `DefaultApiSchemaService` stores *that* as
+`raw_content`. The other four parsers return `null` for both new fields and are otherwise unchanged.
+Documents over 5 MiB or defining more than 2000 requests are rejected 422 (parser constants, mirroring
+`DefaultApiSchemaService.MAX_FETCH_BYTES` — no new configuration property). A `sourceUrl` pointing at
+a public collection link goes through the same fetch guard as any other schema type.
+
**Masking & classification (AF-518).** Connector-level response governance mirroring datasource
dynamic masking (AF-381) + classification tagging (AF-447), adapted to non-tabular bodies. A
masking policy / classification tag targets a field by `api_masking_matcher_type`
diff --git a/docs/17-api-governance.md b/docs/17-api-governance.md
index 9d896b75..c882d6a4 100644
--- a/docs/17-api-governance.md
+++ b/docs/17-api-governance.md
@@ -29,7 +29,7 @@ com.bablsoft.accessflow.apigov/
├── DefaultApiConnectorAdminService, DefaultApiSchemaService
├── persistence/{entity,repo}/ # ApiConnectorEntity, ApiSchemaEntity,
│ # ApiConnectorUserPermissionEntity + repos
- ├── schema/ # ApiSchemaParser SPI + OpenApi/GraphQl/Proto/Wsdl parsers + SchemaParserRegistry
+ ├── schema/ # ApiSchemaParser SPI + OpenApi/GraphQl/Proto/Wsdl/Postman parsers + SchemaParserRegistry
├── client/ # ApiConnectorProber (test-connection); per-protocol execution clients (planned)
└── web/ # ApiConnectorController, ApiSchemaController, DTOs, exception handler, audit writer
```
@@ -99,7 +99,7 @@ encrypted, `@JsonIgnore`, never-returned secrets `oauth2_client_secret_encrypted
## 2. Schema ingestion & operation catalog
An admin uploads a schema document (`POST /api-connectors/{id}/schemas`) with a `schema_type`
-(`OPENAPI`/`WSDL`/`GRAPHQL_SDL`/`GRPC_PROTO`) and `rawContent`. It is parsed **immediately** into a
+(`OPENAPI`/`WSDL`/`GRAPHQL_SDL`/`GRPC_PROTO`/`POSTMAN_COLLECTION`) and `rawContent`. It is parsed **immediately** into a
normalized `ApiOperation` catalog — `operationId`, `verb`, `path`, `summary`, and a **read/write
classification** (`write`) so the same review plans and permissions apply uniformly:
@@ -114,12 +114,31 @@ Parsers live behind the `ApiSchemaParser` SPI, dispatched by `SchemaParserRegist
| `GRAPHQL_SDL` | `GraphQlSchemaParser` | lightweight SDL extractor (root `Query`/`Mutation` fields) |
| `GRPC_PROTO` | `ProtoSchemaParser` | lightweight `.proto` extractor (`service`/`rpc` → `service.method`) |
| `WSDL` | `WsdlSchemaParser` | JDK DOM (XXE-hardened); `portType` `` elements |
+| `POSTMAN_COLLECTION` | `PostmanCollectionParser` | Collection v2.x JSON tree (#612); folders flattened, examples inferred, credentials + scripts stripped |
-> The GraphQL/proto/WSDL parsers are dependency-free (no graphql-java / wire / wsdl4j) to keep the
-> build offline-reproducible; they cover the common document shapes. Replacing them with the full
+> The GraphQL/proto/WSDL/Postman parsers are dependency-free (no graphql-java / wire / wsdl4j) to keep
+> the build offline-reproducible; they cover the common document shapes. Replacing them with the full
> grammar libraries is a drop-in change behind the SPI if richer request/response schema extraction
> is needed.
+A parser returns `apigov.api.ParsedApiSchema` — the operation list plus two optional extras a format
+may carry: `detectedAuthMethod` (the auth scheme the document declares) and `sanitizedContent` (a
+redacted document to persist in place of the upload). Only the Postman parser populates them today;
+the other four return `null` for both.
+
+**Postman collections (#612).** A Collection v2.x export is a first-class schema source for teams
+with no OpenAPI document. Folders flatten into a slugified deterministic `operationId`
+(`billing/invoices/create-invoice`); collection `variable[]` values are substituted and every
+remaining `{{var}}` — plus Postman's `:id` form — becomes a `{var}` path template, leaving `base_url`
+to the admin. Because Postman stores **examples, not schemas**, `requestSchema`/`responseSchema` are
+*inferred* from the saved example bodies (`JsonShapeInferrer`) and are correspondingly less precise
+— the upload UI says so. Two hard security rules: the collection's declared auth **type** is read
+(surfaced as `api_schemas.detected_auth_method`) but **no credential value is ever persisted**, and
+`event` blocks — arbitrary pre-request/test JavaScript — are dropped entirely. Since `raw_content`
+stores the document verbatim, the parser returns a redacted copy (credential arrays and `event`
+blocks removed) as `sanitizedContent`, and that is what gets stored. A v1 export is rejected `422`
+with a pointer to Postman's v2.1 export; documents over 5 MiB or 2000 requests are rejected too.
+
The catalog is cached on `api_schemas.parsed_operations` and re-parsed on each upload. Invalid
documents are rejected `422 API_SCHEMA_PARSE_ERROR`. The editor reads it via
`GET /api-connectors/{id}/operations`.
diff --git a/e2e/tests/api-governance.spec.ts b/e2e/tests/api-governance.spec.ts
index 795b9cc2..16a4e8af 100644
--- a/e2e/tests/api-governance.spec.ts
+++ b/e2e/tests/api-governance.spec.ts
@@ -80,6 +80,85 @@ test('admin creates an API connector and uploads an OpenAPI schema', async ({ pa
await expect(page.getByText(CONNECTOR_NAME)).toBeVisible();
});
+// #612: a Postman Collection v2.1 export is a first-class schema source. Folders flatten into
+// slugified operation ids, {{variables}} become path templates, and the export's credentials and
+// pre-request scripts are dropped rather than stored.
+const POSTMAN_COLLECTION = JSON.stringify({
+ info: {
+ name: 'Billing',
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
+ },
+ variable: [{ key: 'baseUrl', value: 'https://billing.example.com' }],
+ auth: { type: 'bearer', bearer: [{ key: 'token', value: 'E2E-LEAKED-TOKEN' }] },
+ item: [
+ {
+ name: 'Invoices',
+ item: [
+ {
+ name: 'Create Invoice',
+ event: [{ listen: 'prerequest', script: { exec: ["pm.environment.set('x', 1)"] } }],
+ request: {
+ method: 'POST',
+ url: { raw: '{{baseUrl}}/v1/invoices', path: ['v1', 'invoices'] },
+ body: { mode: 'raw', raw: '{"amount": 10}' },
+ },
+ },
+ {
+ name: 'Get Invoice',
+ request: {
+ method: 'GET',
+ url: { raw: '{{baseUrl}}/v1/invoices/:id', path: ['v1', 'invoices', ':id'] },
+ },
+ },
+ ],
+ },
+ ],
+});
+
+test('admin imports a Postman collection as a connector schema (#612)', async ({ page, request }) => {
+ const adminToken = await loginViaApi(request, ADMIN_EMAIL, ADMIN_PASSWORD);
+ const connector = await createApiConnectorViaApi(request, adminToken, {
+ name: `Billing ${Date.now()}`,
+ aiAnalysisEnabled: false,
+ });
+
+ await loginViaUi(page, ADMIN_EMAIL, ADMIN_PASSWORD);
+ await page.goto(`/api-connectors/${connector.id}/settings`);
+ await page.getByRole('tab', { name: 'Schema' }).click();
+
+ // Pick the Postman schema type; the caveat banner must appear before upload.
+ const panel = page.locator('.ant-tabs-tabpane-active');
+ await panel.getByRole('combobox', { name: 'Schema type' }).click();
+ await page.getByTitle('Postman Collection').click();
+ await expect(page.getByText(/inferred from the saved example bodies/)).toBeVisible();
+ await expect(page.getByText(/credentials in your export are ignored/)).toBeVisible();
+
+ await panel.getByPlaceholder('Schema document').fill(POSTMAN_COLLECTION);
+ const uploadResponse = page.waitForResponse(
+ (r) => r.request().method() === 'POST' && r.url().includes('/schemas') && r.ok(),
+ );
+ await panel.getByRole('button', { name: 'Upload schema' }).click();
+ await uploadResponse;
+
+ // Folder-nested items flatten into stable slugified ids, :id becomes a {id} path template.
+ await page.getByRole('tab', { name: 'Operations' }).click();
+ await expect(page.getByText('invoices/create-invoice')).toBeVisible();
+ await expect(page.getByText('invoices/get-invoice')).toBeVisible();
+ await expect(page.getByText('/v1/invoices/{id}')).toBeVisible();
+
+ // The declared auth type is surfaced, but the token in the export is never persisted.
+ await page.getByRole('tab', { name: 'Schema' }).click();
+ await expect(panel.getByText('Bearer Token')).toBeVisible();
+
+ const operations = await request.get(
+ `${apiBase()}/api/v1/api-connectors/${connector.id}/operations`,
+ { headers: { Authorization: `Bearer ${adminToken}` } },
+ );
+ const body = await operations.text();
+ expect(body).not.toContain('E2E-LEAKED-TOKEN');
+ expect(body).not.toContain('pm.environment.set');
+});
+
test('API Requests and API Reviews pages render their filter bars (#512)', async ({ page }) => {
await loginViaUi(page, ADMIN_EMAIL, ADMIN_PASSWORD);
diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json
index 7ee674a4..a5ab5d86 100644
--- a/frontend/src/locales/de.json
+++ b/frontend/src/locales/de.json
@@ -2439,7 +2439,8 @@
"OPENAPI": "OpenAPI",
"WSDL": "WSDL",
"GRAPHQL_SDL": "GraphQL SDL",
- "GRPC_PROTO": "gRPC Proto"
+ "GRPC_PROTO": "gRPC Proto",
+ "POSTMAN_COLLECTION": "Postman-Collection"
},
"oauth2_grant_type": {
"CLIENT_CREDENTIALS": "Client Credentials",
@@ -3149,6 +3150,14 @@
"schemaUrlRequired": "Geben Sie eine Schema-URL ein",
"schemaFile": "Schema-Datei",
"schemaFileRequired": "Wählen Sie eine Schema-Datei",
+ "postmanCaveatTitle": "Postman-Collections beschreiben Beispiele, keine Schemas",
+ "postmanInferredSchemas": "Anfrage- und Antwortschemas werden aus den gespeicherten Beispieltexten abgeleitet und sind daher ungenauer als ein OpenAPI-Dokument.",
+ "postmanCredentialsIgnored": "Zugangsdaten in deinem Export werden ignoriert und niemals gespeichert – gib sie in den Authentifizierungseinstellungen des Konnektors erneut ein.",
+ "schemaFileDragHint": "Schemadatei hierher ziehen oder klicken",
+ "schemaFileFormats": "OpenAPI, WSDL, GraphQL SDL, gRPC-Proto oder ein Postman-Collection-v2.1-Export (max. 5 MB)",
+ "schemaFileTooLarge": "Diese Datei überschreitet das Limit von 5 MB",
+ "detectedAuth": "Erkannte Authentifizierung",
+ "detectedAuthNone": "Keine angegeben",
"tabMasking": "Maskierung",
"tabClassification": "Klassifizierung",
"tabVariables": "Variablen",
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index f69c09fb..86fa715a 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -2474,7 +2474,8 @@
"OPENAPI": "OpenAPI",
"WSDL": "WSDL",
"GRAPHQL_SDL": "GraphQL SDL",
- "GRPC_PROTO": "gRPC Proto"
+ "GRPC_PROTO": "gRPC Proto",
+ "POSTMAN_COLLECTION": "Postman Collection"
},
"oauth2_grant_type": {
"CLIENT_CREDENTIALS": "Client Credentials",
@@ -3149,6 +3150,14 @@
"schemaUrlRequired": "Enter a schema URL",
"schemaFile": "Schema file",
"schemaFileRequired": "Choose a schema file",
+ "postmanCaveatTitle": "Postman collections describe examples, not schemas",
+ "postmanInferredSchemas": "Request and response schemas are inferred from the saved example bodies, so they are less precise than an OpenAPI document.",
+ "postmanCredentialsIgnored": "Any credentials in your export are ignored and never stored — re-enter them in the connector's authentication settings.",
+ "schemaFileDragHint": "Click or drag a schema file here",
+ "schemaFileFormats": "OpenAPI, WSDL, GraphQL SDL, gRPC proto or a Postman Collection v2.1 export (max 5 MB)",
+ "schemaFileTooLarge": "That file is larger than the 5 MB limit",
+ "detectedAuth": "Detected auth",
+ "detectedAuthNone": "None declared",
"tabMasking": "Masking",
"tabClassification": "Classification",
"tabVariables": "Variables",
diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json
index 4a165d92..b472d3ba 100644
--- a/frontend/src/locales/es.json
+++ b/frontend/src/locales/es.json
@@ -2439,7 +2439,8 @@
"OPENAPI": "OpenAPI",
"WSDL": "WSDL",
"GRAPHQL_SDL": "GraphQL SDL",
- "GRPC_PROTO": "gRPC Proto"
+ "GRPC_PROTO": "gRPC Proto",
+ "POSTMAN_COLLECTION": "Colección de Postman"
},
"oauth2_grant_type": {
"CLIENT_CREDENTIALS": "Credenciales de cliente",
@@ -3149,6 +3150,14 @@
"schemaUrlRequired": "Introduzca una URL de esquema",
"schemaFile": "Archivo de esquema",
"schemaFileRequired": "Elija un archivo de esquema",
+ "postmanCaveatTitle": "Las colecciones de Postman describen ejemplos, no esquemas",
+ "postmanInferredSchemas": "Los esquemas de solicitud y respuesta se infieren de los cuerpos de ejemplo guardados, por lo que son menos precisos que un documento OpenAPI.",
+ "postmanCredentialsIgnored": "Las credenciales incluidas en tu exportación se ignoran y nunca se almacenan: vuelve a introducirlas en la configuración de autenticación del conector.",
+ "schemaFileDragHint": "Haz clic o arrastra aquí un archivo de esquema",
+ "schemaFileFormats": "OpenAPI, WSDL, GraphQL SDL, gRPC proto o una exportación de colección de Postman v2.1 (máx. 5 MB)",
+ "schemaFileTooLarge": "Ese archivo supera el límite de 5 MB",
+ "detectedAuth": "Autenticación detectada",
+ "detectedAuthNone": "Ninguna declarada",
"tabMasking": "Enmascaramiento",
"tabClassification": "Clasificación",
"tabVariables": "Variables",
diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json
index f92665e3..5374da8c 100644
--- a/frontend/src/locales/fr.json
+++ b/frontend/src/locales/fr.json
@@ -2439,7 +2439,8 @@
"OPENAPI": "OpenAPI",
"WSDL": "WSDL",
"GRAPHQL_SDL": "GraphQL SDL",
- "GRPC_PROTO": "gRPC Proto"
+ "GRPC_PROTO": "gRPC Proto",
+ "POSTMAN_COLLECTION": "Collection Postman"
},
"oauth2_grant_type": {
"CLIENT_CREDENTIALS": "Identifiants client",
@@ -3149,6 +3150,14 @@
"schemaUrlRequired": "Saisissez une URL de schéma",
"schemaFile": "Fichier de schéma",
"schemaFileRequired": "Choisissez un fichier de schéma",
+ "postmanCaveatTitle": "Les collections Postman décrivent des exemples, pas des schémas",
+ "postmanInferredSchemas": "Les schémas de requête et de réponse sont déduits des corps d'exemple enregistrés ; ils sont donc moins précis qu'un document OpenAPI.",
+ "postmanCredentialsIgnored": "Les identifiants présents dans votre export sont ignorés et jamais stockés — ressaisissez-les dans les paramètres d'authentification du connecteur.",
+ "schemaFileDragHint": "Cliquez ou déposez un fichier de schéma ici",
+ "schemaFileFormats": "OpenAPI, WSDL, GraphQL SDL, proto gRPC ou un export de collection Postman v2.1 (5 Mo max.)",
+ "schemaFileTooLarge": "Ce fichier dépasse la limite de 5 Mo",
+ "detectedAuth": "Authentification détectée",
+ "detectedAuthNone": "Aucune déclarée",
"tabMasking": "Masquage",
"tabClassification": "Classification",
"tabVariables": "Variables",
diff --git a/frontend/src/locales/hy.json b/frontend/src/locales/hy.json
index 64e8e7f1..98c3636b 100644
--- a/frontend/src/locales/hy.json
+++ b/frontend/src/locales/hy.json
@@ -2439,7 +2439,8 @@
"OPENAPI": "OpenAPI",
"WSDL": "WSDL",
"GRAPHQL_SDL": "GraphQL SDL",
- "GRPC_PROTO": "gRPC Proto"
+ "GRPC_PROTO": "gRPC Proto",
+ "POSTMAN_COLLECTION": "Postman-ի հավաքածու"
},
"oauth2_grant_type": {
"CLIENT_CREDENTIALS": "Հաճախորդի հավատարմագրեր",
@@ -3149,6 +3150,14 @@
"schemaUrlRequired": "Մուտքագրեք սխեմայի URL",
"schemaFile": "Սխեմայի ֆայլ",
"schemaFileRequired": "Ընտրեք սխեմայի ֆայլ",
+ "postmanCaveatTitle": "Postman-ի հավաքածուները նկարագրում են օրինակներ, ոչ թե սխեմաներ",
+ "postmanInferredSchemas": "Հարցման և պատասխանի սխեմաները ենթադրվում են պահպանված օրինակների հիման վրա, ուստի դրանք ավելի քիչ ճշգրիտ են, քան OpenAPI փաստաթուղթը։",
+ "postmanCredentialsIgnored": "Ձեր արտահանման մեջ առկա ցանկացած հավատարմագիր անտեսվում է և երբեք չի պահվում — կրկին մուտքագրեք դրանք միակցիչի նույնականացման կարգավորումներում։",
+ "schemaFileDragHint": "Սեղմեք կամ քաշեք սխեմայի ֆայլն այստեղ",
+ "schemaFileFormats": "OpenAPI, WSDL, GraphQL SDL, gRPC proto կամ Postman-ի v2.1 հավաքածուի արտահանում (առավելագույնը 5 ՄԲ)",
+ "schemaFileTooLarge": "Այդ ֆայլը գերազանցում է 5 ՄԲ սահմանաչափը",
+ "detectedAuth": "Հայտնաբերված նույնականացում",
+ "detectedAuthNone": "Հայտարարված չէ",
"tabMasking": "Քողարկում",
"tabClassification": "Դասակարգում",
"tabVariables": "Փոփոխականներ",
diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json
index bf9b62b8..b5facda1 100644
--- a/frontend/src/locales/ru.json
+++ b/frontend/src/locales/ru.json
@@ -2439,7 +2439,8 @@
"OPENAPI": "OpenAPI",
"WSDL": "WSDL",
"GRAPHQL_SDL": "GraphQL SDL",
- "GRPC_PROTO": "gRPC Proto"
+ "GRPC_PROTO": "gRPC Proto",
+ "POSTMAN_COLLECTION": "Коллекция Postman"
},
"oauth2_grant_type": {
"CLIENT_CREDENTIALS": "Учётные данные клиента",
@@ -3149,6 +3150,14 @@
"schemaUrlRequired": "Введите URL схемы",
"schemaFile": "Файл схемы",
"schemaFileRequired": "Выберите файл схемы",
+ "postmanCaveatTitle": "Коллекции Postman описывают примеры, а не схемы",
+ "postmanInferredSchemas": "Схемы запроса и ответа выводятся из сохранённых примеров тел, поэтому они менее точны, чем документ OpenAPI.",
+ "postmanCredentialsIgnored": "Любые учётные данные в вашем экспорте игнорируются и никогда не сохраняются — введите их заново в настройках аутентификации коннектора.",
+ "schemaFileDragHint": "Нажмите или перетащите сюда файл схемы",
+ "schemaFileFormats": "OpenAPI, WSDL, GraphQL SDL, gRPC proto или экспорт коллекции Postman v2.1 (не более 5 МБ)",
+ "schemaFileTooLarge": "Этот файл превышает ограничение в 5 МБ",
+ "detectedAuth": "Обнаруженная аутентификация",
+ "detectedAuthNone": "Не объявлена",
"tabMasking": "Маскирование",
"tabClassification": "Классификация",
"tabVariables": "Переменные",
diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json
index ba6c9377..e6dab31a 100644
--- a/frontend/src/locales/zh-CN.json
+++ b/frontend/src/locales/zh-CN.json
@@ -2439,7 +2439,8 @@
"OPENAPI": "OpenAPI",
"WSDL": "WSDL",
"GRAPHQL_SDL": "GraphQL SDL",
- "GRPC_PROTO": "gRPC Proto"
+ "GRPC_PROTO": "gRPC Proto",
+ "POSTMAN_COLLECTION": "Postman 集合"
},
"oauth2_grant_type": {
"CLIENT_CREDENTIALS": "客户端凭据",
@@ -3149,6 +3150,14 @@
"schemaUrlRequired": "请输入模式 URL",
"schemaFile": "模式文件",
"schemaFileRequired": "请选择模式文件",
+ "postmanCaveatTitle": "Postman 集合描述的是示例,而非模式",
+ "postmanInferredSchemas": "请求和响应模式是根据保存的示例内容推断的,因此不如 OpenAPI 文档精确。",
+ "postmanCredentialsIgnored": "导出文件中的任何凭据都会被忽略且永不存储——请在连接器的身份验证设置中重新输入。",
+ "schemaFileDragHint": "点击或将模式文件拖到此处",
+ "schemaFileFormats": "OpenAPI、WSDL、GraphQL SDL、gRPC proto 或 Postman 集合 v2.1 导出文件(最大 5 MB)",
+ "schemaFileTooLarge": "该文件超过 5 MB 限制",
+ "detectedAuth": "检测到的身份验证",
+ "detectedAuthNone": "未声明",
"tabMasking": "脱敏",
"tabClassification": "分类",
"tabVariables": "变量",
diff --git a/frontend/src/pages/apigov/ApiConnectorSettingsPage.test.tsx b/frontend/src/pages/apigov/ApiConnectorSettingsPage.test.tsx
index 22654383..aaec9641 100644
--- a/frontend/src/pages/apigov/ApiConnectorSettingsPage.test.tsx
+++ b/frontend/src/pages/apigov/ApiConnectorSettingsPage.test.tsx
@@ -320,3 +320,116 @@ describe('ApiConnectorSettingsPage — schema import filter (AF-614)', () => {
);
});
});
+
+describe('ApiConnectorSettingsPage — Postman collection import (AF-612)', () => {
+ beforeEach(() => {
+ getApiConnector.mockReset();
+ getApiConnector.mockResolvedValue(baseConnector);
+ listReviewPlans.mockReset();
+ listReviewPlans.mockResolvedValue([]);
+ listApiSchemas.mockReset();
+ listApiSchemas.mockResolvedValue([]);
+ uploadApiSchema.mockReset();
+ previewApiSchemaFilter.mockReset();
+ updateApiSchemaFilter.mockReset();
+ });
+
+ /** Activates the Schema tab and picks a schema type from the type Select. */
+ async function openSchemaTabWithType(label: string) {
+ render(wrap());
+ fireEvent.click(await screen.findByRole('tab', { name: 'Schema' }));
+ const panel = document.querySelector('.ant-tabs-tabpane-active') as HTMLElement;
+ fireEvent.mouseDown(within(panel).getByRole('combobox', { name: 'Schema type' }));
+ fireEvent.click(await screen.findByTitle(label));
+ return panel;
+ }
+
+ it('offers Postman Collection as a schema type', async () => {
+ await openSchemaTabWithType('Postman Collection');
+
+ expect(await screen.findByText(/Postman collections describe examples/)).toBeInTheDocument();
+ });
+
+ it('warns that schemas are inferred and export credentials are ignored', async () => {
+ await openSchemaTabWithType('Postman Collection');
+
+ expect(
+ await screen.findByText(/inferred from the saved example bodies/),
+ ).toBeInTheDocument();
+ expect(await screen.findByText(/credentials in your export are ignored/)).toBeInTheDocument();
+ });
+
+ it('does not show the Postman caveat for other schema types', async () => {
+ render(wrap());
+ fireEvent.click(await screen.findByRole('tab', { name: 'Schema' }));
+
+ await screen.findByPlaceholderText('Schema document');
+ expect(screen.queryByText(/Postman collections describe examples/)).not.toBeInTheDocument();
+ });
+
+ it('uploads a pasted collection with schema_type POSTMAN_COLLECTION', async () => {
+ uploadApiSchema.mockResolvedValue({
+ id: 's1',
+ schema_type: 'POSTMAN_COLLECTION',
+ source_url: null,
+ operation_count: 2,
+ total_operation_count: 2,
+ operation_filter: null,
+ detected_auth_method: 'BEARER_TOKEN',
+ created_at: '2026-05-01T00:00:00Z',
+ });
+ const panel = await openSchemaTabWithType('Postman Collection');
+ fireEvent.change(within(panel).getByPlaceholderText('Schema document'), {
+ target: { value: '{"info":{"schema":"v2.1.0"}}' },
+ });
+
+ fireEvent.click(within(panel).getByText('Upload schema'));
+
+ await waitFor(() =>
+ expect(uploadApiSchema).toHaveBeenCalledWith(
+ 'conn-1',
+ expect.objectContaining({ schema_type: 'POSTMAN_COLLECTION' }),
+ ),
+ );
+ });
+
+ it('shows the detected auth type on an uploaded schema row', async () => {
+ listApiSchemas.mockResolvedValue([
+ {
+ id: 's1',
+ schema_type: 'POSTMAN_COLLECTION',
+ source_url: null,
+ operation_count: 2,
+ total_operation_count: 2,
+ operation_filter: null,
+ detected_auth_method: 'BEARER_TOKEN',
+ created_at: '2026-05-01T00:00:00Z',
+ },
+ ]);
+
+ render(wrap());
+ fireEvent.click(await screen.findByRole('tab', { name: 'Schema' }));
+
+ expect(await screen.findByText('Bearer Token')).toBeInTheDocument();
+ });
+
+ it('falls back to "None declared" when the document declared no auth', async () => {
+ listApiSchemas.mockResolvedValue([
+ {
+ id: 's1',
+ schema_type: 'OPENAPI',
+ source_url: null,
+ operation_count: 1,
+ total_operation_count: 1,
+ operation_filter: null,
+ detected_auth_method: null,
+ created_at: '2026-05-01T00:00:00Z',
+ },
+ ]);
+
+ render(wrap());
+ fireEvent.click(await screen.findByRole('tab', { name: 'Schema' }));
+
+ expect(await screen.findByText('None declared')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/apigov/ApiConnectorSettingsPage.tsx b/frontend/src/pages/apigov/ApiConnectorSettingsPage.tsx
index 73576ac1..f277dbea 100644
--- a/frontend/src/pages/apigov/ApiConnectorSettingsPage.tsx
+++ b/frontend/src/pages/apigov/ApiConnectorSettingsPage.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import {
+ Alert,
App,
Button,
Checkbox,
@@ -18,7 +19,7 @@ import {
Typography,
Upload,
} from 'antd';
-import { UploadOutlined } from '@ant-design/icons';
+import { InboxOutlined } from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -274,6 +275,8 @@ function ConfigTab({ connectorId }: { connectorId: string }) {
const HTTP_VERBS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
const MAX_FILTER_LIST = 100;
const MAX_FILTER_PATTERN = 200;
+// Mirrors the backend's 5 MiB document bound so an oversized file fails before the round-trip.
+const MAX_SCHEMA_FILE_BYTES = 5 * 1024 * 1024;
const EMPTY_FILTER: ApiOperationFilter = {
includePaths: [],
@@ -432,6 +435,7 @@ function SchemaTab({ connectorId }: { connectorId: string }) {
const [source, setSource] = useState<'paste' | 'upload' | 'url'>('paste');
const [content, setContent] = useState('');
const [fileName, setFileName] = useState('');
+ const [fileError, setFileError] = useState(null);
const [sourceUrl, setSourceUrl] = useState('');
const [filter, setFilter] = useState(EMPTY_FILTER);
const [preview, setPreview] = useState(null);
@@ -459,6 +463,7 @@ function SchemaTab({ connectorId }: { connectorId: string }) {
setImported({ kept: schema.operation_count, total: schema.total_operation_count });
setContent('');
setFileName('');
+ setFileError(null);
setSourceUrl('');
setPreview(null);
invalidate();
@@ -486,8 +491,7 @@ function SchemaTab({ connectorId }: { connectorId: string }) {
}
previewMutation.mutate();
};
- const canUpload =
- source === 'url' ? !!sourceUrl.trim() : source === 'upload' ? !!content : !!content.trim();
+ const canUpload = source === 'url' ? !!sourceUrl.trim() : !!content.trim();
const deleteMutation = useMutation({
mutationFn: (schemaId: string) => deleteApiSchema(connectorId, schemaId),
onSuccess: () => {
@@ -503,7 +507,21 @@ function SchemaTab({ connectorId }: { connectorId: string }) {
value={schemaType}
onChange={setSchemaType}
options={enumOptions(API_SCHEMA_TYPES, apiSchemaTypeLabel, t)}
+ aria-label={t('apiGov.settings.schemaType')}
/>
+ {schemaType === 'POSTMAN_COLLECTION' && (
+
+