diff --git a/record-api-common/pom.xml b/record-api-common/pom.xml index b432440..701ed45 100644 --- a/record-api-common/pom.xml +++ b/record-api-common/pom.xml @@ -16,4 +16,64 @@ ${java.version} + + + + org.apache.commons + commons-lang3 + ${apache.commomLang3.version} + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + 2.15.3 + + + + + org.springframework.boot + spring-boot-autoconfigure + ${spring-boot.version} + provided + + + org.springframework + spring-web + ${spring-framework.version} + provided + + + org.springframework + spring-webmvc + ${spring-framework.version} + provided + + + + jakarta.validation + jakarta.validation-api + 3.0.2 + + + + jakarta.servlet + jakarta.servlet-api + 6.0.0 + provided + + + + javax.annotation + javax.annotation-api + 1.3.2 + + + + org.springframework.boot + spring-boot-starter-log4j2 + + + + \ No newline at end of file diff --git a/record-api-common/src/main/java/eu/europeana/api/config/MediaTypeConfig.java b/record-api-common/src/main/java/eu/europeana/api/config/MediaTypeConfig.java new file mode 100644 index 0000000..a799540 --- /dev/null +++ b/record-api-common/src/main/java/eu/europeana/api/config/MediaTypeConfig.java @@ -0,0 +1,51 @@ +package eu.europeana.api.config; + +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import eu.europeana.api.model.MediaType; +import eu.europeana.api.model.MediaTypes; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.Resource; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.stream.Collectors; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + + +/** + * @author srishti singh + * @since 18 October 2023 + */ +@Configuration +public class MediaTypeConfig { + + private static final Logger LOG = LogManager.getLogger(MediaTypeConfig.class); + + @Resource(name = "msXmlMapper") + private XmlMapper xmlMapper; + + + @Bean(name = "msMediaTypes") + public MediaTypes getMediaTypes() throws IOException { + + MediaTypes mediaTypes; + try (InputStream inputStream = getClass().getResourceAsStream("/mediacategories.xml")) { + assert inputStream != null; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + String contents = reader.lines().collect(Collectors.joining(System.lineSeparator())); + mediaTypes = xmlMapper.readValue(contents, MediaTypes.class); + } + } + + if (!mediaTypes.mediaTypeCategories.isEmpty()) { + mediaTypes.getMap().putAll(mediaTypes.mediaTypeCategories.stream().filter(media -> !media.isEuScreen()).collect(Collectors.toMap(MediaType::getMimeType, e-> e))); + } else { + LOG.error("media Categories not configured at startup. mediacategories.xml file not added or is empty"); + } + return mediaTypes; + } +} diff --git a/record-api-common/src/main/java/eu/europeana/api/config/SerialisationConfig.java b/record-api-common/src/main/java/eu/europeana/api/config/SerialisationConfig.java new file mode 100644 index 0000000..1ae06ad --- /dev/null +++ b/record-api-common/src/main/java/eu/europeana/api/config/SerialisationConfig.java @@ -0,0 +1,21 @@ +package eu.europeana.api.config; + +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; + +@Configuration +public class SerialisationConfig { + + private final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXX"); + + @Bean("msXmlMapper") + public XmlMapper xmlMapper() { + XmlMapper xmlMapper = new XmlMapper(); + xmlMapper.setDateFormat(dateFormat); + return xmlMapper; + } +} diff --git a/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaApiErrorAttributes.java b/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaApiErrorAttributes.java new file mode 100644 index 0000000..7c83ee6 --- /dev/null +++ b/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaApiErrorAttributes.java @@ -0,0 +1,66 @@ +package eu.europeana.api.error; + + +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.boot.web.servlet.error.DefaultErrorAttributes; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.WebRequest; + +import java.time.OffsetDateTime; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +import static eu.europeana.api.error.EuropeanaErrorConstants.*; + +@Component +public class EuropeanaApiErrorAttributes extends DefaultErrorAttributes { + + /** + * Used by Spring to display errors with no custom handler. + * Since we explicitly return {@link EuropeanaApiErrorResponse} on errors within controllers, this method is only invoked when + * a request isn't handled by any controller. + */ + @Override + public Map getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions sbOptions) { + final Map defaultErrorAttributes = super.getErrorAttributes(webRequest, sbOptions); + + // use LinkedHashMap to guarantee display order + LinkedHashMap europeanaErrorAttributes = new LinkedHashMap<>(); + europeanaErrorAttributes.put(SUCCESS, false); + europeanaErrorAttributes.put(STATUS, defaultErrorAttributes.get(STATUS)); + europeanaErrorAttributes.put(ERROR, defaultErrorAttributes.get(ERROR)); + // message not shown + europeanaErrorAttributes.put(TIMESTAMP, OffsetDateTime.now()); + addPathRequestParameters(europeanaErrorAttributes, webRequest); + return europeanaErrorAttributes; + } + + + /** + * Spring errors only return the error path and not the parameters, so we add those ourselves. + * The original parameter string is not available in WebRequest so we rebuild it. + */ + private void addPathRequestParameters(Map errorAttributes, WebRequest webRequest) { + Iterator it = webRequest.getParameterNames(); + StringBuilder s = new StringBuilder(); + while (it.hasNext()) { + if (s.length() == 0) { + s.append('?'); + } else { + s.append("&"); + } + String paramName = it.next(); + s.append(paramName); + String paramValue = webRequest.getParameter(paramName); + if (StringUtils.hasText(paramValue)) { + s.append("=").append(paramValue); + } + } + + if (s.length() > 0) { + errorAttributes.put(PATH, errorAttributes.get(PATH) + s.toString()); + } + } +} diff --git a/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaApiErrorController.java b/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaApiErrorController.java new file mode 100644 index 0000000..d9c0edf --- /dev/null +++ b/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaApiErrorController.java @@ -0,0 +1,76 @@ +package eu.europeana.api.error; + +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.web.ErrorProperties; +import org.springframework.boot.autoconfigure.web.servlet.error.AbstractErrorController; +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import jakarta.servlet.http.HttpServletRequest; + +import javax.annotation.PostConstruct;; + +@RestController +public class EuropeanaApiErrorController extends AbstractErrorController { + + private final EuropeanaApiErrorAttributes errorAttributes; + private ErrorAttributeOptions errorAttributeOptions = ErrorAttributeOptions.defaults(); + + @Value("${server.error.include-message}") + private ErrorProperties.IncludeAttribute includeMessage; + @Value("${server.error.include-exception}") + private Boolean includeException; + @Value("${server.error.include-stacktrace}") + private ErrorProperties.IncludeStacktrace includeStacktrace; + + /** + * Initialize a new controller to handle error output + * @param errorAttributes auto-wired ApiErrorAttributes (error fields) + */ + @Autowired + public EuropeanaApiErrorController(EuropeanaApiErrorAttributes errorAttributes) { + super(errorAttributes); + this.errorAttributes = errorAttributes; + } + + @PostConstruct + private void init() { + if (ErrorProperties.IncludeAttribute.ALWAYS.equals(includeMessage)) { + errorAttributeOptions = ErrorAttributeOptions.of(ErrorAttributeOptions.Include.MESSAGE); + } + if (includeException) { + errorAttributeOptions = ErrorAttributeOptions.of(ErrorAttributeOptions.Include.EXCEPTION); + } + if (ErrorProperties.IncludeStacktrace.ALWAYS.equals(includeStacktrace)) { + errorAttributeOptions = ErrorAttributeOptions.of(ErrorAttributeOptions.Include.STACK_TRACE); + } + } + +// @Override +// public String getErrorPath() { +// return "/error"; +// } + + /** + * Override default Spring-Boot error endpoint + * @param request incoming request + * @return error object to serialize + */ + @GetMapping("/error") + public Map error(HttpServletRequest request) { + ErrorAttributeOptions options = errorAttributeOptions; + if (ErrorProperties.IncludeAttribute.ON_PARAM.equals(includeMessage) && this.getMessageParameter(request)) { + options = errorAttributeOptions.including(ErrorAttributeOptions.Include.MESSAGE); + } + if (ErrorProperties.IncludeStacktrace.ON_PARAM.equals(includeStacktrace) && this.getTraceParameter(request)) { + options = errorAttributeOptions.including(ErrorAttributeOptions.Include.STACK_TRACE); + } + WebRequest webRequest = new ServletWebRequest(request); + return this.errorAttributes.getErrorAttributes(webRequest, options); + } + +} \ No newline at end of file diff --git a/record-api-web/src/main/java/eu/europeana/api/record/model/ApiErrorResponse.java b/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaApiErrorResponse.java similarity index 52% rename from record-api-web/src/main/java/eu/europeana/api/record/model/ApiErrorResponse.java rename to record-api-common/src/main/java/eu/europeana/api/error/EuropeanaApiErrorResponse.java index 42879be..4215cf6 100644 --- a/record-api-web/src/main/java/eu/europeana/api/record/model/ApiErrorResponse.java +++ b/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaApiErrorResponse.java @@ -1,40 +1,43 @@ -package eu.europeana.api.record.model; +package eu.europeana.api.error; -import com.fasterxml.jackson.annotation.*; -import eu.europeana.api.commons.error.ResponseUtils; -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.util.StringUtils; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; -import java.time.OffsetDateTime; import java.util.List; +import java.time.OffsetDateTime; -@JsonPropertyOrder({"success", "status", "error", "message", "timestamp", "path"}) +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.util.StringUtils; +import static eu.europeana.api.error.EuropeanaErrorConstants.*; + +/** + * This class contains fields to be returned by APIs when an error occurs within the application. + * + */ +@JsonPropertyOrder({CONTEXT, TYPE, SUCCESS, STATUS, ERROR, MESSAGE, SEE_ALSO, TIMESTAMP, PATH}) @JsonInclude(JsonInclude.Include.NON_EMPTY) -public class ApiErrorResponse { +public class EuropeanaApiErrorResponse { - @JsonProperty("success") + private final String context = ERROR_CONTEXT; + private final String type= ERROR_TYPE; private final boolean success = false; - - @JsonProperty("status") private final int status; - - @JsonProperty("error") private final String error; - - @JsonProperty("trace") - private final String trace; - - @JsonProperty("message") private final String message; + private final String seeAlso = SEE_ALSO_VALUE; - @JsonFormat( - pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'" - ) + @JsonFormat(pattern="yyyy-MM-dd'T'HH:mm:ss'Z'") private final OffsetDateTime timestamp = OffsetDateTime.now(); + + private final String trace; + private final String path; + private final String code; - private ApiErrorResponse(int status, String error, String message, String trace, String path, String code) { + private EuropeanaApiErrorResponse(int status, String error, String message, String trace, String path, String code) { this.status = status; this.error = error; this.message = message; @@ -44,35 +47,48 @@ private ApiErrorResponse(int status, String error, String message, String trace, } public String getError() { - return this.error; + return error; } public boolean isSuccess() { - return false; + return success; } public int getStatus() { - return this.status; + return status; } public String getMessage() { - return this.message; + return message; } public OffsetDateTime getTimestamp() { - return this.timestamp; + return timestamp; } - public String getPath() { - return this.path; + public String getTrace() { + return trace; } - public String getTrace() { - return this.trace; + public String getPath() { + return path; } public String getCode() { - return this.code; + return code; + } + + @JsonGetter(CONTEXT) + public String getContext() { + return context; + } + + public String getType() { + return type; + } + + public String getSeeAlso() { + return seeAlso; } public static class Builder { @@ -84,46 +100,41 @@ public static class Builder { private String code; public Builder(HttpServletRequest httpRequest, Exception e, boolean stacktraceEnabled) { - this.path = getRequestPath(httpRequest); + this.path = ResponseUtils.getRequestPath(httpRequest); boolean includeErrorStack = false; - String profileParamString = httpRequest.getParameter("profile"); + String profileParamString = httpRequest.getParameter(QUERY_PARAM_PROFILE); + // check if profile contains debug if (StringUtils.hasLength(profileParamString)) { - includeErrorStack = List.of(profileParamString.split(",")).contains("debug"); + includeErrorStack = List.of(profileParamString.split(QUERY_PARAM_PROFILE_SEPARATOR)) + .contains(PROFILE_DEBUG); } - if (stacktraceEnabled && includeErrorStack) { this.trace = ResponseUtils.getExceptionStackTrace(e); } - } - public ApiErrorResponse.Builder setStatus(int status) { + public Builder setStatus(int status) { this.status = status; return this; } - public ApiErrorResponse.Builder setMessage(String message) { + public Builder setMessage(String message) { this.message = message; return this; } - public ApiErrorResponse.Builder setError(String error) { + public Builder setError(String error) { this.error = error; return this; } - public ApiErrorResponse.Builder setCode(String code) { + public Builder setCode(String code) { this.code = code; return this; } - public ApiErrorResponse build() { - return new ApiErrorResponse(this.status, this.error, this.message, this.trace, this.path, this.code); + public EuropeanaApiErrorResponse build() { + return new EuropeanaApiErrorResponse(status, error, message, trace, path, code); } } - - public static String getRequestPath(HttpServletRequest httpRequest) { - return httpRequest.getQueryString() == null ? String.valueOf(httpRequest.getRequestURL()) : String.valueOf(httpRequest.getRequestURL().append("?").append(httpRequest.getQueryString())); - } } - diff --git a/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaApiException.java b/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaApiException.java new file mode 100644 index 0000000..f59a918 --- /dev/null +++ b/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaApiException.java @@ -0,0 +1,98 @@ +package eu.europeana.api.error; + +import org.springframework.http.HttpStatus; + +/** + * Base error class for this application. All other application errors should extend this class + */ +public class EuropeanaApiException extends Exception { + + /** + * + */ + private static final long serialVersionUID = -1354471712894853562L; + private final String errorCode; + + /** + * Initialise a new exception + * @param msg error message + * @param t root cause exception + */ + public EuropeanaApiException(String msg, Throwable t) { + this(msg, null, t); + } + + /** + * Initialise a new exception with error code + * @param msg error message + * @param errorCode error code (optional) + * @param t root cause exception + */ + public EuropeanaApiException(String msg, String errorCode, Throwable t) { + super(msg, t); + this.errorCode = errorCode; + } + + /** + * Initialise a new exception for which there is no root cause + * @param msg error message + */ + public EuropeanaApiException(String msg) { + super(msg); + this.errorCode = null; + } + + /** + * Initialise a new exception with error code for which there is no root cause + * @param msg error message + * @param errorCode error code (optional) + */ + public EuropeanaApiException(String msg, String errorCode) { + super(msg); + this.errorCode = errorCode; + } + + /** + * By default we log all exceptions, but you can override this method and return false if you do not want an error + * subclass to log the error + * @return boolean indicating whether this type of exception should be logged or not. + */ + public boolean doLog() { + return true; + } + + /** + * By default we log error stacktraces, but you can override this method and return false if you do not want an error + * subclass to log the error (e.g. in case of common user errors). Note that this only works if doLog is enabled + * as well. + * @return boolean indicating whether the stacktrace of the exception should be logged or not. + */ + public boolean doLogStacktrace() { + return true; + } + + /** + * @return the error code that was optionally provided when creating this exception + */ + public String getErrorCode() { + return this.errorCode; + } + + /** + * Indicates whether the error message should be include in responses. This is set to true by default. + * @return boolean indicating whether the exception message should be exposed to end users + */ + public boolean doExposeMessage() { + return true; + } + + /** + * Gets the HTTP status for this exception. + * By default this returns HttpStatus.INTERNAL_SERVER_ERROR + * + * @return HTTP status for exception + */ + public HttpStatus getResponseStatus() { + return HttpStatus.INTERNAL_SERVER_ERROR; + } +} diff --git a/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaErrorConstants.java b/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaErrorConstants.java new file mode 100644 index 0000000..e9a0359 --- /dev/null +++ b/record-api-common/src/main/java/eu/europeana/api/error/EuropeanaErrorConstants.java @@ -0,0 +1,27 @@ +package eu.europeana.api.error; + +public class EuropeanaErrorConstants { + + + // error fiels + public static final String CONTEXT = "@context"; + public static final String TYPE = "type"; + public static final String SUCCESS = "success"; + public static final String STATUS = "status"; + public static final String ERROR = "error"; + public static final String MESSAGE = "message"; + public static final String SEE_ALSO = "seeAlso"; + public static final String TIMESTAMP = "timestamp"; + public static final String PATH = "path"; + + public static final String ERROR_TYPE= "ErrorResponse"; + public static final String ERROR_CONTEXT = "http://www.europeana.eu/schemas/context/api.jsonld"; + public static final String SEE_ALSO_VALUE = "https://pro.europeana.eu/page/apis"; + + + // other constants + public static final String QUERY_PARAM_PROFILE = "profile"; + public static final String QUERY_PARAM_PROFILE_SEPARATOR = ","; + public static final String PROFILE_DEBUG = "debug"; + +} diff --git a/record-api-common/src/main/java/eu/europeana/api/error/ResponseUtils.java b/record-api-common/src/main/java/eu/europeana/api/error/ResponseUtils.java new file mode 100644 index 0000000..71236a2 --- /dev/null +++ b/record-api-common/src/main/java/eu/europeana/api/error/ResponseUtils.java @@ -0,0 +1,40 @@ +package eu.europeana.api.error; + +import jakarta.servlet.http.HttpServletRequest; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.Writer; + +public class ResponseUtils { + + private ResponseUtils() { + // hide implicit public constructor + } + + /** + * Gets a String representation of an Exception's stacktrace + * + * @param throwable Throwable instance + * @return String representation of stacktrace + */ + public static String getExceptionStackTrace(Throwable throwable) { + Writer result = new StringWriter(); + PrintWriter printWriter = new PrintWriter(result); + throwable.printStackTrace(printWriter); + return result.toString(); + } + + + /** + * Gets the URI path in the request, appending any query parameters + * + * @param httpRequest Http request + * @return String containing request URI and query parameterss + */ + public static String getRequestPath(HttpServletRequest httpRequest) { + return + httpRequest.getQueryString() == null ? String.valueOf(httpRequest.getRequestURL()) : + String.valueOf(httpRequest.getRequestURL().append("?").append(httpRequest.getQueryString())); + } +} diff --git a/record-api-common/src/main/java/eu/europeana/api/format/RdfFormat.java b/record-api-common/src/main/java/eu/europeana/api/format/RdfFormat.java new file mode 100644 index 0000000..ff5027b --- /dev/null +++ b/record-api-common/src/main/java/eu/europeana/api/format/RdfFormat.java @@ -0,0 +1,63 @@ +package eu.europeana.api.format; + +public enum RdfFormat { + JSONLD("jsonld", "json", "application/ld+json", "application/json"), + XML("rdf", "xml", "application/rdf+xml", "application/xml", "text/xml", "rdf/xml"), + TURTLE("ttl", null, "text/turtle", "application/turtle", "application/x-turtle"), + N3("n3", null, "text/n3", "text/rdf+n3", "application/n3"), + NT("nt", null, "application/n-triples", "application/ntriples", "text/nt"); + + private String extension; + private String alternative; + private String[] mediaTypes; + + RdfFormat(String extension, String alternative, String... mediaTypes) { + this.extension = extension; + this.alternative = alternative; + this.mediaTypes = mediaTypes; + } + + public static RdfFormat getFormatByExtension(String extension) { + for (RdfFormat format : RdfFormat.values()) { + if (format.acceptsExtension(extension)) { + return format; + } + } + return null; + } + + public static RdfFormat getFormatByMediaType(String mediaType) { + for (RdfFormat format : RdfFormat.values()) { + if (format.acceptsMediaType(mediaType)) { + return format; + } + } + return null; + } + + public String getExtension() { + return extension; + } + + public String getAlternative() { + return alternative; + } + + public String getMediaType() { + return mediaTypes[0]; + } + + public boolean acceptsExtension(String extension) { + return (this.extension.equals(extension) + || (this.alternative != null && this.alternative.equals(extension))); + } + + public boolean acceptsMediaType(String mediaType) { + for (String mType : mediaTypes) { + if (mType.equals(mediaType)) { + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/record-api-common/src/main/java/eu/europeana/api/model/MediaType.java b/record-api-common/src/main/java/eu/europeana/api/model/MediaType.java new file mode 100644 index 0000000..e2b16e2 --- /dev/null +++ b/record-api-common/src/main/java/eu/europeana/api/model/MediaType.java @@ -0,0 +1,63 @@ +package eu.europeana.api.model; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; + +/** + * @author srishti singh + * @since 19 October 2023 + */ +@JacksonXmlRootElement(localName = "format") +public class MediaType { + + private static final String BROWSER = "Browser"; + private static final String RENDERED = "Rendered"; + private static final String EU_SCREEN = "EUScreen"; + + public static final String VIDEO = "Video"; + public static final String SOUND = "Sound"; + + @JacksonXmlProperty(localName = "mediaType", isAttribute = true) + private String mimeType; + + @JacksonXmlProperty(isAttribute = true) + private String label; + + @JacksonXmlProperty(isAttribute = true) + private String type; + + @JacksonXmlProperty(isAttribute = true) + private String support; + + public String getMimeType() { + return mimeType; + } + + public String getLabel() { + return label; + } + + public String getType() { + return type; + } + + public String getSupport() { + return support; + } + + public boolean isRendered() { + return RENDERED.equals(getSupport()); + } + + public boolean isBrowserSupported() { + return BROWSER.equals(getSupport()); + } + + public boolean isVideoOrSound() { + return ( VIDEO.equals(getType()) || SOUND.equals(getType()) ) ; + } + + public boolean isEuScreen() { + return EU_SCREEN.equals(getSupport()); + } +} diff --git a/record-api-common/src/main/java/eu/europeana/api/model/MediaTypes.java b/record-api-common/src/main/java/eu/europeana/api/model/MediaTypes.java new file mode 100644 index 0000000..1af24ca --- /dev/null +++ b/record-api-common/src/main/java/eu/europeana/api/model/MediaTypes.java @@ -0,0 +1,62 @@ +package eu.europeana.api.model; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import org.apache.commons.lang3.StringUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * @author srishti singh + * @since 19 October 2023 + */ +@JacksonXmlRootElement(localName = "config") +public class MediaTypes { + + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "format") + public List mediaTypeCategories; + + private Map map = new HashMap<>(); + + /** + * Map contains all the suppoerted media types except EU Screen entries + * @return + */ + public Map getMap() { + return this.map; + } + + + /** + * Checks if a media Type is configured for the given mime Type + * + * @param mimeType mime type to match + * @return true if a Media Type match is configured, false otherwise. + */ + public boolean hasMediaType(String mimeType) { + return map.containsKey(mimeType); + } + + /** + * Gets the configured media Type for the given entity mime type + * + * @param mimetype entity ID + * @return Matching media Type, or empty Optional if none found + */ + public Optional getMediaType(String mimetype) { + if (StringUtils.isNotEmpty(mimetype)) { + return Optional.ofNullable(map.get(mimetype)); + } + return Optional.empty(); + } + + public Optional getEUScreenType(String edmType) { + return mediaTypeCategories.stream().filter(s -> s.isEuScreen() && s.getType().equalsIgnoreCase(edmType)).findFirst(); + } + +} \ No newline at end of file diff --git a/record-api-common/src/main/java/eu/europeana/api/service/AbstractRequestPathMethodService.java b/record-api-common/src/main/java/eu/europeana/api/service/AbstractRequestPathMethodService.java new file mode 100644 index 0000000..b898dd0 --- /dev/null +++ b/record-api-common/src/main/java/eu/europeana/api/service/AbstractRequestPathMethodService.java @@ -0,0 +1,98 @@ +package eu.europeana.api.service; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +/** + * This class collates all the HTTP methods that are implemented for all unique request + * patterns within a Spring Boot application. + * + * This is useful for populating the HTTP Allow header, when generating API responses. + * + * To use: + * - extend this class within your project; + * - instantiate the subclass with the Spring WebApplicationContext; + * - pass the instance as an argument to BaseRestController.createAllowHeader() + * */ +public abstract class AbstractRequestPathMethodService implements InitializingBean { + /** + * Map request urls to Http request methods (implemented across the application) with the url + * pattern. + */ + private final Map> requestPathMethodMap = new HashMap<>(); + + protected final WebApplicationContext applicationContext; + + protected AbstractRequestPathMethodService( + WebApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + /** Populate request url pattern - request methods map */ + @Override + public void afterPropertiesSet() { + RequestMappingHandlerMapping mapping = + applicationContext.getBean(RequestMappingHandlerMapping.class); + Map handlerMethods = mapping.getHandlerMethods(); + + for (RequestMappingInfo info : handlerMethods.keySet()) { + PatternsRequestCondition p = info.getPatternsCondition(); + + // get all request methods for this pattern + final Set requestMethods = + info.getMethodsCondition().getMethods().stream() + .map(Enum::toString) + .collect(Collectors.toSet()); + + for (String url : p.getPatterns()) { + addToMap(requestPathMethodMap, url, requestMethods); + } + } + } + + /** + * Gets request methods that are implemented across the application for this request's URL + * pattern. The return value from this method is used when setting the Allow header in API + * responses. + * + * @param request {@link HttpServletRequest} instance + * @return Optional containing matching request methods, or empty optional if no match could be + * determined. + */ + public Optional getMethodsForRequestPattern(HttpServletRequest request) { + Object patternAttribute = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + + if (patternAttribute == null) { + return Optional.empty(); + } + + Set requestMethods = requestPathMethodMap.get(patternAttribute.toString()); + String methods = String.join(",", requestMethods); + return Optional.of(methods); + } + + /** This method adds url patterns and their matching request methods to the map. */ + private void addToMap( + Map> map, String urlPattern, Set requestMethods) { + if (!map.containsKey(urlPattern)) { + map.put(urlPattern, requestMethods); + return; + } + + // Each pattern can be used across multiple request handlers, so we append here. + Set existing = map.get(urlPattern); + existing.addAll(requestMethods); + } +} diff --git a/record-api-common/src/main/java/eu/europeana/api/service/EuropeanaGlobalExceptionHandler.java b/record-api-common/src/main/java/eu/europeana/api/service/EuropeanaGlobalExceptionHandler.java new file mode 100644 index 0000000..8f60634 --- /dev/null +++ b/record-api-common/src/main/java/eu/europeana/api/service/EuropeanaGlobalExceptionHandler.java @@ -0,0 +1,243 @@ +package eu.europeana.api.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import eu.europeana.api.error.EuropeanaApiErrorResponse; +import eu.europeana.api.error.EuropeanaApiException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.web.ErrorProperties; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; + + +/** + * Global exception handler that catches all errors and logs the interesting ones + * To use this, create a new class in your application that extends this class and add the @ControllerAdvice annotation + * to it. + */ +@Component +public class EuropeanaGlobalExceptionHandler { + + @Value("${server.error.include-stacktrace:ON_PARAM}") + private ErrorProperties.IncludeStacktrace includeStacktraceConfig; + + private static final Logger LOG = LogManager.getLogger(EuropeanaGlobalExceptionHandler.class); + + protected AbstractRequestPathMethodService requestPathMethodService; + + /** + * Checks if {@link EuropeanaApiException} instances should be logged or not + * + * @param e exception + */ + protected void logException(EuropeanaApiException e) { + if (e.doLog()) { + if (e.doLogStacktrace()) { + LOG.error("Caught exception", e); + } else { + LOG.error("Caught exception: {}", e.getMessage()); + } + } + } + + /** + * Checks whether stacktrace for exceptions should be included in responses + * @return true if IncludeStacktrace config is not disabled on server + */ + protected boolean stackTraceEnabled(){ + return includeStacktraceConfig != ErrorProperties.IncludeStacktrace.NEVER; + } + + /** + * Default handler for EuropeanaApiException types + * + * @param e caught exception + */ + @ExceptionHandler + public ResponseEntity handleEuropeanaBaseException(EuropeanaApiException e, HttpServletRequest httpRequest) { + logException(e); + EuropeanaApiErrorResponse response = new EuropeanaApiErrorResponse.Builder(httpRequest, e, stackTraceEnabled()) + .setStatus(e.getResponseStatus().value()) + .setError(e.getResponseStatus().getReasonPhrase()) + .setMessage(e.doExposeMessage() ? e.getMessage() : null) + // code only included in JSON if a value is set in exception + .setCode(e.getErrorCode()) + .build(); + + return ResponseEntity + .status(e.getResponseStatus()) + .headers(createHttpHeaders(httpRequest)) + .body(response); + } + + /** + * Default handler for all other exception types + * + * @param e caught exception + */ + @ExceptionHandler + public ResponseEntity handleOtherExceptionTypes(Exception e, HttpServletRequest httpRequest) { + LOG.error("Error: ", e); + HttpStatus responseStatus = HttpStatus.INTERNAL_SERVER_ERROR; + EuropeanaApiErrorResponse response = new EuropeanaApiErrorResponse.Builder(httpRequest, e, stackTraceEnabled()) + .setStatus(responseStatus.value()) + .setError(responseStatus.getReasonPhrase()) + .build(); + + return ResponseEntity + .status(responseStatus) + .headers(createHttpHeaders(httpRequest)) + .body(response); + } + + /** + * Handler for HttpRequestMethodNotSupportedException errors + * Make sure we return 405 instead of 500 response when http method is not supported; also include error message + */ + @ExceptionHandler + public ResponseEntity handleHttpMethodNotSupportedException(HttpRequestMethodNotSupportedException e, HttpServletRequest httpRequest) { + HttpStatus responseStatus = HttpStatus.METHOD_NOT_ALLOWED; + EuropeanaApiErrorResponse response = new EuropeanaApiErrorResponse.Builder(httpRequest, e, stackTraceEnabled()) + .setStatus(responseStatus.value()) + .setError(responseStatus.getReasonPhrase()) + .setMessage(e.getMessage()) + .build(); + + Set supportedMethods = e.getSupportedHttpMethods(); + + // set Allow header in error response + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + if (supportedMethods != null) { + headers.setAllow(supportedMethods); + } + return new ResponseEntity<>(response, headers, responseStatus); + } + + + /** + * Handler for ConstraintValidation errors + * Make sure we return 400 instead of 500 response when input validation fails; also include error message + */ + @ExceptionHandler + public ResponseEntity handleInputValidationError(ConstraintViolationException e, HttpServletRequest httpRequest) { + HttpStatus responseStatus = HttpStatus.BAD_REQUEST; + EuropeanaApiErrorResponse response = new EuropeanaApiErrorResponse.Builder(httpRequest, e, stackTraceEnabled()) + .setStatus(responseStatus.value()) + .setError(responseStatus.getReasonPhrase()) + .setMessage(e.getMessage()) + .build(); + + return ResponseEntity + .status(responseStatus) + .headers(createHttpHeaders(httpRequest)) + .body(response); + } + + /** + * MissingServletRequestParameterException thrown when a required parameter is not included in a request. + */ + @ExceptionHandler + public ResponseEntity handleInputValidationError(MissingServletRequestParameterException e, HttpServletRequest httpRequest) { + HttpStatus responseStatus = HttpStatus.BAD_REQUEST; + EuropeanaApiErrorResponse response = (new EuropeanaApiErrorResponse.Builder(httpRequest, e, stackTraceEnabled())) + .setStatus(responseStatus.value()) + .setError(responseStatus.getReasonPhrase()) + .setMessage(e.getMessage()) + .build(); + + return ResponseEntity + .status(responseStatus) + .headers(createHttpHeaders(httpRequest)) + .body(response); + } + + /** + * Customise the response for {@link org.springframework.web.HttpMediaTypeNotAcceptableException} + */ + @ExceptionHandler(HttpMediaTypeNotAcceptableException.class) + public ResponseEntity handleMediaTypeNotAcceptableException( + HttpMediaTypeNotAcceptableException e, HttpServletRequest httpRequest) { + + HttpStatus responseStatus = HttpStatus.NOT_ACCEPTABLE; + EuropeanaApiErrorResponse response = new EuropeanaApiErrorResponse.Builder(httpRequest, e, stackTraceEnabled()) + .setStatus(responseStatus.value()) + .setError(responseStatus.getReasonPhrase()) + .setMessage("Server could not generate a response that is acceptable by the client") + .build(); + + return ResponseEntity + .status(responseStatus) + .headers(createHttpHeaders(httpRequest)) + .body(response); + } + + + /** + * Exception thrown by Spring when RequestBody validation fails. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgNotValidException(MethodArgumentNotValidException e, HttpServletRequest httpRequest) { + BindingResult result = e.getBindingResult(); + String error =""; + List fieldErrors = result.getFieldErrors(); + if(!fieldErrors.isEmpty()) { + // just return the first error + error = fieldErrors.get(0).getField() + " " + fieldErrors.get(0).getDefaultMessage(); + } + + HttpStatus responseStatus = HttpStatus.BAD_REQUEST; + EuropeanaApiErrorResponse response = new EuropeanaApiErrorResponse.Builder(httpRequest, e, stackTraceEnabled()) + .setStatus(responseStatus.value()) + .setMessage("Invalid request body") + .setError(error) + .build(); + + return ResponseEntity + .status(responseStatus) + .headers(createHttpHeaders(httpRequest)) + .body(response); + } + + protected HttpHeaders createHttpHeaders(HttpServletRequest httpRequest) { + HttpHeaders headers = new HttpHeaders(); + //enforce application/json as content type, it is the only serialization supported for exceptions + headers.setContentType(MediaType.APPLICATION_JSON); + + //autogenerate allow header if the service is configured + if(getRequestPathMethodService()!=null) { + String allowHeaderValue = getRequestPathMethodService().getMethodsForRequestPattern(httpRequest).orElse(httpRequest.getMethod()); + headers.add(HttpHeaders.ALLOW, allowHeaderValue); + } + return headers; + } + + /** + * The bean needs to be defined in the individual APIs + * + * @return + */ + AbstractRequestPathMethodService getRequestPathMethodService() { + return requestPathMethodService; + } +} + diff --git a/record-api-common/src/main/resources/mediacategories.xml b/record-api-common/src/main/resources/mediacategories.xml new file mode 100644 index 0000000..1d5985a --- /dev/null +++ b/record-api-common/src/main/resources/mediacategories.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/record-api-web/pom.xml b/record-api-web/pom.xml index e78c5d8..a6d8445 100644 --- a/record-api-web/pom.xml +++ b/record-api-web/pom.xml @@ -77,6 +77,10 @@ xml-apis xml-apis + + eu.europeana.api.commons + commons-error + diff --git a/record-api-web/src/main/java/eu/europeana/api/record/exception/GlobalExceptionHandler.java b/record-api-web/src/main/java/eu/europeana/api/record/exception/GlobalExceptionHandler.java index 6533c0d..4d84f10 100644 --- a/record-api-web/src/main/java/eu/europeana/api/record/exception/GlobalExceptionHandler.java +++ b/record-api-web/src/main/java/eu/europeana/api/record/exception/GlobalExceptionHandler.java @@ -1,16 +1,6 @@ package eu.europeana.api.record.exception; -import eu.europeana.api.record.model.ApiErrorResponse; -import jakarta.servlet.http.HttpServletRequest; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.web.ErrorProperties; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.ResponseBody; +import eu.europeana.api.service.EuropeanaGlobalExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; /** @@ -20,49 +10,6 @@ * Created on 24-08-2023 */ @RestControllerAdvice -@ResponseBody -class GlobalExceptionHandler { - - private static final Logger LOG = LogManager.getLogger(GlobalExceptionHandler.class); - - @Value("${server.error.include-stacktrace:ON_PARAM}") - private ErrorProperties.IncludeStacktrace includeStacktraceConfig; - - // with Spring boot 3 (and Spring Framework 6) require a baseline of Jakarte EE 10 - // You cannot use it with Java EE or Jakarte EE versions below that. - // You have to remove the explicit dependency on jakarta.servlet-api from your pom.xml. - // Java Servlet 4 is below the baseline and in particular still uses the package names starting with javax.servlet. - // If you remove the explicit dependency, Spring will pull in transitively the correct one. - // You then need to replace all imports starting with javax.servlet with javax replaced by jakarta - - protected void logException(RecordApiException e) { - if (e.doLog()) { - if (e.doLogStacktrace()) { - LOG.error("Caught exception", e); - } else { - LOG.error("Caught exception: {}", e.getMessage()); - } - } - - } - - protected boolean stackTraceEnabled() { - return this.includeStacktraceConfig != ErrorProperties.IncludeStacktrace.NEVER; - } - - - @ExceptionHandler - public ResponseEntity handleBaseException(RecordApiException e, HttpServletRequest httpRequest) { - this.logException(e); - ApiErrorResponse response = (new ApiErrorResponse.Builder(httpRequest, e, this.stackTraceEnabled())).setStatus(e.getResponseStatus().value()).setError - (e.getResponseStatus().getReasonPhrase()).setMessage(e.doExposeMessage() ? e.getMessage() : null).setCode(e.getErrorCode()).build(); - return ResponseEntity.status(e.getResponseStatus()).headers(this.createHttpHeaders()).body(response); - } - - protected HttpHeaders createHttpHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - return headers; - } +class GlobalExceptionHandler extends EuropeanaGlobalExceptionHandler { } \ No newline at end of file diff --git a/record-api-web/src/main/java/eu/europeana/api/record/exception/HttpBadRequestException.java b/record-api-web/src/main/java/eu/europeana/api/record/exception/HttpBadRequestException.java index 0247d5b..137fbdb 100644 --- a/record-api-web/src/main/java/eu/europeana/api/record/exception/HttpBadRequestException.java +++ b/record-api-web/src/main/java/eu/europeana/api/record/exception/HttpBadRequestException.java @@ -1,9 +1,10 @@ package eu.europeana.api.record.exception; +import eu.europeana.api.error.EuropeanaApiException; import org.springframework.http.HttpStatus; /** Exception thrown when an error occurs due to bad user input. */ -public class HttpBadRequestException extends RecordApiException { +public class HttpBadRequestException extends EuropeanaApiException { public HttpBadRequestException(String msg) { super(msg); diff --git a/record-api-web/src/main/java/eu/europeana/api/record/exception/RecordAlreadyExistsException.java b/record-api-web/src/main/java/eu/europeana/api/record/exception/RecordAlreadyExistsException.java index fdd69fb..71377ae 100644 --- a/record-api-web/src/main/java/eu/europeana/api/record/exception/RecordAlreadyExistsException.java +++ b/record-api-web/src/main/java/eu/europeana/api/record/exception/RecordAlreadyExistsException.java @@ -1,9 +1,10 @@ package eu.europeana.api.record.exception; +import eu.europeana.api.error.EuropeanaApiException; import org.springframework.http.HttpStatus; /** Exception thrown when a record already exists in the DB. */ -public class RecordAlreadyExistsException extends RecordApiException { +public class RecordAlreadyExistsException extends EuropeanaApiException { public RecordAlreadyExistsException(String about) { super("Record already exists for '" + about); diff --git a/record-api-web/src/main/java/eu/europeana/api/record/exception/RecordApiException.java b/record-api-web/src/main/java/eu/europeana/api/record/exception/RecordApiException.java deleted file mode 100644 index a752066..0000000 --- a/record-api-web/src/main/java/eu/europeana/api/record/exception/RecordApiException.java +++ /dev/null @@ -1,48 +0,0 @@ -package eu.europeana.api.record.exception; - -import org.springframework.http.HttpStatus; - -public class RecordApiException extends Exception { - - private static final long serialVersionUID = -1354471712894853562L; - private final String errorCode; - - public RecordApiException(String msg, Throwable t) { - this(msg, (String)null, t); - } - - public RecordApiException(String msg, String errorCode, Throwable t) { - super(msg, t); - this.errorCode = errorCode; - } - - public RecordApiException(String msg) { - super(msg); - this.errorCode = null; - } - - public RecordApiException(String msg, String errorCode) { - super(msg); - this.errorCode = errorCode; - } - - public boolean doLog() { - return true; - } - - public boolean doLogStacktrace() { - return true; - } - - public String getErrorCode() { - return this.errorCode; - } - - public boolean doExposeMessage() { - return true; - } - - public HttpStatus getResponseStatus() { - return HttpStatus.INTERNAL_SERVER_ERROR; - } -} diff --git a/record-api-web/src/main/java/eu/europeana/api/record/exception/RecordDoesNotExistsException.java b/record-api-web/src/main/java/eu/europeana/api/record/exception/RecordDoesNotExistsException.java index 0819c0c..cfe89b4 100644 --- a/record-api-web/src/main/java/eu/europeana/api/record/exception/RecordDoesNotExistsException.java +++ b/record-api-web/src/main/java/eu/europeana/api/record/exception/RecordDoesNotExistsException.java @@ -1,8 +1,9 @@ package eu.europeana.api.record.exception; +import eu.europeana.api.error.EuropeanaApiException; import org.springframework.http.HttpStatus; -public class RecordDoesNotExistsException extends RecordApiException { +public class RecordDoesNotExistsException extends EuropeanaApiException { public RecordDoesNotExistsException(String about) { super("Record does not exists for '" + about); diff --git a/record-api-web/src/main/java/eu/europeana/api/record/web/RecordController.java b/record-api-web/src/main/java/eu/europeana/api/record/web/RecordController.java index 62d8b5e..7912c9d 100644 --- a/record-api-web/src/main/java/eu/europeana/api/record/web/RecordController.java +++ b/record-api-web/src/main/java/eu/europeana/api/record/web/RecordController.java @@ -1,7 +1,7 @@ package eu.europeana.api.record.web; import eu.europeana.api.commons.web.http.HttpHeaders; -import eu.europeana.api.record.exception.RecordApiException; +import eu.europeana.api.error.EuropeanaApiException; import eu.europeana.api.record.exception.RecordDoesNotExistsException; import eu.europeana.api.record.model.ProvidedCHO; import eu.europeana.api.record.serialization.JsonLdSerializer;