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..d3c6fd2df50 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/util/JsonResponseBuilder.java @@ -0,0 +1,269 @@ +package edu.harvard.iq.dataverse.api.util; + +import edu.harvard.iq.dataverse.api.ApiBlockingFilter; + +import javax.json.Json; +import javax.json.JsonValue; +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) { + String type = request.getContentType(); + this.entityBuilder.add("requestContentType", ((type==null) ? JsonValue.NULL : Json.createValue(type))); + 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..a6da689da7a --- /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