Skip to content

Commit cf7d929

Browse files
prestoncabeclaude
andcommitted
Fix OpenAPI filter to read DMN files from classpath + add comprehensive tests
The DynamicDMNOpenAPIFilter was reading DMN files from the filesystem (src/main/resources/), which worked locally but failed in production (Docker/Cloud Run) where files are packaged in JARs. This caused the filter to fall back to "result" instead of "checkResult" for check endpoints. Changes: - Add readDMNFileContent() method that reads from classpath using ClassLoader.getResourceAsStream(), mirroring ModelRegistry's pattern - Update getOutputDecisionName() to use classpath-based reading - Add property-based test suite (11 tests) that dynamically discovers and validates all DMN endpoints by category - Tests verify: check endpoints have checkResult, benefit endpoints have checks/isEligible, examples match schemas, responses match OpenAPI specs The tests scale automatically as new DMN files are added, with no hardcoded endpoint paths. All tests pass in both dev and production builds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 0452a31 commit cf7d929

File tree

4 files changed

+419
-7
lines changed

4 files changed

+419
-7
lines changed

library-api/src/main/java/org/prestoncabe/api/DynamicDMNOpenAPIFilter.java

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import org.eclipse.microprofile.openapi.models.responses.APIResponses;
1717

1818
import javax.enterprise.inject.spi.CDI;
19+
import java.io.IOException;
20+
import java.io.InputStream;
1921
import java.util.HashSet;
2022
import java.util.Map;
2123
import java.util.Set;
@@ -449,23 +451,46 @@ private Schema createDMNContextSchema(ModelInfo model, String inputRef, String o
449451
return contextSchema;
450452
}
451453

454+
/**
455+
* Read DMN file content from classpath.
456+
* Works in both dev mode (filesystem) and production (JAR packaging).
457+
* Pattern mirrors ModelRegistry.scanDMNFiles() for consistency.
458+
*
459+
* @param relativePath path relative to classpath root (e.g., "checks/age/PersonMinAge")
460+
* @return DMN file content as string, or null if not found
461+
*/
462+
private String readDMNFileContent(String relativePath) {
463+
try {
464+
String resourcePath = relativePath + ".dmn";
465+
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
466+
467+
try (InputStream is = classLoader.getResourceAsStream(resourcePath)) {
468+
if (is == null) {
469+
LOG.warning("DMN file not found on classpath: " + resourcePath);
470+
return null;
471+
}
472+
return new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
473+
}
474+
} catch (IOException e) {
475+
LOG.warning("Error reading DMN file " + relativePath + ": " + e.getMessage());
476+
return null;
477+
}
478+
}
479+
452480
/**
453481
* Get the actual output decision name from the DMN file.
454482
* Parses the decision service definition to find the output decision's name attribute.
455483
*/
456484
private String getOutputDecisionName(ModelInfo model, String serviceName) {
457485
try {
458-
// Read the DMN file
459-
String dmnPath = "src/main/resources/" + model.getPath() + ".dmn";
460-
java.nio.file.Path path = java.nio.file.Paths.get(dmnPath);
486+
// Read the DMN file from classpath
487+
String dmnContent = readDMNFileContent(model.getPath());
461488

462-
if (!java.nio.file.Files.exists(path)) {
463-
LOG.warning("DMN file not found: " + dmnPath);
489+
if (dmnContent == null) {
490+
LOG.warning("DMN file not found for model: " + model.getModelName() + " at path: " + model.getPath());
464491
return "result"; // fallback
465492
}
466493

467-
String dmnContent = java.nio.file.Files.readString(path);
468-
469494
// Find the decision service with the given name
470495
String servicePattern = "name=\"" + serviceName + "\"";
471496
int serviceIndex = dmnContent.indexOf(servicePattern);
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package org.prestoncabe.api;
2+
3+
import io.quarkus.test.junit.QuarkusTest;
4+
import io.restassured.http.ContentType;
5+
import io.restassured.path.json.JsonPath;
6+
import io.restassured.response.Response;
7+
import org.junit.jupiter.api.Test;
8+
9+
import javax.inject.Inject;
10+
import java.util.List;
11+
import java.util.Map;
12+
import java.util.stream.Collectors;
13+
14+
import static io.restassured.RestAssured.given;
15+
import static org.hamcrest.Matchers.notNullValue;
16+
import static org.junit.jupiter.api.Assertions.*;
17+
18+
@QuarkusTest
19+
public class DynamicEndpointPatternTest {
20+
21+
@Inject
22+
ModelRegistry modelRegistry;
23+
24+
@Inject
25+
DMNSchemaResolver schemaResolver;
26+
27+
@Test
28+
public void testAllCheckEndpointsReturnCheckResult() {
29+
Map<String, ModelInfo> allModels = modelRegistry.getAllModels();
30+
31+
List<ModelInfo> checkModels = allModels.values().stream()
32+
.filter(model -> model.getPath().startsWith("checks/"))
33+
.filter(model -> model.getDecisionServices().contains(model.getModelName() + "Service"))
34+
.collect(Collectors.toList());
35+
36+
assertTrue(checkModels.size() > 0, "Should have at least one check model");
37+
38+
for (ModelInfo model : checkModels) {
39+
String serviceName = model.getModelName() + "Service";
40+
String inputRef = schemaResolver.findInputSchemaRef(model.getModelName(), serviceName);
41+
assertNotNull(inputRef, "Should find input schema for " + serviceName);
42+
43+
// Generate example request
44+
Map<String, Object> exampleRequest = schemaResolver.generateExample(inputRef);
45+
46+
String path = "/api/v1/" + model.getPath();
47+
48+
given()
49+
.contentType(ContentType.JSON)
50+
.body(exampleRequest)
51+
.when()
52+
.post(path)
53+
.then()
54+
.statusCode(200)
55+
.body("checkResult", notNullValue())
56+
.body("situation", notNullValue())
57+
.body("parameters", notNullValue());
58+
}
59+
}
60+
61+
@Test
62+
public void testAllBenefitEndpointsReturnExpectedStructure() {
63+
Map<String, ModelInfo> allModels = modelRegistry.getAllModels();
64+
65+
List<ModelInfo> benefitModels = allModels.values().stream()
66+
.filter(model -> model.getPath().startsWith("benefits/"))
67+
.filter(model -> model.getDecisionServices().contains(model.getModelName() + "Service"))
68+
.collect(Collectors.toList());
69+
70+
assertTrue(benefitModels.size() > 0, "Should have at least one benefit model");
71+
72+
for (ModelInfo model : benefitModels) {
73+
String serviceName = model.getModelName() + "Service";
74+
String inputRef = schemaResolver.findInputSchemaRef(model.getModelName(), serviceName);
75+
76+
if (inputRef != null) {
77+
Map<String, Object> exampleRequest = schemaResolver.generateExample(inputRef);
78+
String path = "/api/v1/" + model.getPath();
79+
80+
given()
81+
.contentType(ContentType.JSON)
82+
.body(exampleRequest)
83+
.when()
84+
.post(path)
85+
.then()
86+
.statusCode(200)
87+
.body("checks", notNullValue())
88+
.body("isEligible", notNullValue())
89+
.body("situation", notNullValue());
90+
}
91+
}
92+
}
93+
94+
@Test
95+
public void testActualResponsesMatchOpenAPIExamples() {
96+
// For each endpoint, verify actual response structure matches OpenAPI example structure
97+
Map<String, ModelInfo> allModels = modelRegistry.getAllModels();
98+
99+
List<ModelInfo> exposedModels = allModels.values().stream()
100+
.filter(model -> model.getPath().startsWith("checks/") || model.getPath().startsWith("benefits/"))
101+
.filter(model -> model.getDecisionServices().contains(model.getModelName() + "Service"))
102+
.collect(Collectors.toList());
103+
104+
JsonPath openApiSpec = given()
105+
.queryParam("format", "JSON")
106+
.when()
107+
.get("/q/openapi")
108+
.then()
109+
.extract()
110+
.jsonPath();
111+
112+
for (ModelInfo model : exposedModels) {
113+
String serviceName = model.getModelName() + "Service";
114+
String inputRef = schemaResolver.findInputSchemaRef(model.getModelName(), serviceName);
115+
116+
if (inputRef != null) {
117+
Map<String, Object> exampleRequest = schemaResolver.generateExample(inputRef);
118+
String path = "/api/v1/" + model.getPath();
119+
120+
// Get actual response
121+
Response response = given()
122+
.contentType(ContentType.JSON)
123+
.body(exampleRequest)
124+
.when()
125+
.post(path)
126+
.then()
127+
.extract()
128+
.response();
129+
130+
assertEquals(200, response.statusCode(), path + " should return 200");
131+
132+
Map<String, Object> actualResponse = response.as(Map.class);
133+
134+
// Get OpenAPI example
135+
String examplePath = "paths.'" + path + "'.post.responses.'200'.content.'application/json'.examples.'Example response'.value";
136+
Map<String, Object> openApiExample = openApiSpec.getMap(examplePath);
137+
138+
if (openApiExample != null) {
139+
// Verify same keys
140+
assertEquals(openApiExample.keySet(), actualResponse.keySet(),
141+
path + ": actual response keys should match OpenAPI example keys");
142+
}
143+
}
144+
}
145+
}
146+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package org.prestoncabe.api;
2+
3+
import io.quarkus.test.junit.QuarkusTest;
4+
import org.junit.jupiter.api.Test;
5+
6+
import javax.inject.Inject;
7+
import java.util.List;
8+
import java.util.Map;
9+
import java.util.stream.Collectors;
10+
11+
import static io.restassured.RestAssured.given;
12+
import static org.hamcrest.Matchers.hasKey;
13+
import static org.junit.jupiter.api.Assertions.*;
14+
15+
@QuarkusTest
16+
public class ModelDiscoveryTest {
17+
18+
@Inject
19+
ModelRegistry modelRegistry;
20+
21+
@Test
22+
public void testAllDMNModelsAreDiscovered() {
23+
Map<String, ModelInfo> models = modelRegistry.getAllModels();
24+
assertNotNull(models);
25+
assertTrue(models.size() > 0, "Should discover at least one DMN model");
26+
27+
// Log discovered models for debugging
28+
models.values().forEach(model -> {
29+
System.out.println("Discovered: " + model.getModelName() +
30+
" at " + model.getPath() +
31+
" with services: " + model.getDecisionServices());
32+
});
33+
}
34+
35+
@Test
36+
public void testAllModelsFollowNamingConvention() {
37+
Map<String, ModelInfo> allModels = modelRegistry.getAllModels();
38+
39+
for (ModelInfo model : allModels.values()) {
40+
// Models that are exposed via API should have {ModelName}Service
41+
String expectedService = model.getModelName() + "Service";
42+
43+
if (model.getDecisionServices().contains(expectedService)) {
44+
// This model should appear in OpenAPI
45+
String path = "/api/v1/" + model.getPath();
46+
47+
given()
48+
.queryParam("format", "JSON")
49+
.when()
50+
.get("/q/openapi")
51+
.then()
52+
.statusCode(200)
53+
.body("paths", hasKey(path));
54+
}
55+
}
56+
}
57+
58+
@Test
59+
public void testCheckModelsHaveCorrectCategory() {
60+
Map<String, ModelInfo> allModels = modelRegistry.getAllModels();
61+
62+
List<ModelInfo> checkModels = allModels.values().stream()
63+
.filter(model -> model.getPath().startsWith("checks/"))
64+
.collect(Collectors.toList());
65+
66+
for (ModelInfo model : checkModels) {
67+
String category = model.getCategory();
68+
assertTrue(category.endsWith("Checks"),
69+
"Check model " + model.getModelName() +
70+
" should have category ending with 'Checks', got: " + category);
71+
}
72+
}
73+
74+
@Test
75+
public void testBenefitModelsHaveCorrectCategory() {
76+
Map<String, ModelInfo> allModels = modelRegistry.getAllModels();
77+
78+
List<ModelInfo> benefitModels = allModels.values().stream()
79+
.filter(model -> model.getPath().startsWith("benefits/"))
80+
.collect(Collectors.toList());
81+
82+
for (ModelInfo model : benefitModels) {
83+
String category = model.getCategory();
84+
assertEquals("Benefits", category,
85+
"Benefit model " + model.getModelName() + " should have 'Benefits' category");
86+
}
87+
}
88+
}

0 commit comments

Comments
 (0)