Skip to content
Merged
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions examples/school-management/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@
</dependencyManagement>

<dependencies>
<dependency>
<groupId>io.teaql</groupId>
<artifactId>teaql-query-json</artifactId>
<version>${teaql.version}</version>
</dependency>
<dependency>
<groupId>io.teaql</groupId>
<artifactId>teaql-provider-spring-jdbc</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<School> 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.Warning>();
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<School> 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");
}
}
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>10.0.4</version>
<version>13.0.0</version>
<configuration>
<ossindexAnalyzerEnabled>false</ossindexAnalyzerEnabled>
</configuration>
Expand Down
23 changes: 23 additions & 0 deletions teaql-core/src/main/java/io/teaql/core/checker/CheckResult.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.teaql.core.checker;

import java.time.LocalDateTime;
import java.util.Locale;

public class CheckResult {
private RuleId ruleId;
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
25 changes: 23 additions & 2 deletions teaql-core/src/main/java/io/teaql/core/checker/ObjectLocation.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,39 @@ 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<WireLocationSegment> segments() {
List<WireLocationSegment> 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<ObjectLocation> locations = new ArrayList<>();
for (ObjectLocation current = this; current != null; current = current.getParent()) {
locations.add(0, current);
}
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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<WireLocationSegment> location,
String instancePath,
String sourceInstancePath,
Object inputValue,
Object systemValue,
String message) {}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.teaql.core.checker;

import java.util.List;
import org.junit.Test;
import static org.junit.Assert.*;

Expand All @@ -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());
Expand Down
Loading
Loading