diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a531927..52fa755a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,4 +29,8 @@ jobs: # intentionally do not inherit the runtime's SpotBugs build plugin. run: mvn spotbugs:check -pl '!examples/conformance/lib,!examples/conformance' - name: OWASP Dependency Check - run: mvn org.owasp:dependency-check-maven:check + env: + NVD_API_KEY: ${{ secrets.NVD_API_KEY }} + # Pass only the environment variable NAME on the command line, never + # the secret value. Pin the scanner for non-inheriting example modules too. + run: mvn -B org.owasp:dependency-check-maven:13.0.0:check -DnvdApiKeyEnvironmentVariable=NVD_API_KEY diff --git a/examples/school-management/pom.xml b/examples/school-management/pom.xml index ad402320..fe5ab3e0 100644 --- a/examples/school-management/pom.xml +++ b/examples/school-management/pom.xml @@ -43,6 +43,11 @@ + + io.teaql + teaql-query-json + ${teaql.version} + io.teaql teaql-provider-spring-jdbc diff --git a/examples/school-management/src/main/java/com/example/schoolmanagementservice/App.java b/examples/school-management/src/main/java/com/example/schoolmanagementservice/App.java index 896182aa..b8a3b187 100644 --- a/examples/school-management/src/main/java/com/example/schoolmanagementservice/App.java +++ b/examples/school-management/src/main/java/com/example/schoolmanagementservice/App.java @@ -129,6 +129,7 @@ public CommandLineRunner teaQLConsoleStartup( school.auditAs("Create the School Query conformance fixture").save(context); } + verifyDynamicSearch(context); assertQuery(context, "string equality", Q.schools().withNameIs("Riverside Primary School"), 1); assertQuery(context, "string inequality", Q.schools().withNameIsNot("Another School"), 1); assertQuery(context, "string membership", Q.schools().withNameIn("Riverside Primary School", "Another School"), 1); @@ -226,4 +227,48 @@ private static void assertQuery( private static void require(boolean condition, String message) { if (!condition) throw new IllegalStateException(message); } + + private static void verifyDynamicSearch(UserContext context) { + var models = java.util.Map.of( + "School", new io.teaql.query.json.LocalDynamicSearch.Model( + java.util.Map.of("name", "string"), java.util.Map.of("platform", "Platform")), + "Platform", new io.teaql.query.json.LocalDynamicSearch.Model( + java.util.Map.of("name", "string"), java.util.Map.of())); + String input = """ + {"filter":{"name":"Riverside Primary School","platform.name":"Campus Learning Platform", + "removed":"SECRET_VALUE","platform.removed":"SECRET_VALUE"}, + "orderBy":[{"field":"removed","direction":"asc"}]} + """; + // Authorization input is server-owned, never accepted from the JSON form. + for (long authorizedPlatform : new long[] {1L, 2L}) { + for (String searchInput : new String[] {input, "{}"}) { + SchoolRequest request = Q.schools().withNameIs("Riverside Primary School") + .withPlatformMatching(Q.platforms().withIdIs(authorizedPlatform)); + request.setSize(2); + request.orderByIdDescending(); + int hardLimit = request.hardLimit(); + var warnings = new java.util.ArrayList(); + io.teaql.query.json.LocalDynamicSearch.merge(request, searchInput, models, filter -> { + if (!"$eq".equals(filter.operator())) { + throw new IllegalArgumentException("This demo binding supports equality only"); + } + return switch (filter.fieldPath()) { + case "name" -> Q.schools().withNameIs(filter.value().textValue()).getSearchCriteria(); + case "platform.name" -> Q.schools().withPlatformMatching( + Q.platforms().withIdIs(authorizedPlatform).withNameIs(filter.value().textValue())) + .getSearchCriteria(); + default -> throw new IllegalArgumentException("Missing trusted demo binding"); + }; + }, order -> { throw new IllegalArgumentException("No demo order binding required"); }, warnings::add); + SmartList rows = request.comment("what: generated School dynamic search") + .purpose("why: verify stale fields cannot bypass related authorization").executeForList(context); + require(rows.size() == (authorizedPlatform == 1L ? 1 : 0), + "Dynamic search lost its related authorization filter"); + require(warnings.size() == (searchInput.equals("{}") ? 0 : 3) + && request.hardLimit() == hardLimit && request.getSize() == 2, + "Dynamic search warning or limit contract failed"); + } + } + System.out.println("PASS Java generated School dynamic search: related scope, drift warnings, typed bindings"); + } } diff --git a/pom.xml b/pom.xml index c440af48..97cbc78c 100644 --- a/pom.xml +++ b/pom.xml @@ -314,7 +314,7 @@ org.owasp dependency-check-maven - 10.0.4 + 13.0.0 false diff --git a/teaql-core/src/main/java/io/teaql/core/checker/CheckResult.java b/teaql-core/src/main/java/io/teaql/core/checker/CheckResult.java index 9ba150e9..f9a2c7ee 100644 --- a/teaql-core/src/main/java/io/teaql/core/checker/CheckResult.java +++ b/teaql-core/src/main/java/io/teaql/core/checker/CheckResult.java @@ -1,6 +1,7 @@ package io.teaql.core.checker; import java.time.LocalDateTime; +import java.util.Locale; public class CheckResult { private RuleId ruleId; @@ -12,6 +13,7 @@ public class CheckResult { private Object systemValue; private String naturalLanguageStatement; + private String sourceInstancePath; public static CheckResult required(ObjectLocation location) { CheckResult checkResult = new CheckResult(); @@ -144,6 +146,27 @@ public void setNaturalLanguageStatement(String pNaturalLanguageStatement) { naturalLanguageStatement = pNaturalLanguageStatement; } + /** Exact RFC 6901 pointer submitted through an accepted input alias. */ + public String getSourceInstancePath() { + return sourceInstancePath; + } + + public void setSourceInstancePath(String pSourceInstancePath) { + sourceInstancePath = pSourceInstancePath; + } + + public WireCheckResult toWire(JsonFieldNamingProfile profile) { + return new WireCheckResult( + ruleId == null ? null : ruleId.name().toLowerCase(Locale.ROOT), + rootType, + location == null ? null : location.segments(), + location == null ? null : location.instancePath(profile), + sourceInstancePath, + inputValue, + systemValue, + naturalLanguageStatement); + } + public enum RuleId { MIN, MAX, diff --git a/teaql-core/src/main/java/io/teaql/core/checker/JsonFieldNamingProfile.java b/teaql-core/src/main/java/io/teaql/core/checker/JsonFieldNamingProfile.java new file mode 100644 index 00000000..cb99e243 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/checker/JsonFieldNamingProfile.java @@ -0,0 +1,46 @@ +package io.teaql.core.checker; + +/** Model-selected naming policy for JSON fields and RFC 6901 instance paths. */ +public enum JsonFieldNamingProfile { + CAMEL_CASE("camelCase"), + SNAKE_CASE("snake_case"), + PASCAL_CASE("PascalCase"); + + private final String modelValue; + + JsonFieldNamingProfile(String pModelValue) { + modelValue = pModelValue; + } + + public String modelValue() { + return modelValue; + } + + public String render(String canonicalName) { + if (this == SNAKE_CASE) { + return canonicalName; + } + String[] parts = canonicalName.split("_", -1); + StringBuilder result = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (parts[i].isEmpty()) continue; + if (i == 0 && this == CAMEL_CASE) { + result.append(parts[i]); + } else { + result.append(Character.toUpperCase(parts[i].charAt(0))) + .append(parts[i].substring(1)); + } + } + return result.toString(); + } + + public static JsonFieldNamingProfile fromModelValue(String value) { + if (value == null || value.isBlank() || "camelCase".equals(value)) { + return CAMEL_CASE; + } + for (JsonFieldNamingProfile profile : values()) { + if (profile.modelValue.equals(value)) return profile; + } + throw new IllegalArgumentException("Unsupported json_field_naming: " + value); + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/checker/ObjectLocation.java b/teaql-core/src/main/java/io/teaql/core/checker/ObjectLocation.java index 66dc1e37..14a705b7 100644 --- a/teaql-core/src/main/java/io/teaql/core/checker/ObjectLocation.java +++ b/teaql-core/src/main/java/io/teaql/core/checker/ObjectLocation.java @@ -61,10 +61,31 @@ public String nativePath() { /** RFC 6901 JSON pointer using TeaQL's default lower-camel wire policy. */ public String instancePath() { - return render(true, true); + return instancePath(JsonFieldNamingProfile.CAMEL_CASE); + } + + /** RFC 6901 JSON pointer rendered with the model-selected wire profile. */ + public String instancePath(JsonFieldNamingProfile profile) { + return render(profile, true); + } + + public List segments() { + List result = new ArrayList<>(); + for (ObjectLocation current = this; current != null; current = current.getParent()) { + if (current instanceof HashLocation hash) { + result.add(0, WireLocationSegment.property(hash.getMember())); + } else if (current instanceof ArrayLocation array) { + result.add(0, WireLocationSegment.index(array.getIndex())); + } + } + return List.copyOf(result); } private String render(boolean lowerCamel, boolean pointer) { + return render(lowerCamel ? JsonFieldNamingProfile.CAMEL_CASE : null, pointer); + } + + private String render(JsonFieldNamingProfile profile, boolean pointer) { List locations = new ArrayList<>(); for (ObjectLocation current = this; current != null; current = current.getParent()) { locations.add(0, current); @@ -72,7 +93,7 @@ private String render(boolean lowerCamel, boolean pointer) { StringBuilder result = new StringBuilder(); for (ObjectLocation location : locations) { if (location instanceof HashLocation hash) { - String member = lowerCamel ? lowerCamel(hash.getMember()) : hash.getMember(); + String member = profile == null ? hash.getMember() : profile.render(hash.getMember()); if (pointer) { result.append('/').append(escapePointer(member)); } else { diff --git a/teaql-core/src/main/java/io/teaql/core/checker/WireCheckResult.java b/teaql-core/src/main/java/io/teaql/core/checker/WireCheckResult.java new file mode 100644 index 00000000..750bca99 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/checker/WireCheckResult.java @@ -0,0 +1,14 @@ +package io.teaql.core.checker; + +import java.util.List; + +/** Stable external projection of a Checker violation. */ +public record WireCheckResult( + String ruleId, + String entityType, + List location, + String instancePath, + String sourceInstancePath, + Object inputValue, + Object systemValue, + String message) {} diff --git a/teaql-core/src/main/java/io/teaql/core/checker/WireLocationSegment.java b/teaql-core/src/main/java/io/teaql/core/checker/WireLocationSegment.java new file mode 100644 index 00000000..46d7f2c4 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/checker/WireLocationSegment.java @@ -0,0 +1,12 @@ +package io.teaql.core.checker; + +/** Canonical KSML property or array-index segment used by wire violations. */ +public record WireLocationSegment(String kind, String name, Integer index) { + public static WireLocationSegment property(String name) { + return new WireLocationSegment("property", name, null); + } + + public static WireLocationSegment index(int index) { + return new WireLocationSegment("index", null, index); + } +} diff --git a/teaql-core/src/test/java/io/teaql/core/checker/ObjectLocationTest.java b/teaql-core/src/test/java/io/teaql/core/checker/ObjectLocationTest.java index 57f038bb..c90e3104 100644 --- a/teaql-core/src/test/java/io/teaql/core/checker/ObjectLocationTest.java +++ b/teaql-core/src/test/java/io/teaql/core/checker/ObjectLocationTest.java @@ -1,5 +1,6 @@ package io.teaql.core.checker; +import java.util.List; import org.junit.Test; import static org.junit.Assert.*; @@ -14,9 +15,25 @@ public void rendersCanonicalNativeAndExternalPaths() { assertEquals("order_items[2].user_url", location.modelPath()); assertEquals("orderItems[2].userUrl", location.nativePath()); assertEquals("/orderItems/2/userUrl", location.instancePath()); + assertEquals("/order_items/2/user_url", location.instancePath(JsonFieldNamingProfile.SNAKE_CASE)); + assertEquals("/OrderItems/2/UserUrl", location.instancePath(JsonFieldNamingProfile.PASCAL_CASE)); assertEquals("order_items[2].user_url", location.toString()); } + @Test + public void projectsCheckerResultWithProfileAndSubmittedAlias() { + CheckResult result = CheckResult.required(ObjectLocation.hashRoot("user_url")); + result.setRootType("customer_account"); + result.setSourceInstancePath("/user_url"); + + WireCheckResult wire = result.toWire(JsonFieldNamingProfile.CAMEL_CASE); + assertEquals("required", wire.ruleId()); + assertEquals("customer_account", wire.entityType()); + assertEquals(List.of(WireLocationSegment.property("user_url")), wire.location()); + assertEquals("/userUrl", wire.instancePath()); + assertEquals("/user_url", wire.sourceInstancePath()); + } + @Test public void escapesJsonPointerMembers() { assertEquals("/a~0~1b", ObjectLocation.hashRoot("a~/b").instancePath()); diff --git a/teaql-query-json/src/main/java/io/teaql/query/json/DynamicSearchHelper.java b/teaql-query-json/src/main/java/io/teaql/query/json/DynamicSearchHelper.java index 0f72a62a..20c7e894 100644 --- a/teaql-query-json/src/main/java/io/teaql/query/json/DynamicSearchHelper.java +++ b/teaql-query-json/src/main/java/io/teaql/query/json/DynamicSearchHelper.java @@ -7,11 +7,13 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Level; import java.util.logging.Logger; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.node.JsonNodeType; import io.teaql.core.BaseRequest; @@ -75,6 +77,8 @@ public class DynamicSearchHelper { public static final String WARNINGS_EXTENSION = "teaql.dynamicSearch.warnings"; private static final Logger LOGGER = Logger.getLogger(DynamicSearchHelper.class.getName()); + private static final Set SEARCH_CONTROLS = + Set.of("_orderBy", "_start", "_size", "_page", "_pageSize"); public static List warningsOf(BaseRequest request) { Object warnings = request.getExtensions().get(WARNINGS_EXTENSION); @@ -122,22 +126,84 @@ protected void warnUnknownField(BaseRequest request, String clause, String field protected static JsonNode jsonFromString(String jsonExpr) { try { - ObjectMapper objectMapper = new ObjectMapper(); + ObjectMapper objectMapper = new ObjectMapper() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); JsonNode jsonNode = objectMapper.readTree(jsonExpr); + if (jsonNode == null || !jsonNode.isObject()) { + throw new IllegalArgumentException("Dynamic search must be a JSON object"); + } return jsonNode; } catch (Exception e) { - throw new IllegalArgumentException("Input JSON format error: " + jsonExpr); + throw new IllegalArgumentException("Dynamic search requires a valid JSON object"); } } public void mergeClauses(BaseRequest baseRequest, JsonNode jsonExpr) { + if (jsonExpr == null || !jsonExpr.isObject()) { + throw new IllegalArgumentException("Dynamic search must be a JSON object"); + } + // Validate control keys before adding any clauses. Trusted context is not search input. + jsonExpr.fieldNames().forEachRemaining(name -> { + if (name.startsWith("_") && !SEARCH_CONTROLS.contains(name)) { + throw new IllegalArgumentException("Unsupported dynamic search control: " + name); + } + if (SEARCH_CONTROLS.contains(name) && !"_orderBy".equals(name)) { + JsonNode value = jsonExpr.get(name); + int minimum = "_page".equals(name) || "_pageSize".equals(name) ? 1 : 0; + if (!value.isIntegralNumber() || !value.canConvertToInt() || value.intValue() < minimum) { + throw new IllegalArgumentException("Invalid dynamic search paging control: " + name); + } + if (("_size".equals(name) || "_pageSize".equals(name)) + && value.intValue() > baseRequest.hardLimit()) { + throw new IllegalArgumentException("Dynamic search page size exceeds hard limit"); + } + } + }); + // Validate syntax for the whole payload before mutating requests or + // emitting drift warnings, including values on unknown fields. + jsonExpr.fields().forEachRemaining(field -> { + if (!SEARCH_CONTROLS.contains(field.getKey())) { + validateValueSyntax(field.getValue()); + } + }); + validateOrderSyntax(jsonExpr.get("_orderBy")); this.addJsonFilter(baseRequest, jsonExpr); // where name='x' this.addJsonOrderBy(baseRequest, jsonExpr); // order by age this.addJsonLimiter(baseRequest, jsonExpr); // limit 0,1000 this.addJsonPager(baseRequest, jsonExpr); } + private void validateValueSyntax(JsonNode value) { + if (value.isArray()) { + value.forEach(this::validateValueSyntax); + } + else if (value.isObject()) { + // Existing Java grammar accepts reference objects, not arbitrary + // operator objects. Keep validation identical to native conversion. + unwrapValue(value); + } + } + + private void validateOrderSyntax(JsonNode order) { + if (order == null) return; + if (order.isTextual() && !order.textValue().isBlank()) return; + if (order.isArray()) { + order.forEach(this::validateSingleOrderSyntax); + return; + } + validateSingleOrderSyntax(order); + } + + private void validateSingleOrderSyntax(JsonNode order) { + if (!order.isObject() || !order.has("field") || !order.get("field").isTextual() + || order.get("field").textValue().isBlank() + || !order.has("useAsc") || !order.get("useAsc").isBoolean()) { + throw new IllegalArgumentException( + "Dynamic search order requires a field and boolean useAsc"); + } + } + protected void addJsonPager(BaseRequest baseRequest, JsonNode jsonNode) { if (jsonNode == null) { @@ -368,9 +434,17 @@ protected Object unwrapValue(JsonNode node) { } if (node.isObject()) { if (node.get("id") == null) { - return null; + throw new IllegalArgumentException("Unsupported dynamic search value or operator object"); } - return node.get("id").asLong(); + JsonNode id = node.get("id"); + if (id.isIntegralNumber() && id.canConvertToLong()) return id.longValue(); + if (id.isTextual()) { + try { return Long.parseLong(id.textValue()); } + catch (NumberFormatException invalid) { + throw new IllegalArgumentException("Dynamic search reference id must be an integer"); + } + } + throw new IllegalArgumentException("Dynamic search reference id must be an integer"); } return node.asText().trim(); diff --git a/teaql-query-json/src/main/java/io/teaql/query/json/LocalDynamicSearch.java b/teaql-query-json/src/main/java/io/teaql/query/json/LocalDynamicSearch.java new file mode 100644 index 00000000..9c533a5c --- /dev/null +++ b/teaql-query-json/src/main/java/io/teaql/query/json/LocalDynamicSearch.java @@ -0,0 +1,202 @@ +package io.teaql.query.json; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.logging.Logger; +import io.teaql.core.BaseRequest; +import io.teaql.core.OrderBy; +import io.teaql.core.SearchCriteria; + +/** Typed local UI-search envelope. Trusted metadata is never read from client input. + * This does not change the legacy Java search grammar or the strict TFP decoder. + */ +public final class LocalDynamicSearch { + private LocalDynamicSearch() {} + + public record Model(Map fields, Map relations) { + public Model { fields = Map.copyOf(fields); relations = Map.copyOf(relations); } + } + public record Warning(String entity, String clause, String fieldPath) { + public String code() { return DynamicSearchWarning.UNKNOWN_FIELD; } + @com.fasterxml.jackson.annotation.JsonProperty("code") + public String getCode() { return code(); } + } + public record Filter(String fieldPath, String operator, JsonNode value) {} + public record Order(String fieldPath, String direction) {} + public record Result(List filters, List orders, List warnings) { + public Result { + filters = List.copyOf(filters); orders = List.copyOf(orders); warnings = List.copyOf(warnings); + } + } + + private static final Logger LOG = Logger.getLogger(LocalDynamicSearch.class.getName()); + private static final Set OPERATORS = Set.of( + "$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in", "$notIn", "$contains"); + private static final ObjectMapper JSON = new ObjectMapper() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); + + /** Apply to a caller-owned Java request only after every native binding succeeds. + * Bindings must be pure: construct criteria/orders without modifying the request, + * and enforce authorization inside any related query they construct. + * Paging, hard limits, projection, intent and existing filters are not replaced. + */ + public static > Result merge(T request, String source, + Map models, Function filterBinding, + Function orderBinding, Consumer warn) { + Result normalized = normalize(source, request.getTypeName(), models, ignored -> {}); + List criteria = new ArrayList<>(); + if (request.getSearchCriteria() != null) criteria.add(request.getSearchCriteria()); + for (Filter filter : normalized.filters()) { + SearchCriteria bound = filterBinding.apply(filter); + if (bound == null) throw invalid("Invalid trusted filter binding"); + criteria.add(bound); + } + List orders = new ArrayList<>(request.getOrderBy().getOrderBys()); + for (Order order : normalized.orders()) { + OrderBy bound = orderBinding.apply(order); + if (bound == null) throw invalid("Invalid trusted order binding"); + orders.add(bound); + } + // Do not append into an existing mutable AND or ordering list: another + // request can still own those nodes. Prepare replacements first. + SearchCriteria combined = criteria.isEmpty() ? null : criteria.size() == 1 ? criteria.get(0) + : SearchCriteria.and(criteria.toArray(SearchCriteria[]::new)); + request.replaceSearchCriteria(combined); + request.getOrderBy().setOrderBys(orders); + emit(normalized.warnings(), warn); + return normalized; + } + + public static Result normalize(String source, String entity, Map models, + Consumer warn) { + return normalize(source, entity, models, warn, 100); + } + + public static Result normalize(String source, String entity, Map models, + Consumer warn, int maxClauses) { + if (maxClauses < 1 || !models.containsKey(entity)) throw invalid("Invalid trusted search setup"); + JsonNode root; + try { root = JSON.readTree(source); } + catch (Exception error) { throw invalid("Dynamic search requires valid JSON"); } + checkObject(root, Set.of("filter", "orderBy")); + JsonNode filter = root.get("filter"), order = root.get("orderBy"); + if (filter != null && !filter.isObject() || order != null && !order.isArray()) + throw invalid("Invalid search filter or ordering"); + if ((filter == null ? 0 : filter.size()) + (order == null ? 0 : order.size()) > maxClauses) + throw invalid("Dynamic search exceeds clause limit"); + List filters = new ArrayList<>(); + List orders = new ArrayList<>(); + List warnings = new ArrayList<>(); + if (filter != null) { + var fields = filter.fields(); + while (fields.hasNext()) { + var field = fields.next(); + String op = "$eq"; + JsonNode value = field.getValue(); + if (value.isObject()) { + if (value.size() != 1) throw invalid("Malformed search operator"); + var operation = value.fields().next(); + op = operation.getKey(); value = operation.getValue(); + if (!OPERATORS.contains(op)) throw invalid("Unsupported search operator"); + } + boolean list = op.equals("$in") || op.equals("$notIn"); + if (list && (!value.isArray() || value.size() > 1000)) + throw invalid("Invalid or oversized search value list"); + String type = fieldType(field.getKey(), entity, models); + if (type == null) { + warnings.add(new Warning(entity, "FILTER", field.getKey())); + continue; + } + if (op.equals("$contains") && !type.equals("string")) + throw invalid("String operator requires a string field"); + if (value.isArray()) { + if (!list) throw invalid("Unexpected search value list"); + for (JsonNode item : value) scalar(item, type); + } else scalar(value, type); + filters.add(new Filter(field.getKey(), op, value.deepCopy())); + } + } + if (order != null) for (JsonNode item : order) { + checkObject(item, Set.of("field", "direction")); + if (!item.has("field") || !item.get("field").isTextual() + || !item.has("direction") || !item.get("direction").isTextual() + || !Set.of("asc", "desc").contains(item.get("direction").textValue())) + throw invalid("Invalid dynamic search ordering"); + String path = item.get("field").textValue(); + if (fieldType(path, entity, models) == null) warnings.add(new Warning(entity, "ORDER_BY", path)); + else orders.add(new Order(path, item.get("direction").textValue())); + } + Result result = new Result(filters, orders, warnings); + emit(warnings, warn); + return result; + } + + private static void emit(List warnings, Consumer warn) { + for (Warning warning : warnings) { + if (warn != null) warn.accept(warning); + else LOG.warning(() -> warning.code() + " entity=" + warning.entity() + + " clause=" + warning.clause() + " fieldPath=" + warning.fieldPath()); + } + } + + private static void checkObject(JsonNode value, Set keys) { + if (value == null || !value.isObject()) throw invalid("Expected search object"); + value.fieldNames().forEachRemaining(key -> { + if (!keys.contains(key)) throw invalid("Unsupported dynamic search control"); + }); + } + + private static String fieldType(String path, String entity, Map models) { + String[] parts = path.split("\\.", -1); + if (parts.length > 16) throw invalid("Invalid search field path"); + for (String part : parts) if (part.isEmpty() || part.startsWith("$") + || Set.of("__proto__", "prototype", "constructor").contains(part)) + throw invalid("Invalid search field path"); + Model model = models.get(entity); + for (int i = 0; i < parts.length - 1; i++) { + String target = model.relations().get(parts[i]); + if (target == null) return null; + model = models.get(target); + if (model == null) throw invalid("Invalid trusted relation metadata"); + } + return model.fields().get(parts[parts.length - 1]); + } + + private static void scalar(JsonNode value, String type) { + if (value.isNull()) return; + boolean number = value.isNumber() && Double.isFinite(value.doubleValue()); + String text = value.isTextual() ? value.textValue() : null; + boolean valid = switch (type) { + case "integer", "timestamp" -> number + && value.decimalValue().abs().compareTo(new java.math.BigDecimal("9007199254740991")) <= 0 + && value.decimalValue().stripTrailingZeros().scale() <= 0; + case "number" -> number; + case "boolean" -> value.isBoolean(); + case "string" -> text != null; + case "decimal" -> number || text != null && text.matches("[+-]?[0-9]+(?:\\.[0-9]+)?"); + case "date" -> validDate(text); + default -> false; + }; + if (!valid) throw invalid("Invalid value for known search field"); + } + + private static boolean validDate(String value) { + if (value == null || !value.matches("[0-9]{4}-[0-9]{2}-[0-9]{2}")) return false; + try { return LocalDate.parse(value).getYear() >= 1; } + catch (DateTimeParseException invalid) { return false; } + } + + private static IllegalArgumentException invalid(String message) { + return new IllegalArgumentException(message); + } +} diff --git a/teaql-query-json/src/test/java/io/teaql/query/json/DynamicSearchHelperTest.java b/teaql-query-json/src/test/java/io/teaql/query/json/DynamicSearchHelperTest.java index fb9c5472..a1fae953 100644 --- a/teaql-query-json/src/test/java/io/teaql/query/json/DynamicSearchHelperTest.java +++ b/teaql-query-json/src/test/java/io/teaql/query/json/DynamicSearchHelperTest.java @@ -2,6 +2,9 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertSame; +import com.fasterxml.jackson.databind.ObjectMapper; import io.teaql.core.BaseRequest; import io.teaql.core.Entity; @@ -10,6 +13,98 @@ public class DynamicSearchHelperTest { + @Test + public void invalidPayloadPreservesExistingQueryAndWarnings() { + StubRequest request = new StubRequest("Order"); + DynamicSearchHelper helper = new DynamicSearchHelper(); + helper.mergeClauses(request, DynamicSearchHelper.jsonFromString( + "{\"name\":\"trusted\",\"old_field\":1,\"_size\":10," + + "\"_orderBy\":[{\"field\":\"id\",\"useAsc\":false}]}")); + Object originalCriteria = request.getSearchCriteria(); + java.util.List originalOrders = new java.util.ArrayList<>(request.getOrderBy().getOrderBys()); + int originalWarnings = DynamicSearchHelper.warningsOf(request).size(); + assertThrows(IllegalArgumentException.class, () -> helper.mergeClauses(request, + DynamicSearchHelper.jsonFromString( + "{\"name\":\"new\",\"removed\":1,\"id\":{\"$invalid\":1},\"_size\":20}"))); + assertSame(originalCriteria, request.getSearchCriteria()); + assertEquals(originalOrders, request.getOrderBy().getOrderBys()); + assertEquals(originalWarnings, DynamicSearchHelper.warningsOf(request).size()); + assertEquals(10, request.getSize()); + } + + @Test + public void invalidLaterClauseDoesNotLeaveFiltersOrWarnings() { + for (String tail : new String[] {"\"id\":{\"$invalid\":1}", + "\"_orderBy\":42", "\"_orderBy\":[{\"field\":\"id\"}]", + "\"_orderBy\":[{\"field\":\"id\",\"useAsc\":\"false\"}]"}) { + StubRequest request = new StubRequest("Order"); + assertThrows(IllegalArgumentException.class, () -> new DynamicSearchHelper().mergeClauses( + request, DynamicSearchHelper.jsonFromString( + "{\"name\":\"valid\",\"removed\":\"SECRET_VALUE\"," + tail + "}"))); + assertTrue(request.getSearchCriteria() == null); + assertTrue(request.getOrderBy().isEmpty()); + assertTrue(DynamicSearchHelper.warningsOf(request).isEmpty()); + } + } + + @Test + public void invalidPagingCannotChangeTrustedHardLimitOrFilters() { + for (String paging : new String[] {"\"_size\":10001", "\"_size\":-1", + "\"_pageSize\":0", "\"_start\":1.5", "\"_size\":\"10\""}) { + StubRequest request = new StubRequest("Order"); + int hardLimit = request.hardLimit(); + assertThrows(IllegalArgumentException.class, () -> new DynamicSearchHelper().mergeClauses( + request, DynamicSearchHelper.jsonFromString("{\"name\":\"valid\"," + paging + "}"))); + assertEquals(hardLimit, request.hardLimit()); + assertTrue(request.getSearchCriteria() == null); + } + } + + @Test + public void unsupportedOperatorAndBadReferenceIdAreNotNullPredicates() { + for (String value : new String[] {"{\"$invalid\":1}", "{\"id\":\"not-an-id\"}"}) { + StubRequest request = new StubRequest("Order"); + assertThrows(IllegalArgumentException.class, () -> new DynamicSearchHelper().mergeClauses( + request, DynamicSearchHelper.jsonFromString("{\"name\":" + value + "}"))); + assertTrue(request.getSearchCriteria() == null); + assertTrue(DynamicSearchHelper.warningsOf(request).isEmpty()); + } + } + + @Test + public void malformedAndNonObjectInputRemainsFatalWithoutEchoingValues() { + for (String input : new String[] {"{secret", "[]", "null", "42", "\"secret\"", "{} {}"}) { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> DynamicSearchHelper.jsonFromString(input)); + assertTrue(!error.getMessage().contains("secret")); + } + } + + @Test + public void reservedContextControlsFailBeforeChangingRequest() { + for (String control : new String[] {"_tenant", "_principal", "_policy", "_audit", "_hardLimit"}) { + StubRequest request = new StubRequest("Order"); + assertThrows(IllegalArgumentException.class, () -> new DynamicSearchHelper().mergeClauses( + request, DynamicSearchHelper.jsonFromString( + "{\"name\":\"valid\",\"" + control + "\":\"secret\"}"))); + assertTrue(request.getSearchCriteria() == null); + assertTrue(DynamicSearchHelper.warningsOf(request).isEmpty()); + } + } + + @Test + public void staleClauseKeepsValidSiblingAndWarningOmitsItsValue() throws Exception { + StubRequest request = new StubRequest("Order"); + DynamicSearchHelper helper = new DynamicSearchHelper(); + helper.mergeClauses(request, DynamicSearchHelper.jsonFromString("{\"name\":\"valid\"}")); + Object valid = request.getSearchCriteria(); + helper.mergeClauses(request, DynamicSearchHelper.jsonFromString("{\"removed\":\"SECRET_VALUE\"}")); + assertSame(valid, request.getSearchCriteria()); + String warnings = new ObjectMapper().writeValueAsString(DynamicSearchHelper.warningsOf(request)); + assertTrue(!warnings.contains("SECRET_VALUE")); + assertWarning(request, "FILTER", "removed"); + } + @Test public void unknownTopLevelFilterIsIgnoredAndRecorded() { StubRequest request = new StubRequest("Order"); @@ -85,6 +180,11 @@ public boolean isOneOfSelfField(String propertyName) { return "id".equals(propertyName) || "name".equals(propertyName); } + @Override + public boolean isDateTimeField(String fieldName) { + return false; + } + @Override public Optional subRequestOfFieldName(String fieldName) { if ("customer".equals(fieldName) && child != null) { diff --git a/teaql-query-json/src/test/java/io/teaql/query/json/LocalDynamicSearchTest.java b/teaql-query-json/src/test/java/io/teaql/query/json/LocalDynamicSearchTest.java new file mode 100644 index 00000000..4b753161 --- /dev/null +++ b/teaql-query-json/src/test/java/io/teaql/query/json/LocalDynamicSearchTest.java @@ -0,0 +1,116 @@ +package io.teaql.query.json; + +import static org.junit.Assert.*; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.Test; +import io.teaql.core.BaseRequest; +import io.teaql.core.Entity; +import io.teaql.core.OrderBy; +import io.teaql.core.SearchCriteria; +import io.teaql.core.criteria.AND; +import io.teaql.core.criteria.Operator; + +public class LocalDynamicSearchTest { + private static final class SchoolRequest extends BaseRequest { + SchoolRequest() { super(Entity.class); } + @Override public String getTypeName() { return "School"; } + } + + @Test public void mergePreservesBasisAndDoesNotModifySharedAnd() { + SchoolRequest request = new SchoolRequest(); + AND trusted = new AND(request.createBasicSearchCriteria("id", Operator.GREATER_THAN, 0L)); + request.replaceSearchCriteria(trusted); + request.addOrderBy("id", false); + request.setSize(7); + int hardLimit = request.hardLimit(); + var previousOrders = request.getOrderBy().getOrderBys(); + SearchCriteria bound = request.createBasicSearchCriteria("name", Operator.EQUAL, "x"); + var result = LocalDynamicSearch.merge(request, + "{\"filter\":{\"name\":\"x\",\"removed\":1},\"orderBy\":[{\"field\":\"name\",\"direction\":\"asc\"}]}", + MODELS, filter -> bound, order -> new OrderBy(order.fieldPath(), "ASC"), warning -> {}); + AND combined = (AND) request.getSearchCriteria(); + assertEquals(2, combined.getExpressions().size()); + assertSame(trusted, combined.getExpressions().get(0)); + assertSame(bound, combined.getExpressions().get(1)); + assertEquals(1, trusted.getExpressions().size()); + assertEquals(1, previousOrders.size()); + assertEquals(2, request.getOrderBy().getOrderBys().size()); + assertEquals(7, request.getSize()); + assertEquals(hardLimit, request.hardLimit()); + assertEquals(1, result.warnings().size()); + } + + @Test public void bindingFailureDoesNotChangeRequestOrEmitWarnings() { + SchoolRequest request = new SchoolRequest(); + SearchCriteria original = request.createBasicSearchCriteria("id", Operator.GREATER_THAN, 0L); + request.replaceSearchCriteria(original); + var originalOrders = request.getOrderBy().getOrderBys(); + var warnings = new ArrayList(); + assertThrows(IllegalStateException.class, () -> LocalDynamicSearch.merge(request, + "{\"filter\":{\"name\":\"x\",\"removed\":1},\"orderBy\":[{\"field\":\"id\",\"direction\":\"asc\"}]}", + MODELS, filter -> request.createBasicSearchCriteria("name", Operator.EQUAL, "x"), + order -> { throw new IllegalStateException("binding failed"); }, warnings::add)); + assertSame(original, request.getSearchCriteria()); + assertSame(originalOrders, request.getOrderBy().getOrderBys()); + assertTrue(warnings.isEmpty()); + } + private static final Map MODELS = Map.of("School", + new LocalDynamicSearch.Model(Map.of("id", "integer", "name", "string", + "amount", "decimal", "date", "date"), Map.of())); + + @Test public void preservesValidSiblingsAndExactDecimal() { + var warnings = new ArrayList(); + var result = LocalDynamicSearch.normalize( + "{\"filter\":{\"removed\":\"SECRET\",\"id\":1.0,\"amount\":\"12345678901234567890.123\"}}", + "School", MODELS, warnings::add); + assertEquals(2, result.filters().size()); + assertEquals("12345678901234567890.123", result.filters().get(1).value().textValue()); + assertEquals(1, warnings.size()); + assertEquals("removed", warnings.get(0).fieldPath()); + } + + @Test public void fatalSiblingsEmitNoWarnings() { + for (String value : new String[] {"true", "1.5", "9007199254740992", "{\"$invalid\":1}"}) { + var warnings = new ArrayList(); + assertThrows(IllegalArgumentException.class, () -> LocalDynamicSearch.normalize( + "{\"filter\":{\"removed\":\"SECRET\",\"id\":" + value + "}}", + "School", MODELS, warnings::add)); + assertTrue(warnings.isEmpty()); + } + } + + @Test public void resourceLimitsAndMalformedPathsRemainFatal() { + assertThrows(IllegalArgumentException.class, () -> LocalDynamicSearch.normalize( + "{\"filter\":{\"id\":1,\"name\":\"x\"}}", "School", MODELS, null, 1)); + assertThrows(IllegalArgumentException.class, () -> LocalDynamicSearch.normalize( + "{\"filter\":{\"a..b\":1}}", "School", MODELS, null)); + assertThrows(IllegalArgumentException.class, () -> LocalDynamicSearch.normalize( + "{\"filter\":{\"date\":\"0000-01-01\"}}", "School", MODELS, null)); + assertThrows(IllegalArgumentException.class, () -> LocalDynamicSearch.normalize( + "{\"filter\":{\"amount\":\"١٢\"}}", "School", MODELS, null)); + } + + @Test public void warningLoggingIsEnabledWithoutCallback() { + Logger logger = Logger.getLogger(LocalDynamicSearch.class.getName()); + AtomicInteger count = new AtomicInteger(); + Handler handler = new Handler() { + public void publish(LogRecord record) { + assertFalse(record.getMessage().contains("SECRET")); + assertTrue(record.getMessage().contains("DYNAMIC_SEARCH_UNKNOWN_FIELD")); + count.incrementAndGet(); + } + public void flush() {} + public void close() {} + }; + logger.addHandler(handler); + try { + LocalDynamicSearch.normalize("{\"filter\":{\"removed\":\"SECRET\"}}", "School", MODELS, null); + assertEquals(1, count.get()); + } finally { logger.removeHandler(handler); } + } +} diff --git a/teaql-sqlite/pom.xml b/teaql-sqlite/pom.xml index b915ef8b..e6772a72 100644 --- a/teaql-sqlite/pom.xml +++ b/teaql-sqlite/pom.xml @@ -34,6 +34,12 @@ ${project.version} + + io.teaql + teaql-query-json + ${project.version} + test + junit junit @@ -55,6 +61,8 @@ --add-reads io.teaql.sqlite=io.teaql.runtime --add-reads io.teaql.sqlite=java.sql + --add-reads io.teaql.sqlite=io.teaql.query.json + --add-reads io.teaql.sqlite=com.fasterxml.jackson.databind --add-opens io.teaql.sqlite/io.teaql.sqlite=io.teaql.utils --add-opens io.teaql.core/io.teaql.core=io.teaql.utils diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteIntegrationTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteIntegrationTest.java index 3dec639c..1475420f 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteIntegrationTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteIntegrationTest.java @@ -35,6 +35,37 @@ public class SqliteIntegrationTest { + @Test + public void localDynamicSearchPreservesTrustedScopeInSqlite() { + for (String scope : new String[] {"SEARCH-SCOPE-A", "SEARCH-SCOPE-B"}) { + for (int i = 0; i < 3; i++) { + Task task = new Task(); + task.updateTitle("dynamic-search-match"); + task.updateStatus(scope); + task.auditAs("seed scoped dynamic search counterexamples").save(context); + } + } + TaskRequest request = new TaskRequest().filterByStatus("SEARCH-SCOPE-A"); + request.setSize(2); + request.addOrderBy("id", false); + int originalHardLimit = request.hardLimit(); + var warnings = new ArrayList(); + var models = java.util.Map.of("Task", new io.teaql.query.json.LocalDynamicSearch.Model( + java.util.Map.of("id", "integer", "title", "string", "status", "string"), java.util.Map.of())); + io.teaql.query.json.LocalDynamicSearch.merge(request, + "{\"filter\":{\"title\":\"dynamic-search-match\",\"removed\":\"SECRET\"}," + + "\"orderBy\":[{\"field\":\"removed\",\"direction\":\"asc\"}]}", + models, filter -> request.createBasicSearchCriteria(filter.fieldPath(), Operator.EQUAL, filter.value().textValue()), + order -> new OrderBy(order.fieldPath(), order.direction().toUpperCase(java.util.Locale.ROOT)), warnings::add); + SmartList rows = request.comment("what: scoped dynamic search") + .purpose("why: verify unknown clauses preserve server scope").executeForList(context); + assertEquals(2, rows.size()); + assertTrue(rows.stream().allMatch(task -> "SEARCH-SCOPE-A".equals(task.getStatus()))); + assertTrue(rows.get(0).getId() > rows.get(1).getId()); + assertEquals(originalHardLimit, request.hardLimit()); + assertEquals(2, warnings.size()); + } + private static UserContext context; private static TeaQLRuntime runtime; private static JdbcSqlExecutor jdbcSqlExecutor; diff --git a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/NormalizedWireObject.java b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/NormalizedWireObject.java new file mode 100644 index 00000000..05d73861 --- /dev/null +++ b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/NormalizedWireObject.java @@ -0,0 +1,7 @@ +package io.teaql.tfp; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.Map; + +/** Canonical payload plus the exact accepted JSON pointer used for each KSML field. */ +public record NormalizedWireObject(ObjectNode values, Map sourceInstancePaths) {} diff --git a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java index 2d70ce18..1f2e4c20 100644 --- a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java +++ b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java @@ -294,8 +294,13 @@ private Map handleMutationActive(UserContext context, TrustedFed if (writable == null || !entityPayload.isObject()) { throw new TfpEndpointException("TFP_INVALID_REQUEST", "Invalid mutation payload"); } + ObjectNode normalizedPayload = (ObjectNode) entityPayload; + WireEntityMetadata metadata = trusted.wireMetadata(entityName); + if (metadata != null) { + normalizedPayload = WireFieldAdapter.normalize(normalizedPayload, metadata).values(); + } ObjectNode mappedPayload = objectMapper.createObjectNode(); - entityPayload.fields().forEachRemaining(entry -> { + normalizedPayload.fields().forEachRemaining(entry -> { String mapped = writable.get(entry.getKey()); if (mapped == null) throw new TfpEndpointException("TFP_FORBIDDEN_FIELD", "Mutation field is not allowed: " + entry.getKey()); diff --git a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TrustedFederalContext.java b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TrustedFederalContext.java index 95076870..8c136fa7 100644 --- a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TrustedFederalContext.java +++ b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TrustedFederalContext.java @@ -13,6 +13,7 @@ public final class TrustedFederalContext { private final Map> readableFields; private final Map> writableFields; private final Map> allowedActions; + private final Map wireMetadata; private final int maxPageSize; public TrustedFederalContext(String tenantField, Object tenantId, @@ -21,6 +22,17 @@ public TrustedFederalContext(String tenantField, Object tenantId, Map> readableFields, Map> writableFields, Map> allowedActions, int maxPageSize) { + this(tenantField, tenantId, authenticatedUser, approvedPurpose, allowedEntities, + readableFields, writableFields, allowedActions, maxPageSize, Map.of()); + } + + public TrustedFederalContext(String tenantField, Object tenantId, + String authenticatedUser, String approvedPurpose, + Set allowedEntities, + Map> readableFields, + Map> writableFields, + Map> allowedActions, int maxPageSize, + Map wireMetadata) { this.tenantField = tenantField; this.tenantId = tenantId; this.authenticatedUser = authenticatedUser; @@ -30,6 +42,7 @@ public TrustedFederalContext(String tenantField, Object tenantId, this.writableFields = Map.copyOf(writableFields); this.allowedActions = Map.copyOf(allowedActions); this.maxPageSize = maxPageSize; + this.wireMetadata = Map.copyOf(wireMetadata); } public String tenantField() { return tenantField; } @@ -37,8 +50,18 @@ public TrustedFederalContext(String tenantField, Object tenantId, public String authenticatedUser() { return authenticatedUser; } public String approvedPurpose() { return approvedPurpose; } public Set allowedEntities() { return allowedEntities; } - public Map readableFields(String entity) { return readableFields.get(entity); } - public Map writableFields(String entity) { return writableFields.get(entity); } + public Map readableFields(String entity) { + return effectiveFields(entity, readableFields.get(entity)); + } + public Map writableFields(String entity) { + return effectiveFields(entity, writableFields.get(entity)); + } public Set allowedActions(String entity) { return allowedActions.get(entity); } public int maxPageSize() { return maxPageSize; } + public WireEntityMetadata wireMetadata(String entity) { return wireMetadata.get(entity); } + + private Map effectiveFields(String entity, Map fields) { + WireEntityMetadata metadata = wireMetadata.get(entity); + return fields == null || metadata == null ? fields : metadata.acceptedPolicyMap(fields); + } } diff --git a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/WireEntityMetadata.java b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/WireEntityMetadata.java new file mode 100644 index 00000000..74c68743 --- /dev/null +++ b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/WireEntityMetadata.java @@ -0,0 +1,65 @@ +package io.teaql.tfp; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Generated, serializer-independent mapping between wire names and canonical KSML fields. */ +public final class WireEntityMetadata { + private final String entity; + private final Map acceptedToCanonical; + private final Map canonicalToWire; + + public WireEntityMetadata(String entity, Map canonicalToWire, + Map aliases) { + this.entity = entity; + this.canonicalToWire = Map.copyOf(canonicalToWire); + Map accepted = new LinkedHashMap<>(); + canonicalToWire.forEach((canonical, wire) -> { + register(accepted, canonical, canonical); + register(accepted, wire, canonical); + }); + aliases.forEach((alias, canonical) -> { + if (!canonicalToWire.containsKey(canonical)) { + throw new IllegalArgumentException("Unknown canonical field for alias: " + canonical); + } + register(accepted, alias, canonical); + }); + this.acceptedToCanonical = Map.copyOf(accepted); + } + + /** Converts dependency-free metadata emitted by GeneratedRuntimeModule. */ + public static Map fromGenerated( + Map> mappings, + Map> aliases) { + Map result = new LinkedHashMap<>(); + mappings.forEach((entity, fields) -> result.put(entity, + new WireEntityMetadata(entity, fields, aliases.getOrDefault(entity, Map.of())))); + return Map.copyOf(result); + } + + private static void register(Map accepted, String name, String canonical) { + String previous = accepted.putIfAbsent(name, canonical); + if (previous != null && !previous.equals(canonical)) { + throw new IllegalArgumentException("Wire field alias is ambiguous: " + name); + } + } + + public String entity() { return entity; } + + public String canonicalField(String submitted) { + return acceptedToCanonical.get(submitted); + } + + public String wireField(String canonical) { + return canonicalToWire.get(canonical); + } + + Map acceptedPolicyMap(Map canonicalPolicy) { + Map result = new LinkedHashMap<>(); + acceptedToCanonical.forEach((accepted, canonical) -> { + String internal = canonicalPolicy.get(canonical); + if (internal != null) result.put(accepted, internal); + }); + return Map.copyOf(result); + } +} diff --git a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/WireFieldAdapter.java b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/WireFieldAdapter.java new file mode 100644 index 00000000..fc6ef961 --- /dev/null +++ b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/WireFieldAdapter.java @@ -0,0 +1,62 @@ +package io.teaql.tfp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.LinkedHashMap; +import java.util.Map; +import io.teaql.core.checker.CheckResult; +import io.teaql.core.checker.WireLocationSegment; + +/** Strict boundary adapter shared by TFP mutation and generated HTTP input adapters. */ +public final class WireFieldAdapter { + private WireFieldAdapter() {} + + public static NormalizedWireObject normalize(ObjectNode submitted, WireEntityMetadata metadata) { + ObjectNode canonical = JsonNodeFactory.instance.objectNode(); + Map paths = new LinkedHashMap<>(); + submitted.fields().forEachRemaining(entry -> { + String canonicalName = metadata.canonicalField(entry.getKey()); + if (canonicalName == null) { + throw new TfpEndpointException("WIRE_UNKNOWN_FIELD", + "Unknown field at /" + escape(entry.getKey())); + } + if (canonical.has(canonicalName)) { + throw new TfpEndpointException("WIRE_FIELD_COLLISION", + "Multiple submitted fields resolve to " + canonicalName); + } + canonical.set(canonicalName, entry.getValue()); + paths.put(canonicalName, "/" + escape(entry.getKey())); + }); + return new NormalizedWireObject(canonical, Map.copyOf(paths)); + } + + public static ObjectNode encode(ObjectNode canonical, WireEntityMetadata metadata) { + ObjectNode wire = JsonNodeFactory.instance.objectNode(); + canonical.fields().forEachRemaining(entry -> { + String wireName = metadata.wireField(entry.getKey()); + if (wireName == null) { + throw new TfpEndpointException("WIRE_UNKNOWN_FIELD", + "Unknown canonical field: " + entry.getKey()); + } + wire.set(wireName, entry.getValue()); + }); + return wire; + } + + /** Adds the submitted alias path without changing the canonical checker location. */ + public static void retainSubmittedPaths(Iterable results, + NormalizedWireObject normalized) { + for (CheckResult result : results) { + if (result.getLocation() == null || result.getLocation().segments().isEmpty()) continue; + WireLocationSegment first = result.getLocation().segments().get(0); + if (!"property".equals(first.kind())) continue; + String path = normalized.sourceInstancePaths().get(first.name()); + if (path != null) result.setSourceInstancePath(path); + } + } + + private static String escape(String value) { + return value.replace("~", "~0").replace("/", "~1"); + } +} diff --git a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/WireFieldAdapterTest.java b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/WireFieldAdapterTest.java new file mode 100644 index 00000000..8c6c3c14 --- /dev/null +++ b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/WireFieldAdapterTest.java @@ -0,0 +1,61 @@ +package io.teaql.tfp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.Map; +import org.junit.Test; +import io.teaql.core.checker.CheckResult; +import io.teaql.core.checker.JsonFieldNamingProfile; +import io.teaql.core.checker.ObjectLocation; + +public class WireFieldAdapterTest { + private final ObjectMapper json = new ObjectMapper(); + private final WireEntityMetadata metadata = new WireEntityMetadata("School", Map.of( + "user_url", "userUrl", "school_type", "schoolType"), + Map.of("legacyUrl", "user_url")); + + @Test + public void matchesTypescriptFixtureAndRetainsSubmittedPath() throws Exception { + ObjectNode input = (ObjectNode) json.readTree( + "{\"legacyUrl\":\"https://teaql.io\",\"schoolType\":1001}"); + NormalizedWireObject normalized = WireFieldAdapter.normalize(input, metadata); + assertEquals("https://teaql.io", normalized.values().path("user_url").asText()); + assertEquals(1001, normalized.values().path("school_type").asInt()); + assertEquals("/legacyUrl", normalized.sourceInstancePaths().get("user_url")); + assertEquals("/schoolType", normalized.sourceInstancePaths().get("school_type")); + assertEquals("userUrl", metadata.wireField("user_url")); + CheckResult violation = CheckResult.required(ObjectLocation.hashRoot("user_url")); + WireFieldAdapter.retainSubmittedPaths(java.util.List.of(violation), normalized); + assertEquals("/legacyUrl", violation.getSourceInstancePath()); + } + + @Test + public void rejectsUnknownAndAliasCollisionBeforeSave() throws Exception { + TfpEndpointException unknown = assertThrows(TfpEndpointException.class, + () -> WireFieldAdapter.normalize((ObjectNode) json.readTree("{\"unknown\":1}"), metadata)); + assertEquals("WIRE_UNKNOWN_FIELD", unknown.getCode()); + TfpEndpointException collision = assertThrows(TfpEndpointException.class, + () -> WireFieldAdapter.normalize((ObjectNode) json.readTree( + "{\"userUrl\":\"a\",\"legacyUrl\":\"a\"}"), metadata)); + assertEquals("WIRE_FIELD_COLLISION", collision.getCode()); + } + + @Test + public void serializesCanonicalLocationAndSelectedWirePath() throws Exception { + CheckResult violation = CheckResult.required(ObjectLocation.hashRoot("school_type")); + violation.setRootType("School"); + violation.setSourceInstancePath("/legacySchoolType"); + + ObjectNode wire = (ObjectNode) json.valueToTree( + violation.toWire(JsonFieldNamingProfile.CAMEL_CASE)); + assertEquals("required", wire.path("ruleId").asText()); + assertEquals("School", wire.path("entityType").asText()); + assertEquals("property", wire.path("location").path(0).path("kind").asText()); + assertEquals("school_type", wire.path("location").path(0).path("name").asText()); + assertEquals("/schoolType", wire.path("instancePath").asText()); + assertEquals("/legacySchoolType", wire.path("sourceInstancePath").asText()); + } +}