diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/api/StructuredQuery.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/api/StructuredQuery.java index b5b380af..bb540911 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/query/api/StructuredQuery.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/api/StructuredQuery.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; import de.numcodex.feasibility_gui_backend.common.api.Criterion; +import de.numcodex.feasibility_gui_backend.common.api.TermCode; import de.numcodex.feasibility_gui_backend.query.api.validation.StructuredQueryValidation; import lombok.Builder; diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/api/ValidatedStructuredQuery.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/api/ValidatedStructuredQuery.java new file mode 100644 index 00000000..799185e7 --- /dev/null +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/api/ValidatedStructuredQuery.java @@ -0,0 +1,15 @@ +package de.numcodex.feasibility_gui_backend.query.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import de.numcodex.feasibility_gui_backend.common.api.Criterion; +import lombok.Builder; + +import java.util.List; + +@Builder +public record ValidatedStructuredQuery( + @JsonProperty StructuredQuery query, + @JsonProperty List invalidCriteria +) { + +} diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/api/validation/StructuredQueryValidator.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/api/validation/StructuredQueryValidator.java index 986514c3..116fd67b 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/query/api/validation/StructuredQueryValidator.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/api/validation/StructuredQueryValidator.java @@ -13,6 +13,8 @@ import jakarta.validation.ConstraintValidatorContext; import org.springframework.beans.factory.annotation.Qualifier; +import java.util.stream.Collectors; + /** * Validator for {@link StructuredQuery} that does an actual check based on a JSON schema. */ @@ -47,8 +49,11 @@ public boolean isValid(StructuredQuery structuredQuery, var jsonSubject = new JSONObject(jsonUtil.writeValueAsString(structuredQuery)); jsonSchema.validate(jsonSubject); return true; - } catch (ValidationException | JsonProcessingException e) { - log.debug("Structured query is invalid", e); + } catch (ValidationException e) { + log.error("Structured query is invalid: {}", e.getCausingExceptions().stream().map(Throwable::getMessage).collect(Collectors.joining(" ## "))); + return false; + } catch (JsonProcessingException jpe) { + log.debug("Could not process JSON", jpe); return false; } } diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/v3/QueryHandlerRestController.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/v3/QueryHandlerRestController.java index cfa6dfaa..a84f9a6a 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/query/v3/QueryHandlerRestController.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/v3/QueryHandlerRestController.java @@ -6,12 +6,7 @@ import de.numcodex.feasibility_gui_backend.query.QueryHandlerService; import de.numcodex.feasibility_gui_backend.query.QueryHandlerService.ResultDetail; import de.numcodex.feasibility_gui_backend.query.QueryNotFoundException; -import de.numcodex.feasibility_gui_backend.query.api.Query; -import de.numcodex.feasibility_gui_backend.query.api.QueryListEntry; -import de.numcodex.feasibility_gui_backend.query.api.QueryResult; -import de.numcodex.feasibility_gui_backend.query.api.QueryResultRateLimit; -import de.numcodex.feasibility_gui_backend.query.api.SavedQuery; -import de.numcodex.feasibility_gui_backend.query.api.StructuredQuery; +import de.numcodex.feasibility_gui_backend.query.api.*; import de.numcodex.feasibility_gui_backend.query.api.status.FeasibilityIssue; import de.numcodex.feasibility_gui_backend.query.api.status.FeasibilityIssues; import de.numcodex.feasibility_gui_backend.query.api.status.SavedQuerySlots; @@ -404,9 +399,14 @@ public ResponseEntity getQueryContent( } @PostMapping("/validate") - public ResponseEntity validateStructuredQuery( + public ResponseEntity validateStructuredQuery( @Valid @RequestBody StructuredQuery query) { - return new ResponseEntity<>(HttpStatus.NO_CONTENT); + var invalidCriteria = termCodeValidation.getInvalidCriteria(query); + var validatedQuery = ValidatedStructuredQuery.builder() + .query(query) + .invalidCriteria(invalidCriteria) + .build(); + return new ResponseEntity<>(validatedQuery, HttpStatus.OK); } private boolean hasAccess(Long queryId, Authentication authentication) { diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/terminology/validation/TermCodeValidation.java b/src/main/java/de/numcodex/feasibility_gui_backend/terminology/validation/TermCodeValidation.java index 245c2e27..48e21858 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/terminology/validation/TermCodeValidation.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/terminology/validation/TermCodeValidation.java @@ -62,4 +62,46 @@ public List getInvalidTermCodes(StructuredQuery structuredQuery) { return invalidTermCodes; } + + private Criterion removeFilters(Criterion in) { + return Criterion.builder() + .termCodes(in.termCodes()) + .context(in.context()) + .build(); + } + + public List getInvalidCriteria(StructuredQuery structuredQuery) { + var invalidCriteria = new ArrayList(); + + List> combinedCriteria; + + if (structuredQuery.exclusionCriteria() != null && !structuredQuery.exclusionCriteria().isEmpty()) { + combinedCriteria = Stream.of( + structuredQuery.inclusionCriteria(), + structuredQuery.exclusionCriteria()).flatMap( + Collection::stream).toList(); + } else { + combinedCriteria = structuredQuery.inclusionCriteria(); + } + + for (List criterionList : combinedCriteria) { + for (Criterion criterion : criterionList) { + if (criterion.context() == null) { + log.debug("Missing context"); + invalidCriteria.add(removeFilters(criterion)); + } + for (TermCode termCode : criterion.termCodes()) { + if (terminologyService.isExistingTermCode(termCode.system(), termCode.code(), termCode.version())) { + log.trace("termcode ok: {} - {} - {}", termCode.system(), termCode.code(), termCode.version()); + } else { + log.debug("termcode invalid: {} - {} - {}", termCode.system(), termCode.code(), + termCode.version()); + invalidCriteria.add(removeFilters(criterion)); + } + } + } + } + + return invalidCriteria; + } } diff --git a/src/main/resources/static/v3/api-docs/swagger.yaml b/src/main/resources/static/v3/api-docs/swagger.yaml index 98e88284..3790807d 100644 --- a/src/main/resources/static/v3/api-docs/swagger.yaml +++ b/src/main/resources/static/v3/api-docs/swagger.yaml @@ -109,6 +109,35 @@ paths: security: - feasibility_auth: - user + /query/validate: + post: + tags: + - query + - validation + summary: Validates a submitted (structured) query to check for schema violations or invalid termCodes + operationId: validateQuery + requestBody: + description: Structured query to validate + content: + application/json: + schema: + $ref: '#/components/schemas/StructuredQuery' + required: true + responses: + 200: + description: Query adheres to json schema. If invalid termCodes are present, they will be in the response. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ValidatedStructuredQuery' + 400: + description: Query does not adhere to json schema + content: { } + 401: + description: Unauthorized - please login first + content: { } /query/by-user/{userId}: get: tags: @@ -987,6 +1016,18 @@ components: type: array items: $ref: "#/components/schemas/CriterionList" + ValidatedStructuredQuery: + type: object + required: + - query + - invalidTerms + properties: + query: + $ref: "#/components/schemas/StructuredQuery" + invalidTerms: + type: array + items: + $ref: "#/components/schemas/TermCode" SavedQuery: type: object required: diff --git a/src/test/java/de/numcodex/feasibility_gui_backend/query/v3/QueryHandlerRestControllerIT.java b/src/test/java/de/numcodex/feasibility_gui_backend/query/v3/QueryHandlerRestControllerIT.java index 79dceaa0..5e9ddb12 100644 --- a/src/test/java/de/numcodex/feasibility_gui_backend/query/v3/QueryHandlerRestControllerIT.java +++ b/src/test/java/de/numcodex/feasibility_gui_backend/query/v3/QueryHandlerRestControllerIT.java @@ -184,7 +184,7 @@ public void testRunQueryEndpoint_FailsOnSoftQuotaExceeded() throws Exception { @Test @WithMockUser(roles = "FEASIBILITY_TEST_USER", username = "test") - public void testValidateQueryEndpoint_SucceedsOnValidQuery() throws Exception { + public void testValidate2QueryEndpoint_SucceedsOnValidQuery() throws Exception { StructuredQuery testQuery = createValidStructuredQuery(); doReturn(List.of()).when(termCodeValidation).getInvalidTermCodes(any(StructuredQuery.class)); @@ -192,18 +192,28 @@ public void testValidateQueryEndpoint_SucceedsOnValidQuery() throws Exception { mockMvc.perform(post(URI.create(PATH_API + PATH_QUERY + "/validate")).with(csrf()) .contentType(APPLICATION_JSON) .content(jsonUtil.writeValueAsString(testQuery))) - .andExpect(status().isNoContent()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.invalidTerms").isEmpty()); } @Test @WithMockUser(roles = "FEASIBILITY_TEST_USER") - public void testValidateQueryEndpoint_FailsOnInvalidStructuredQueryWith400() throws Exception { - var testQuery = StructuredQuery.builder().build(); + public void testValidate2QueryEndpoint_SucceedsDespiteInvalidTermcodesWith200() throws Exception { + StructuredQuery testQuery = createValidStructuredQuery(); + var invalidTermCode = TermCode.builder() + .code("LL2191-6") + .system("http://loinc.org") + .display("Geschlecht") + .build(); + + doReturn(List.of(invalidTermCode)).when(termCodeValidation).getInvalidTermCodes(any(StructuredQuery.class)); mockMvc.perform(post(URI.create(PATH_API + PATH_QUERY + "/validate")).with(csrf()) .contentType(APPLICATION_JSON) .content(jsonUtil.writeValueAsString(testQuery))) - .andExpect(status().isBadRequest()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.invalidTerms").exists()) + .andExpect(jsonPath("$.invalidTerms").isNotEmpty()); } @Test