diff --git a/doc/sphinx-guides/source/developers/api.rst b/doc/sphinx-guides/source/developers/api.rst new file mode 100644 index 00000000000..c1a503e7f60 --- /dev/null +++ b/doc/sphinx-guides/source/developers/api.rst @@ -0,0 +1,115 @@ +API development +=============== + +All features of Dataverse are mostly "API too", if not "API first". Making APIs great again helps building integrations +for Dataverse with third party systems, automation and many other use cases. + +Dataverse API is based on `Jersey `_ using JSON all over for both requests and +responses. + +Response Creation +----------------- + +To provide a consistent experience for developers, integrators and users, it's important to streamline what is sent +over the wire as responses. + +.. warning:: To be extended and changed with refactoring of API code to align code paths. + +`JsonResponseBuilder` +^^^^^^^^^^^^^^^^^^^^^ + +To make response building easier and aligned throught the API code base, a factory and decorator pattern based helper +utility has been introduced in `edu.harvard.iq.dataverse.api.util.JsonResponseBuilder`_. + +*Hint: Right now, only the exception handlers listed below use it.* + +Create a response builder by using ``JsonResponseBuilder.error()``. + +1. Add decorations as you see fit (messages, incident identifiers, ...). For current options, see the classes extensive Javadoc. +2. You can also use the same builder to log your response for the admins by using ``.log()`` before you ``.build()``. +3. When writing a filter for the servlet container, you may use ``.apply()`` to sent a response looking and feeling like + when used from a JAX-RS endpoint method. + +.. code-block:: java + :caption: A full fledged example (copied from the ``ThrowableHandler`` described below) + + // Create an instance (contains a human friendly message by default, but may be overridden) + return JSONResponseBuilder.error(Response.Status.INTERNAL_SERVER_ERROR) + // Add an identifier, so admins can find exceptions easier in the logs when users report + .randomIncidentId() + // Hint what went wrong internally by adding exception name & cause + .internalError(ex) + // Add the complete URL & HTTP method the client tried to use + .request(request) + // Write all this to the logs, including the stack trace from the exception + .log(logger, Level.SEVERE, Optional.of(ex)) + // Build the JAX-RS response. Done! + .build(); + +Exception Handling +------------------ + +We have to differ between three types of exceptions thrown during API usage: + +1. Servlet exceptions, based on `javax.servlet.ServletException `_ +2. JAX-RS-native exceptions, based on `javax.ws.rs.WebApplicationException `_ +3. Any other Java exception + +When accessing the API, requests are handled by the servlet container before handing over to Jersey and from there to +the lower layers of bean validation, database connections and all other stuff you might use through the API: + +.. graphviz:: + + digraph G { + rankdir = LR + + node [shape=plain] Client; + node [shape=egg] Network; + + subgraph cluster_0 { + node [shape=box,label="Filters, ..."] F; + node [shape=box,label="JAX-RS/Jersey"] J; + node [shape=box,label="Services/Persistence/..."] D; + + label = "Servlet Container"; + color = black; + } + Client -> Network -> F -> J -> D; + } + +Exceptions before handing over to JAX-RS +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The servlet container usually offers limited options to handle errors in elegant ways for an API, which is why you want +to avoid throwing exceptions there. However, it is possible: `see examples by Eugen Baeldung `_ + +You better avoid throwing exceptions from places like +`Filter `_\ s or similar, as those +require specialized code handling instead of going with the below JAX-RS exception handling. + +Exceptions after handing over to JAX-RS +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Cases 2) and 3) above are both happening in code easy to control in API code without fiddling with ``web.xml`` or similar. + +Easy to follow code examples and explanations: + +- https://www.baeldung.com/jersey-rest-api-with-spring#using-exceptionmapper (never mind this is about Spring, the technology is the same!) +- https://www.baeldung.com/jersey-bean-validation#custom-exception-handler + +For Dataverse, all API exception handlers live in package `edu.harvard.iq.dataverse.api.errorhandlers`_. +*Remember, this does not handle exceptions popping via web UI, thus not served by Jersey JAX-RS!* + +1. ``ThrowableHandler`` catches any Java exception thrown and uncatched in business logic (either by accident or on purpose) + or not having a more specialized ``ExceptionMapper``. It allows for handing out nice JSON error messages to users and + detailed logging for admins. +2. ``WebApplicationExceptionHandler`` catches all JAX-RS typed exceptions, which usually depict HTTP statuses. + Those "native" exceptions are commonly used for redirection, client and server errors, so better watch out what you + catch and what you do when the fish bit. +3. ``ConstraintViolationExceptionHandler`` allows for catching and formating bean validation exception from any + layer of validation (JAX-RS itself, persistence, ...) +4. ``JsonParseExceptionHandler`` catches and formats error messages when a user or admin send an invalid JSON document + to and endpoint and validation failed. (Usually contains hints about what is wrong...) + +.. _edu.harvard.iq.dataverse.api.errorhandlers: https://github.com/IQSS/dataverse/tree/develop/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers +.. _edu.harvard.iq.dataverse.api.util.JSONResponseBuilder: https://github.com/IQSS/dataverse/tree/develop/src/main/java/edu/harvard/iq/dataverse/api/util/JSONResponseBuilder.java \ No newline at end of file diff --git a/doc/sphinx-guides/source/developers/index.rst b/doc/sphinx-guides/source/developers/index.rst index 96595220e07..1a38206d1bb 100755 --- a/doc/sphinx-guides/source/developers/index.rst +++ b/doc/sphinx-guides/source/developers/index.rst @@ -17,6 +17,7 @@ Developer Guide troubleshooting version-control sql-upgrade-scripts + api testing documentation dependencies diff --git a/src/main/java/edu/harvard/iq/dataverse/api/ApiBlockingFilter.java b/src/main/java/edu/harvard/iq/dataverse/api/ApiBlockingFilter.java index 761a6c8ef77..a0f584736f1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/ApiBlockingFilter.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/ApiBlockingFilter.java @@ -25,7 +25,7 @@ * @author michael */ public class ApiBlockingFilter implements javax.servlet.Filter { - private static final String UNBLOCK_KEY_QUERYPARAM = "unblock-key"; + public static final String UNBLOCK_KEY_QUERYPARAM = "unblock-key"; interface BlockPolicy { public void doBlock(ServletRequest sr, ServletResponse sr1, FilterChain fc) throws IOException, ServletException; diff --git a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ArrayOutOfBoundsExceptionHandler.java b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ArrayOutOfBoundsExceptionHandler.java deleted file mode 100644 index 112f50eb3ed..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ArrayOutOfBoundsExceptionHandler.java +++ /dev/null @@ -1,38 +0,0 @@ -package edu.harvard.iq.dataverse.api.errorhandlers; - -import java.util.UUID; -import java.util.logging.Level; -import java.util.logging.Logger; -import javax.json.Json; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; - -/** - * Produces custom 500 messages for the API. - * @author michael - */ -@Provider -public class ArrayOutOfBoundsExceptionHandler implements ExceptionMapper{ - - private static final Logger logger = Logger.getLogger(ServeletExceptionHandler.class.getName()); - - @Context - HttpServletRequest request; - - @Override - public Response toResponse(java.lang.ArrayIndexOutOfBoundsException ex){ - String incidentId = UUID.randomUUID().toString(); - logger.log(Level.SEVERE, "API internal error " + incidentId +": ArrayOutOfBounds:" + ex.getMessage(), ex); - return Response.status(500) - .entity( Json.createObjectBuilder() - .add("status", "ERROR") - .add("code", 500) - .add("message", "Internal server error. More details available at the server logs.") - .add("incidentId", incidentId) - .build()) - .type("application/json").build(); - } -} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/BadRequestExceptionHandler.java b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/BadRequestExceptionHandler.java deleted file mode 100644 index f6412a871ac..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/BadRequestExceptionHandler.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package edu.harvard.iq.dataverse.api.errorhandlers; - -import edu.harvard.iq.dataverse.util.BundleUtil; -import javax.json.Json; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.BadRequestException; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; - -/** - * - * @author skraffmi - */ -@Provider -public class BadRequestExceptionHandler implements ExceptionMapper { - - @Context - HttpServletRequest request; - - @Override - public Response toResponse(BadRequestException ex) { - System.out.print( ex.getMessage()); - String uri = request.getRequestURI(); - String exMessage = ex.getMessage(); - String outputMessage; - if (exMessage != null && exMessage.toLowerCase().startsWith("tabular data required")) { - outputMessage = BundleUtil.getStringFromBundle("access.api.exception.metadata.not.available.for.nontabular.file"); - } else { - outputMessage = "Bad Request. The API request cannot be completed with the parameters supplied. Please check your code for typos, or consult our API guide at http://guides.dataverse.org."; - } - return Response.status(400) - .entity( Json.createObjectBuilder() - .add("status", "ERROR") - .add("code", 400) - .add("message", "'" + uri + "' " + outputMessage) - .build()) - .type("application/json").build(); - - - } - -} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ForbiddenExceptionHandler.java b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ForbiddenExceptionHandler.java deleted file mode 100644 index 8096b719832..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ForbiddenExceptionHandler.java +++ /dev/null @@ -1,36 +0,0 @@ -package edu.harvard.iq.dataverse.api.errorhandlers; - -import javax.json.Json; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.ForbiddenException; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; - -/** - * Produces custom 403 messages for the API. - * @author michael - */ -@Provider -public class ForbiddenExceptionHandler implements ExceptionMapper{ - - @Context - HttpServletRequest request; - - @Override - public Response toResponse(ForbiddenException ex){ - String uri = request.getRequestURI(); - return Response.status(403) - .entity( Json.createObjectBuilder() - .add("status", "ERROR") - .add("code", 403) - .add("message", "'" + uri + "' you are not authorized to access this object via this api endpoint. Please check your code for typos, or consult our API guide at http://guides.dataverse.org.") - .build()) - .type("application/json").build(); - - - } - -} - diff --git a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/InternalServerErrorExceptionHandler.java b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/InternalServerErrorExceptionHandler.java deleted file mode 100644 index f59dd5029fc..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/InternalServerErrorExceptionHandler.java +++ /dev/null @@ -1,39 +0,0 @@ -package edu.harvard.iq.dataverse.api.errorhandlers; - -import java.util.UUID; -import java.util.logging.Level; -import java.util.logging.Logger; -import javax.json.Json; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.InternalServerErrorException; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; - -/** - * Produces custom 500 messages for the API. - * @author qqmyers - */ -@Provider -public class InternalServerErrorExceptionHandler implements ExceptionMapper{ - - private static final Logger logger = Logger.getLogger(InternalServerErrorExceptionHandler.class.getName()); - - @Context - HttpServletRequest request; - - @Override - public Response toResponse(InternalServerErrorException ex){ - String incidentId = UUID.randomUUID().toString(); - logger.log(Level.SEVERE, "API internal error " + incidentId +": " + ex.getMessage(), ex); - return Response.status(500) - .entity( Json.createObjectBuilder() - .add("status", "ERROR") - .add("code", 500) - .add("message", "Internal server error. More details available at the server logs.") - .add("incidentId", incidentId) - .build()) - .type("application/json").build(); - } -} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/NotAllowedExceptionHandler.java b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/NotAllowedExceptionHandler.java deleted file mode 100644 index 5df16c9596d..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/NotAllowedExceptionHandler.java +++ /dev/null @@ -1,31 +0,0 @@ -package edu.harvard.iq.dataverse.api.errorhandlers; - -import javax.json.Json; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.NotAllowedException; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; - -@Provider -public class NotAllowedExceptionHandler implements ExceptionMapper{ - - @Context - HttpServletRequest request; - - @Override - public Response toResponse(NotAllowedException ex){ - String uri = request.getRequestURI(); - return Response.status(405) - .entity( Json.createObjectBuilder() - .add("status", "ERROR") - .add("code", 405) - .add("message", "'" + uri + "' endpoint does not support method '"+request.getMethod()+"'. Consult our API guide at http://guides.dataverse.org.") - .build()) - .type("application/json").build(); - - - } - -} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/NotFoundExceptionHandler.java b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/NotFoundExceptionHandler.java deleted file mode 100644 index 51c4f343f85..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/NotFoundExceptionHandler.java +++ /dev/null @@ -1,43 +0,0 @@ -package edu.harvard.iq.dataverse.api.errorhandlers; - -import javax.json.Json; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.NotFoundException; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; - -/** - * Produces custom 404 messages for the API. - * @author michael - */ -@Provider -public class NotFoundExceptionHandler implements ExceptionMapper{ - - @Context - HttpServletRequest request; - - @Override - public Response toResponse(NotFoundException ex){ - String uri = request.getRequestURI(); - String exMessage = ex.getMessage(); - String outputMessage; - if (exMessage != null && exMessage.toLowerCase().startsWith("datafile")) { - outputMessage = exMessage; - } else { - outputMessage = "endpoint does not exist on this server. Please check your code for typos, or consult our API guide at http://guides.dataverse.org."; - } - return Response.status(404) - .entity( Json.createObjectBuilder() - .add("status", "ERROR") - .add("code", 404) - .add("message", "'" + uri + "' " + outputMessage) - .build()) - .type("application/json").build(); - - - } - -} - diff --git a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/NullPointerExceptionHandler.java b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/NullPointerExceptionHandler.java deleted file mode 100644 index 1e9c2a28690..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/NullPointerExceptionHandler.java +++ /dev/null @@ -1,38 +0,0 @@ -package edu.harvard.iq.dataverse.api.errorhandlers; - -import java.util.UUID; -import java.util.logging.Level; -import java.util.logging.Logger; -import javax.json.Json; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; - -/** - * Produces custom 500 messages for the API. - * @author michael - */ -@Provider -public class NullPointerExceptionHandler implements ExceptionMapper{ - - private static final Logger logger = Logger.getLogger(ServeletExceptionHandler.class.getName()); - - @Context - HttpServletRequest request; - - @Override - public Response toResponse(java.lang.NullPointerException ex){ - String incidentId = UUID.randomUUID().toString(); - logger.log(Level.SEVERE, "API internal error " + incidentId +": Null Pointer", ex); - return Response.status(500) - .entity( Json.createObjectBuilder() - .add("status", "ERROR") - .add("code", 500) - .add("message", "Internal server error. More details available at the server logs.") - .add("incidentId", incidentId) - .build()) - .type("application/json").build(); - } -} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/RedirectionExceptionHandler.java b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/RedirectionExceptionHandler.java deleted file mode 100644 index 21e677180be..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/RedirectionExceptionHandler.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package edu.harvard.iq.dataverse.api.errorhandlers; - -import edu.harvard.iq.dataverse.util.BundleUtil; -import javax.json.Json; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.RedirectionException; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; - -/** - * - * @author qqmyers - */ -@Provider -public class RedirectionExceptionHandler implements ExceptionMapper { - - @Context - HttpServletRequest request; - - @Override - public Response toResponse(RedirectionException ex) { - return ex.getResponse(); - } - -} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ServeletExceptionHandler.java b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ServeletExceptionHandler.java deleted file mode 100644 index fa6bff57c03..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ServeletExceptionHandler.java +++ /dev/null @@ -1,38 +0,0 @@ -package edu.harvard.iq.dataverse.api.errorhandlers; - -import java.util.UUID; -import java.util.logging.Level; -import java.util.logging.Logger; -import javax.json.Json; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; - -/** - * Produces custom 500 messages for the API. - * @author michael - */ -@Provider -public class ServeletExceptionHandler implements ExceptionMapper{ - - private static final Logger logger = Logger.getLogger(ServeletExceptionHandler.class.getName()); - - @Context - HttpServletRequest request; - - @Override - public Response toResponse(javax.servlet.ServletException ex){ - String incidentId = UUID.randomUUID().toString(); - logger.log(Level.SEVERE, "API internal error " + incidentId +": " + ex.getMessage(), ex); - return Response.status(500) - .entity( Json.createObjectBuilder() - .add("status", "ERROR") - .add("code", 500) - .add("message", "Internal server error. More details available at the server logs.") - .add("incidentId", incidentId) - .build()) - .type("application/json").build(); - } -} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ServiceUnavailableExceptionHandler.java b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ServiceUnavailableExceptionHandler.java deleted file mode 100644 index ebed03a13da..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ServiceUnavailableExceptionHandler.java +++ /dev/null @@ -1,43 +0,0 @@ -package edu.harvard.iq.dataverse.api.errorhandlers; - -import javax.json.Json; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.ServiceUnavailableException; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; - -/** - * Produces custom 503 messages for the API. - * @author michael - */ -@Provider -public class ServiceUnavailableExceptionHandler implements ExceptionMapper{ - - @Context - HttpServletRequest request; - - @Override - public Response toResponse(ServiceUnavailableException ex){ - String uri = request.getRequestURI(); - String exMessage = ex.getMessage(); - String outputMessage; - if (exMessage != null && exMessage.toLowerCase().startsWith("datafile")) { - outputMessage = exMessage; - } else { - outputMessage = "Requested service or method not available on the requested object"; - } - return Response.status(503) - .entity( Json.createObjectBuilder() - .add("status", "ERROR") - .add("code", 503) - .add("message", "'" + uri + "' " + outputMessage) - .build()) - .type("application/json").build(); - - - } - -} - diff --git a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ThrowableHandler.java b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ThrowableHandler.java index 8514d099787..7b7f1f08fad 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ThrowableHandler.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ThrowableHandler.java @@ -1,19 +1,21 @@ package edu.harvard.iq.dataverse.api.errorhandlers; -import javax.annotation.Priority; -import javax.json.Json; +import edu.harvard.iq.dataverse.api.util.JsonResponseBuilder; + import javax.servlet.http.HttpServletRequest; import javax.ws.rs.core.Context; -import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; -import java.util.UUID; +import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; /** * Produces a generic 500 message for the API, being a fallback handler for not specially treated exceptions. + * + * This catches bad exceptions like ArrayOutOfBoundsExceptions, NullPointerExceptions and ServeletExceptions, + * which had formerly specialised handlers, generating a generic error message. (This is now handled here.) */ @Provider public class ThrowableHandler implements ExceptionMapper{ @@ -25,46 +27,11 @@ public class ThrowableHandler implements ExceptionMapper{ @Override public Response toResponse(Throwable ex){ - String incidentId = UUID.randomUUID().toString(); - logger.log(Level.SEVERE, "Uncaught REST API exception:\n"+ - " Incident: " + incidentId +"\n"+ - " URL: "+getOriginalURL(request)+"\n"+ - " Method: "+request.getMethod(), ex); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity( Json.createObjectBuilder() - .add("status", "ERROR") - .add("code", Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()) - .add("type", ex.getClass().getSimpleName()) - .add("message", "Internal server error. More details available at the server logs.") - .add("incidentId", incidentId) - .build()) - .type(MediaType.APPLICATION_JSON_TYPE).build(); - } - - private String getOriginalURL(HttpServletRequest req) { - // Rebuild the original request URL: http://stackoverflow.com/a/5212336/356408 - String scheme = req.getScheme(); // http - String serverName = req.getServerName(); // hostname.com - int serverPort = req.getServerPort(); // 80 - String contextPath = req.getContextPath(); // /mywebapp - String servletPath = req.getServletPath(); // /servlet/MyServlet - String pathInfo = req.getPathInfo(); // /a/b;c=123 - String queryString = req.getQueryString(); // d=789 - - // Reconstruct original requesting URL - StringBuilder url = new StringBuilder(); - url.append(scheme).append("://").append(serverName); - if (serverPort != 80 && serverPort != 443) { - url.append(":").append(serverPort); - } - url.append(contextPath).append(servletPath); - if (pathInfo != null) { - url.append(pathInfo); - } - if (queryString != null) { - url.append("?").append(queryString); - } - - return url.toString(); + return JsonResponseBuilder.error(Response.Status.INTERNAL_SERVER_ERROR) + .randomIncidentId() + .internalError(ex) + .request(request) + .log(logger, Level.SEVERE, Optional.of(ex)) + .build(); } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/WebApplicationExceptionHandler.java b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/WebApplicationExceptionHandler.java new file mode 100644 index 00000000000..b6229e58192 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/WebApplicationExceptionHandler.java @@ -0,0 +1,103 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package edu.harvard.iq.dataverse.api.errorhandlers; + +import edu.harvard.iq.dataverse.api.util.JsonResponseBuilder; +import edu.harvard.iq.dataverse.util.BundleUtil; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Catches all types of web application exceptions like NotFoundException, etc etc and handles them properly. + */ +@Provider +public class WebApplicationExceptionHandler implements ExceptionMapper { + + static final Logger logger = Logger.getLogger(WebApplicationExceptionHandler.class.getSimpleName()); + + @Context + HttpServletRequest request; + + @Override + public Response toResponse(WebApplicationException ex) { + + // If this is not a HTTP client or server error, just pass the response. + if (ex.getResponse().getStatus() < 400) + return ex.getResponse(); + + // Otherwise, do stuff. + JsonResponseBuilder jrb = JsonResponseBuilder.error(ex.getResponse()); + + // See also https://en.wikipedia.org/wiki/List_of_HTTP_status_codes for a list of status codes. + switch (ex.getResponse().getStatus()) { + // BadRequest + case 400: + if ( (ex.getMessage()+"").toLowerCase().startsWith("tabular data required")) { + jrb.message(BundleUtil.getStringFromBundle("access.api.exception.metadata.not.available.for.nontabular.file")); + } else { + jrb.message("Bad Request. The API request cannot be completed with the parameters supplied. Please check your code for typos, or consult our API guide at http://guides.dataverse.org."); + jrb.request(request); + } + break; + // Forbidden + case 403: + jrb.message("Not authorized to access this object via this API endpoint. Please check your code for typos, or consult our API guide at http://guides.dataverse.org."); + jrb.request(request); + break; + // NotFound + case 404: + if ( (ex.getMessage()+"").toLowerCase().startsWith("datafile")) { + jrb.message(ex.getMessage()); + } else { + jrb.message("API endpoint does not exist on this server. Please check your code for typos, or consult our API guide at http://guides.dataverse.org."); + jrb.request(request); + } + break; + // MethodNotAllowed + case 405: + jrb.message("API endpoint does not support this method. Consult our API guide at http://guides.dataverse.org."); + jrb.request(request); + break; + // NotAcceptable (might be content type, charset, encoding or language) + case 406: + jrb.message("API endpoint does not accept your request. Consult our API guide at http://guides.dataverse.org."); + jrb.request(request); + jrb.requestContentType(request); + break; + // InternalServerError + case 500: + jrb.randomIncidentId(); + jrb.internalError(ex); + jrb.request(request); + jrb.log(logger, Level.SEVERE, Optional.of(ex)); + break; + // ServiceUnavailable + case 503: + if ( (ex.getMessage()+"").toLowerCase().startsWith("datafile")) { + jrb.message(ex.getMessage()); + } else { + jrb.message("Requested service or method not available on the requested object"); + } + break; + default: + jrb.message(ex.getMessage()); + break; + } + + // Logging for debugging. Will not double-log messages. + jrb.log(logger, Level.FINEST, Optional.of(ex)); + return jrb.build(); + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/util/JsonResponseBuilder.java b/src/main/java/edu/harvard/iq/dataverse/api/util/JsonResponseBuilder.java new file mode 100644 index 00000000000..1cc9e8d77f5 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/util/JsonResponseBuilder.java @@ -0,0 +1,267 @@ +package edu.harvard.iq.dataverse.api.util; + +import edu.harvard.iq.dataverse.api.ApiBlockingFilter; + +import javax.json.Json; +import javax.json.JsonObjectBuilder; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.io.IOException; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class JsonResponseBuilder { + + private JsonObjectBuilder entityBuilder = Json.createObjectBuilder(); + private Response.ResponseBuilder jerseyResponseBuilder; + private boolean alreadyLogged = false; + + private JsonResponseBuilder() {} + + /** + * Create an error response from an numeric error code (should be >= 400) + * @param httpStatusCode Numerical HTTP status code + * @return A builder with a basic JSON body + * @throws IllegalArgumentException if fromResponse isn't an error response + */ + public static JsonResponseBuilder error(int httpStatusCode) { + if (httpStatusCode < 400) { + throw new IllegalArgumentException("A status code < 400 cannot be an error indicating response."); + } + + JsonResponseBuilder b = new JsonResponseBuilder(); + b.jerseyResponseBuilder = Response.status(httpStatusCode); + b.entityBuilder.add("status", "ERROR"); + b.entityBuilder.add("code", httpStatusCode); + // include default message if present + getDefaultMessage(httpStatusCode).ifPresent(v -> b.entityBuilder.add("message", v)); + + return b; + } + + /** + * Create an error response from a Response.Status. + * @param status A JAX-RS Response.Status object + * @return A builder with a basic JSON body + * @throws IllegalArgumentException if fromResponse isn't an error response + */ + public static JsonResponseBuilder error(Response.Status status) { + JsonResponseBuilder b = error(status.getStatusCode()); + b.jerseyResponseBuilder = Response.status(status); + return b; + } + + /** + * Create an error response from an existing response. + * @param fromResponse An existing JAX-RS Response + * @return A builder with a basic JSON body + * @throws IllegalArgumentException if fromResponse isn't an error response + */ + public static JsonResponseBuilder error(Response fromResponse) { + JsonResponseBuilder b = error(fromResponse.getStatus()); + b.jerseyResponseBuilder = Response.fromResponse(fromResponse); + return b; + } + + /** + * Add a human friendly message to the response + * @param message A human readable message + * @return The enhanced builder + */ + public JsonResponseBuilder message(String message) { + this.entityBuilder.add("message", message); + return this; + } + + /** + * Set an identifier for this (usually included in logs, too). + * @param id A String containing an (ideally unique) identifier + * @return The enhanced builder + */ + public JsonResponseBuilder incidentId(String id) { + this.entityBuilder.add("incidentId", id); + return this; + } + + /** + * Add a UUID random identifier for errors. Will overwrite existing. + * @return The enhanced builder + */ + public JsonResponseBuilder randomIncidentId() { + this.entityBuilder.add("incidentId", UUID.randomUUID().toString()); + return this; + } + + /** + * Add more details about the original request: what URL was used, + * what HTTP method involved? + * @param request The original request (usually provided from a context) + * @return The enhanced builder + */ + public JsonResponseBuilder request(HttpServletRequest request) { + this.entityBuilder.add("requestUrl", getOriginalURL(request)); + this.entityBuilder.add("requestMethod", request.getMethod()); + return this; + } + + /** + * Add more details about the original request: what content type was sent? + * @param request The original request (usually provided from a context) + * @return The enhanced builder + */ + public JsonResponseBuilder requestContentType(HttpServletRequest request) { + this.entityBuilder.add("requestContentType", request.getContentType()); + return this; + } + + /** + * Add more details about internal errors (exceptions) to the response. + * Will include a detail about the cause if exception has one. + * @param ex An exception. + * @return The enhanced builder + */ + public JsonResponseBuilder internalError(Throwable ex) { + this.entityBuilder.add("interalError", ex.getClass().getSimpleName()); + if (ex.getCause() != null) { + this.entityBuilder.add("internalCause", ex.getCause().getClass().getSimpleName()); + } + return this; + } + + /** + * Finish building a Jersey JAX-RS response with JSON message + * @return JAX-RS response including JSON message + */ + public Response build() { + return jerseyResponseBuilder.type(MediaType.APPLICATION_JSON_TYPE) + .entity(this.entityBuilder.build()) + .build(); + } + + /** + * For usage in non-Jersey areas like servlet filters, blocks, etc., + * apply the response to the Servlet provided response object. + * @param response The ServletResponse from the context + * @throws IOException + */ + public void apply(ServletResponse response) throws IOException { + HttpServletResponse httpServletResponse = (HttpServletResponse) response; + apply(httpServletResponse); + } + + /** + * For usage in non-Jersey areas like servlet filters, blocks, etc., + * apply the response to the Servlet provided response object. + * @param response The HttpServletResponse from the context + * @throws IOException + */ + public void apply(HttpServletResponse response) throws IOException { + Response jersey = jerseyResponseBuilder.build(); + response.setStatus(jersey.getStatus()); + response.setContentType(MediaType.APPLICATION_JSON); + response.getWriter().print(entityBuilder.build().toString()); + response.getWriter().flush(); + } + + /** + * Log this JSON response as a useful log message. + * Should be done before calling build(), but after adding any decorations. + * + * The log message will contain a message with the flattened JSON entity: + * { "status": "ERROR", "code": 401 } -> _status=ERROR;_code=401 + * + * Will prevent logging the same response twice. + * + * @param logger Provide a logger instance to write to + * @param level Provide a level at which this should be logged + * @return The unmodified builder. + */ + public JsonResponseBuilder log(Logger logger, Level level) { + return this.log(logger, level, Optional.empty()); + } + + /** + * Log this JSON response as a useful log message. + * Should be done before calling build(), but after adding any decorations. + * + * The log message will contain a message with the flattened JSON entity: + * { "status": "ERROR", "code": 401 } -> _status=ERROR;_code=401 + * + * If an exception is given, it will be folled by a "|" and the exception message + * formatted by the logging system itself. + * + * Will prevent logging the same response twice. + * + * @param logger Provide a logger instance to write to + * @param level Provide a level at which this should be logged + * @param ex An optional exception to be included in the log message. + * @return The unmodified builder. + */ + public JsonResponseBuilder log(Logger logger, Level level, Optional ex) { + if ( ! logger.isLoggable(level) || alreadyLogged ) + return this; + + StringBuilder metadata = new StringBuilder(""); + this.entityBuilder.build() + .forEach((k,v) -> metadata.append("_"+k+"="+v.toString()+";")); + // remove trailing ; + metadata.deleteCharAt(metadata.length()-1); + + if (ex.isPresent()) { + metadata.append("|"); + logger.log(level, metadata.toString(), ex); + } else { + logger.log(level, metadata.toString()); + } + + this.alreadyLogged = true; + return this; + } + + /** + * Build a complete request URL for logging purposes. + * Masks query parameter "unblock-key" if present to avoid leaking secrets. + * @param req The request + * @return The requests URL sent by the client + */ + public static String getOriginalURL(HttpServletRequest req) { + // Rebuild the original request URL: http://stackoverflow.com/a/5212336/356408 + String scheme = req.getScheme(); // http + String serverName = req.getServerName(); // hostname.com + int serverPort = req.getServerPort(); // 80 + String contextPath = req.getContextPath(); // /mywebapp + String servletPath = req.getServletPath(); // /servlet/MyServlet + String pathInfo = req.getPathInfo(); // /a/b;c=123 + String queryString = req.getQueryString(); // d=789 + + // Reconstruct original requesting URL + StringBuilder url = new StringBuilder(); + url.append(scheme).append("://").append(serverName); + if (serverPort != 80 && serverPort != 443) { + url.append(":").append(serverPort); + } + url.append(contextPath).append(servletPath); + if (pathInfo != null) { + url.append(pathInfo); + } + if (queryString != null) { + // filter for unblock-key parameter and mask the key + String maskedQueryString = queryString.replaceAll(ApiBlockingFilter.UNBLOCK_KEY_QUERYPARAM+"=.+?\\b", ApiBlockingFilter.UNBLOCK_KEY_QUERYPARAM+"=****"); + url.append("?").append(maskedQueryString); + } + + return url.toString(); + } + + public static Optional getDefaultMessage(int httpStatusCode) { + switch (httpStatusCode) { + case 500: return Optional.of("Internal server error. More details available at the server logs."); + default: return Optional.empty(); + } + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/api/util/JSONResponseBuilderTest.java b/src/test/java/edu/harvard/iq/dataverse/api/util/JSONResponseBuilderTest.java new file mode 100644 index 00000000000..c9ec80d6d31 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/util/JSONResponseBuilderTest.java @@ -0,0 +1,31 @@ +package edu.harvard.iq.dataverse.api.util; + +import edu.harvard.iq.dataverse.api.ApiBlockingFilter; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EmptySource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; + +import javax.servlet.http.HttpServletRequest; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class JSONResponseBuilderTest { + + @ParameterizedTest + @NullSource + @EmptySource + @ValueSource(strings = { + "test", + ApiBlockingFilter.UNBLOCK_KEY_QUERYPARAM+"=supersecret", + ApiBlockingFilter.UNBLOCK_KEY_QUERYPARAM+"=supersecret&hello=1234", + "hello=1234&"+ApiBlockingFilter.UNBLOCK_KEY_QUERYPARAM+"=supersecret", + "hello=1234&"+ApiBlockingFilter.UNBLOCK_KEY_QUERYPARAM+"=supersecret&test=1234"}) + void testMaskingOriginalURL(String query) { + HttpServletRequest test = Mockito.mock(HttpServletRequest.class); + when(test.getQueryString()).thenReturn(query); + assertFalse(JsonResponseBuilder.getOriginalURL(test).contains("supersecret")); + } +} \ No newline at end of file