Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <em>inferred from examples</em> rather than
* declared.
*/
public enum ApiSchemaType {
OPENAPI,
WSDL,
GRAPHQL_SDL,
GRPC_PROTO
GRPC_PROTO,
POSTMAN_COLLECTION
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,5 +19,6 @@ public record ApiSchemaView(
int operationCount,
int totalOperationCount,
OperationFilter operationFilter,
ApiAuthMethod detectedAuthMethod,
Instant createdAt) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.bablsoft.accessflow.apigov.api;

import java.util.List;

/**
* Result of parsing an uploaded API schema document.
*
* <p>{@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.
*
* <p>{@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<ApiOperation> operations,
ApiAuthMethod detectedAuthMethod,
String sanitizedContent) {

/** For parsers whose format declares no auth and needs no sanitization. */
public ParsedApiSchema(List<ApiOperation> operations) {
this(operations, null, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
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 {

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<ApiOperation> parse(String content);
ParsedApiSchema parse(String content);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -30,7 +31,7 @@ public ApiSchemaType supportedType() {
}

@Override
public List<ApiOperation> parse(String content) {
public ParsedApiSchema parse(String content) {
if (content == null || content.isBlank()) {
throw new ApiSchemaParseException("Empty GraphQL SDL document");
}
Expand All @@ -40,7 +41,7 @@ public List<ApiOperation> 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <em>example</em> 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<String, String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,7 +27,7 @@ public ApiSchemaType supportedType() {
}

@Override
public List<ApiOperation> parse(String content) {
public ParsedApiSchema parse(String content) {
SwaggerParseResult result;
try {
result = new OpenAPIParser().readContents(content, null, null);
Expand All @@ -53,6 +54,6 @@ public List<ApiOperation> parse(String content) {
if (operations.isEmpty()) {
throw new ApiSchemaParseException("OpenAPI document defines no operations");
}
return operations;
return new ParsedApiSchema(operations);
}
}
Loading
Loading