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' && ( + +

{t('apiGov.settings.postmanInferredSchemas')}
+
{t('apiGov.settings.postmanCredentialsIgnored')}
+ + } + /> + )} setSource(v as 'paste' | 'upload' | 'url')} @@ -522,20 +540,33 @@ function SchemaTab({ connectorId }: { connectorId: string }) { /> )} {source === 'upload' && ( - { - void file.text().then((text) => { - setContent(text); - setFileName(file.name); - }); - return false; - }} - > - - + <> + { + setFileError(null); + if (file.size > MAX_SCHEMA_FILE_BYTES) { + setFileError(t('apiGov.settings.schemaFileTooLarge')); + return Upload.LIST_IGNORE; + } + void file.text().then((text) => { + setContent(text); + setFileName(file.name); + }); + return false; // read client-side; the document is POSTed as raw_content + }} + > +

+ +

+

{fileName || t('apiGov.settings.schemaFileDragHint')}

+

{t('apiGov.settings.schemaFileFormats')}

+
+ {fileError && } + )} {source === 'url' && ( + row.detected_auth_method ? ( + {apiAuthMethodLabel(t, row.detected_auth_method)} + ) : ( + + {t('apiGov.settings.detectedAuthNone')} + + ), + }, { title: '', key: 'a', render: (_: unknown, row: ApiSchema) => (