diff --git a/commons-schemavalidation/.gitignore b/commons-schemavalidation/.gitignore new file mode 100644 index 00000000..549e00a2 --- /dev/null +++ b/commons-schemavalidation/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/commons-schemavalidation/pom.xml b/commons-schemavalidation/pom.xml new file mode 100644 index 00000000..bc3d097b --- /dev/null +++ b/commons-schemavalidation/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + eu.europeana.api.commons + commons-api-services + 0.3.22-SNAPSHOT + ../pom.xml + + + commons-schemavalidation + Api Commons - Json schema validation + commons-schemavalidation + + + + com.networknt + json-schema-validator + 1.0.47 + + + + org.springframework + spring-webmvc + ${version.spring} + + + + javax.servlet + servlet-api + 2.5 + provided + + + + org.junit.jupiter + junit-jupiter-api + ${version.org.junit.jupiter} + test + + + + + + + ${project.artifactId}-${project.version} + + + diff --git a/commons-schemavalidation/src/main/java/eu/europeana/api/commons/JsonSchemaValidatingArgumentResolver.java b/commons-schemavalidation/src/main/java/eu/europeana/api/commons/JsonSchemaValidatingArgumentResolver.java new file mode 100644 index 00000000..ecb62933 --- /dev/null +++ b/commons-schemavalidation/src/main/java/eu/europeana/api/commons/JsonSchemaValidatingArgumentResolver.java @@ -0,0 +1,105 @@ +package eu.europeana.api.commons; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion.VersionFlag; +import com.networknt.schema.ValidationMessage; +import eu.europeana.api.commons.exception.JsonSchemaLoadingFailedException; +import eu.europeana.api.commons.exception.JsonValidationFailedException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import javax.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.MethodParameter; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.util.StreamUtils; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class JsonSchemaValidatingArgumentResolver implements + HandlerMethodArgumentResolver { + private final ObjectMapper objectMapper; + private final ResourcePatternResolver resourcePatternResolver; + private final Map schemaCache; + + public JsonSchemaValidatingArgumentResolver(ObjectMapper objectMapper, ResourcePatternResolver resourcePatternResolver) { + this.objectMapper = objectMapper; + this.resourcePatternResolver = resourcePatternResolver; + this.schemaCache = new ConcurrentHashMap<>(); + } + + @Override + public boolean supportsParameter(MethodParameter methodParameter) { + return methodParameter.getParameterAnnotation(ValidJson.class) != null; + } + + @Override + public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) + throws URISyntaxException, IOException { + // get schema path from ValidJson annotation + if (supportsParameter(methodParameter)) { + + ValidJson parameterAnnotation = methodParameter.getParameterAnnotation(ValidJson.class); + JsonSchema schema = parameterAnnotation!=null ? getJsonSchemaBasedOnInput( parameterAnnotation.path(), parameterAnnotation.uri()) :null; + if (schema != null) { + String schemaRef = parameterAnnotation.nested(); + JsonSchema reqSchema = ValidationUtils.getSubSchema(schema, schemaRef); + // parse json payload + JsonNode json = getParsedJson(nativeWebRequest); + // Do actual validation + Set validationResult = reqSchema.validate(json, json, schemaRef); + if (!validationResult.isEmpty()) { + // throw exception if validation failed + throw new JsonValidationFailedException(validationResult); + } + // No validation errors, convert JsonNode to method parameter type and return it + return objectMapper.treeToValue(json, methodParameter.getParameterType()); + } + } + return null; + } + + private JsonNode getParsedJson(NativeWebRequest nativeWebRequest) throws IOException { + HttpServletRequest httpServletRequest = nativeWebRequest.getNativeRequest(HttpServletRequest.class); + String jsonStream = httpServletRequest!=null ? StreamUtils.copyToString(httpServletRequest.getInputStream(),StandardCharsets.UTF_8) :null; + return objectMapper.readTree(jsonStream); + } + + private JsonSchema getJsonSchemaBasedOnInput(String schemaPath, String schemaUri) throws URISyntaxException { + JsonSchema schema = null; + // get JsonSchema from schemaPath the URI path is prioritized if provided. + if(StringUtils.isNotBlank(schemaPath)) { + schema = getJsonSchema(schemaPath); + } + if(StringUtils.isNotBlank(schemaUri)){ + schema = ValidationUtils.getJsonSchemaFromUrl(schemaUri); + } + return schema; + } + + private JsonSchema getJsonSchema(String schemaPath) { + return schemaCache.computeIfAbsent(schemaPath, path -> { + Resource resource = resourcePatternResolver.getResource(path); + if (!resource.exists()) { + throw new JsonSchemaLoadingFailedException("Schema file does not exist, path: " + path); + } + JsonSchemaFactory schemaFactory = JsonSchemaFactory.getInstance(VersionFlag.V201909); + try (InputStream schemaStream = resource.getInputStream()) { + return schemaFactory.getSchema(schemaStream); + } catch (Exception e) { + throw new JsonSchemaLoadingFailedException("An error occurred while loading JSON Schema, path: " + path, e); + } + }); + } +} + diff --git a/commons-schemavalidation/src/main/java/eu/europeana/api/commons/ValidJson.java b/commons-schemavalidation/src/main/java/eu/europeana/api/commons/ValidJson.java new file mode 100644 index 00000000..42220f3e --- /dev/null +++ b/commons-schemavalidation/src/main/java/eu/europeana/api/commons/ValidJson.java @@ -0,0 +1,14 @@ +package eu.europeana.api.commons; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidJson { + String path() default ""; + String uri() default ""; + String nested() default "$"; +} diff --git a/commons-schemavalidation/src/main/java/eu/europeana/api/commons/ValidationUtils.java b/commons-schemavalidation/src/main/java/eu/europeana/api/commons/ValidationUtils.java new file mode 100644 index 00000000..a6a45d52 --- /dev/null +++ b/commons-schemavalidation/src/main/java/eu/europeana/api/commons/ValidationUtils.java @@ -0,0 +1,58 @@ +package eu.europeana.api.commons; + + + +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion.VersionFlag; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collection; + +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonValidator; +import com.networknt.schema.OneOfValidator; +import com.networknt.schema.RefValidator; + +/** + * @author Hugo + * @since 18 Dec 2023 + */ +public class ValidationUtils +{ + public static JsonSchema getSubSchema(JsonSchema schema, String ref) { + if ( schema.getSchemaPath().equals(ref) ) { return schema; } + return getSchemaFromValidators(schema.getValidators().values(), ref); + } + + private static JsonSchema getSchemaFromValidators(Collection validators, String ref) { + for (JsonValidator v : validators) { + JsonSchema ret = getSchemaFromValidator(v, ref); + if ( ret != null ) { return ret; } + } + return null; + } + + private static JsonSchema getSubSchema(Collection schemas, String ref) { + for (JsonSchema schema : schemas ) { + JsonSchema ret = getSubSchema(schema, ref); + if ( ret != null ) { return ret; } + } + return null; + } + + private static JsonSchema getSchemaFromValidator(JsonValidator v, String ref) { + if ( v instanceof OneOfValidator ) { + return getSubSchema(((OneOfValidator)v).getChildSchemas(), ref); + } + if ( v instanceof RefValidator ) { + return getSubSchema(((RefValidator)v).getSchemaRef().getSchema(), ref); + } + return null; + } + + public static JsonSchema getJsonSchemaFromUrl(String uri) throws URISyntaxException { + JsonSchemaFactory factory = JsonSchemaFactory.getInstance(VersionFlag.V201909); + return factory.getSchema(new URI(uri)); + } +} + diff --git a/commons-schemavalidation/src/main/java/eu/europeana/api/commons/exception/JsonSchemaLoadingFailedException.java b/commons-schemavalidation/src/main/java/eu/europeana/api/commons/exception/JsonSchemaLoadingFailedException.java new file mode 100644 index 00000000..b0fc8e6c --- /dev/null +++ b/commons-schemavalidation/src/main/java/eu/europeana/api/commons/exception/JsonSchemaLoadingFailedException.java @@ -0,0 +1,11 @@ +package eu.europeana.api.commons.exception; + +public class JsonSchemaLoadingFailedException extends RuntimeException{ + public JsonSchemaLoadingFailedException(String message) { + super(message); + } + + public JsonSchemaLoadingFailedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/commons-schemavalidation/src/main/java/eu/europeana/api/commons/exception/JsonValidationFailedException.java b/commons-schemavalidation/src/main/java/eu/europeana/api/commons/exception/JsonValidationFailedException.java new file mode 100644 index 00000000..b09f3c7a --- /dev/null +++ b/commons-schemavalidation/src/main/java/eu/europeana/api/commons/exception/JsonValidationFailedException.java @@ -0,0 +1,17 @@ +package eu.europeana.api.commons.exception; + +import com.networknt.schema.ValidationMessage; +import java.util.Set; + +public class JsonValidationFailedException extends RuntimeException { + private final Set validationMessages; + + public JsonValidationFailedException(Set validationMessages) { + super("Json validation failed: " + validationMessages); + this.validationMessages = validationMessages; + } + + public Set getValidationMessages() { + return validationMessages; + } +} diff --git a/commons-schemavalidation/src/test/java/eu/europeana/api/commons/JsonSchemaValidationTest.java b/commons-schemavalidation/src/test/java/eu/europeana/api/commons/JsonSchemaValidationTest.java new file mode 100644 index 00000000..2b549a94 --- /dev/null +++ b/commons-schemavalidation/src/test/java/eu/europeana/api/commons/JsonSchemaValidationTest.java @@ -0,0 +1,15 @@ +package eu.europeana.api.commons; + +import org.junit.jupiter.api.Test; + +public class JsonSchemaValidationTest { + + @Test + + public void testSunnyDay_scenario(){ + + + } + + +} diff --git a/commons-web/pom.xml b/commons-web/pom.xml index ea7f38bd..25b70873 100644 --- a/commons-web/pom.xml +++ b/commons-web/pom.xml @@ -47,7 +47,15 @@ commons-oauth 0.3.22-SNAPSHOT - + + + eu.europeana.api.commons + commons-schemavalidation + 0.3.22-SNAPSHOT + + + + at.ac.ait.ngcms annotation-ld diff --git a/commons-web/src/main/java/eu/europeana/api/commons/web/exception/EuropeanaGlobalExceptionHandler.java b/commons-web/src/main/java/eu/europeana/api/commons/web/exception/EuropeanaGlobalExceptionHandler.java index d0ba824d..e74a483d 100644 --- a/commons-web/src/main/java/eu/europeana/api/commons/web/exception/EuropeanaGlobalExceptionHandler.java +++ b/commons-web/src/main/java/eu/europeana/api/commons/web/exception/EuropeanaGlobalExceptionHandler.java @@ -1,7 +1,11 @@ package eu.europeana.api.commons.web.exception; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.networknt.schema.ValidationMessage; +import eu.europeana.api.commons.exception.JsonValidationFailedException; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; import javax.validation.ConstraintViolationException; import org.apache.commons.lang3.StringUtils; @@ -337,4 +341,22 @@ public String getSeeAlso() { protected I18nService getI18nService() { return null; } + + @ExceptionHandler(JsonValidationFailedException.class) + public ResponseEntity onJsonValidationFailedException(JsonValidationFailedException e,HttpServletRequest httpRequest) { + List messages = e.getValidationMessages().stream() + .map(ValidationMessage::getMessage) + .collect(Collectors.toList()); + + HttpStatus responseStatus = HttpStatus.BAD_REQUEST; + EuropeanaApiErrorResponse response = (new EuropeanaApiErrorResponse.Builder(httpRequest, e, this.stackTraceEnabled())). + setStatus(responseStatus.value()). + setMessage(String.join(",", messages)). + setError(responseStatus.getReasonPhrase()). + setSeeAlso(getSeeAlso()).build(); + + return (ResponseEntity.status(responseStatus).headers(this.createHttpHeaders(httpRequest))).body(response); + } + + } diff --git a/pom.xml b/pom.xml index 2f501a44..374d563e 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,7 @@ commons-web commons-error commons-logs + commons-schemavalidation