This is an automated email from the ASF dual-hosted git repository. dklco pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/sling-whiteboard.git
The following commit(s) were added to refs/heads/master by this push: new 1c377cc7 Adding initial version of JSON Servlet 1c377cc7 is described below commit 1c377cc755aaab6b40f32ba6c93a9437b93c3c9d Author: Dan Klco <k...@adobe.com> AuthorDate: Tue Jun 28 21:14:14 2022 -0400 Adding initial version of JSON Servlet --- org.apache.sling.servlets.json/.editorconfig | 14 + org.apache.sling.servlets.json/README.md | 34 +++ org.apache.sling.servlets.json/pom.xml | 178 ++++++++++++ .../sling/servlets/json/BaseJsonServlet.java | 322 +++++++++++++++++++++ .../sling/servlets/json/JacksonJsonServlet.java | 135 +++++++++ .../sling/servlets/json/problem/Problem.java | 125 ++++++++ .../servlets/json/problem/ProblemBuilder.java | 291 +++++++++++++++++++ .../sling/servlets/json/problem/Problematic.java | 26 ++ .../servlets/json/problem/ThrowableProblem.java | 36 +++ .../servlets/json/JacksonJsonServletTest.java | 164 +++++++++++ .../org/apache/sling/servlets/json/SamplePojo.java | 26 ++ .../servlets/json/TestJacksonJsonServlet.java | 55 ++++ .../servlets/json/problem/ProblemBuilderTest.java | 201 +++++++++++++ 13 files changed, 1607 insertions(+) diff --git a/org.apache.sling.servlets.json/.editorconfig b/org.apache.sling.servlets.json/.editorconfig new file mode 100644 index 00000000..cc902561 --- /dev/null +++ b/org.apache.sling.servlets.json/.editorconfig @@ -0,0 +1,14 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +end_of_line = lf +trim_trailing_whitespace = true +charset = utf-8 + +[*.java] +indent_style = space +indent_size = 4 +insert_final_newline = true diff --git a/org.apache.sling.servlets.json/README.md b/org.apache.sling.servlets.json/README.md new file mode 100644 index 00000000..d3b265ac --- /dev/null +++ b/org.apache.sling.servlets.json/README.md @@ -0,0 +1,34 @@ +# Apache Sling JSON Servlet Support + +Sling excels at delivering content and experiences, however creating APIs isn't as smooth of an experience. + +Apache Sling JSON Servlet support enables developers to easily create REST API's on top of Apache Sling. This library includes: + + - Implementation of non-Resource-based Servlets for creating OSGi Whiteboard APIs + - Built-in methods for deserializing requests and serializing responses to JSON + - Built-in support for [RFC-7807 JSON Problem](https://datatracker.ietf.org/doc/html/rfc7807) responses + - Extensions of Java's default HttpServlet which ensures exceptions are returned as JSON Problem responses with reasonable HTTP codes + +## Use + +After installing the bundle, you can create Servlets using the OSGi HTTP whiteboard context: + + @Component(service = { Servlet.class }) + @HttpWhiteboardContextSelect("(" + HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_NAME + "=com.company.restapi)") + @HttpWhiteboardServletPattern("/myapi/*") + public class MyApiServlet extends JacksonJsonServlet { + + protected void doPost(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) + throws ServletException, IOException { + Map<String, Object> properties = super.readRequestBody(request, new TypeReference<Map<String, Object>>() { + }); + String name = properties.get("name"); + if (StringUtils.isBlank(name)) { + super.sendProblemResponse(response, ProblemBuilder.get().withStatus(400).withDetails("Please provide a name").build()); + } else { + Map responseBody = Map.of("message", "Greetings " + name); + super.sendJsonResponse(response, responseBody); + } + } + } + diff --git a/org.apache.sling.servlets.json/pom.xml b/org.apache.sling.servlets.json/pom.xml new file mode 100644 index 00000000..6c42b606 --- /dev/null +++ b/org.apache.sling.servlets.json/pom.xml @@ -0,0 +1,178 @@ +<?xml version="1.0" encoding="ISO-8859-1"?> +<!-- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <parent> + <groupId>org.apache.sling</groupId> + <artifactId>sling-bundle-parent</artifactId> + <version>48</version> + <relativePath /> + </parent> + + <artifactId>org.apache.sling.servlets.json</artifactId> + <version>1.0.0-SNAPSHOT</version> + <name>Apache Sling - JSON Servlet</name> + + <properties> + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> + <sling.java.version>11</sling.java.version> + <bnd.baseline.fail.on.missing>false</bnd.baseline.fail.on.missing> + + <required.coverage>0.85</required.coverage> + </properties> + + <build> + <plugins> + <plugin> + <groupId>biz.aQute.bnd</groupId> + <artifactId>bnd-maven-plugin</artifactId> + </plugin> + <plugin> + <groupId>org.jacoco</groupId> + <artifactId>jacoco-maven-plugin</artifactId> + <version>0.8.7</version> + <executions> + <execution> + <goals> + <goal>prepare-agent</goal> + </goals> + </execution> + <execution> + <id>report</id> + <phase>prepare-package</phase> + <goals> + <goal>report</goal> + </goals> + </execution> + <execution> + <id>jacoco-check</id> + <goals> + <goal>check</goal> + </goals> + <configuration> + <rules> + <rule> + <element>PACKAGE</element> + <limits> + <limit> + <counter>LINE</counter> + <value>COVEREDRATIO</value> + <minimum>${required.coverage}</minimum> + </limit> + </limits> + </rule> + </rules> + </configuration> + </execution> + </executions> + </plugin> + + <plugin> + <groupId>org.ec4j.maven</groupId> + <artifactId>editorconfig-maven-plugin</artifactId> + <version>0.1.1</version> + <executions> + <execution> + <id>check</id> + <phase>verify</phase> + <goals> + <goal>check</goal> + </goals> + </execution> + </executions> + <configuration> + <excludes> + <exclude>src/test/scripts/run/*</exclude> + </excludes> + </configuration> + </plugin> + </plugins> + </build> + + <dependencies> + <dependency> + <groupId>javax.jcr</groupId> + <artifactId>jcr</artifactId> + </dependency> + <dependency> + <groupId>org.apache.sling</groupId> + <artifactId>org.apache.sling.api</artifactId> + <version>2.24.0</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.apache.sling</groupId> + <artifactId>org.apache.sling.auth.core</artifactId> + <version>1.5.6</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>javax.servlet</groupId> + <artifactId>servlet-api</artifactId> + <version>2.5</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.osgi</groupId> + <artifactId>org.osgi.annotation.versioning</artifactId> + <version>1.0.0</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.osgi</groupId> + <artifactId>org.osgi.service.component.annotations</artifactId> + <version>1.3.0</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.osgi</groupId> + <artifactId>org.osgi.service.metatype.annotations</artifactId> + <version>1.3.0</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-databind</artifactId> + <version>2.13.3</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-api</artifactId> + </dependency> + <dependency> + <groupId>org.osgi</groupId> + <artifactId>org.osgi.annotation.versioning</artifactId> + </dependency> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter</artifactId> + <version>5.8.2</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.apache.sling</groupId> + <artifactId>org.apache.sling.testing.sling-mock.junit5</artifactId> + <version>3.3.0</version> + <scope>test</scope> + </dependency> + </dependencies> +</project> \ No newline at end of file diff --git a/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/BaseJsonServlet.java b/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/BaseJsonServlet.java new file mode 100644 index 00000000..99325107 --- /dev/null +++ b/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/BaseJsonServlet.java @@ -0,0 +1,322 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sling.servlets.json; + +import java.io.IOException; +import java.util.Optional; +import java.util.Set; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.auth.core.AuthenticationSupport; +import org.apache.sling.servlets.json.problem.Problem; +import org.apache.sling.servlets.json.problem.ProblemBuilder; +import org.apache.sling.servlets.json.problem.Problematic; +import org.jetbrains.annotations.NotNull; +import org.osgi.annotation.versioning.ConsumerType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.type.TypeReference; + +/** + * An extension of the SlingAllMethodsServlet tailored to producing JSON APIs. + * <p> + * This class adds support for PATCH requests and adds several different useful + * base methods for reading the response body as JSON, writing an object as JSON + * and sending problems as RFC 7807-compliant JSON+Problem responses + * <p> + * This class also catches ServletException, IOException and + * RuntimeExceptions thrown from the called methods and sends a JSON + * Problem response based on the thrown exception + */ +@ConsumerType +public abstract class BaseJsonServlet extends HttpServlet { + + private static final String RESPONSE_CONTENT_TYPE = "application/json"; + + private static final Set<String> SERVLET_SUPPORTED_METHODS = Set.of("GET", "HEAD", "POST", "PUT", "DELETE", + "OPTIONS", "TRACE"); + + private static final Logger log = LoggerFactory.getLogger(BaseJsonServlet.class); + + /** + * Called by the + * {@link #service(HttpServletRequest, HttpServletResponse)} method + * to handle an HTTP <em>GET</em> request. + * <p> + * This default implementation reports back to the client that the method is + * not supported. + * <p> + * Implementations of this class should overwrite this method with their + * implementation for the HTTP <em>PATCH</em> method support. + * + * @param request The HTTP request + * @param response The HTTP response + * @throws ServletException Not thrown by this implementation. + * @throws IOException If the error status cannot be reported back to the + * client. + */ + @Override + protected void doGet(final HttpServletRequest req, final HttpServletResponse resp) + throws ServletException, IOException { + sendProblemResponse(resp, ProblemBuilder.get().withStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED).build()); + } + + /** + * Called by the + * {@link #service(HttpServletRequest, HttpServletResponse)} method + * to handle an HTTP <em>POST</em> request. + * <p> + * This default implementation reports back to the client that the method is + * not supported. + * <p> + * Implementations of this class should overwrite this method with their + * implementation for the HTTP <em>PATCH</em> method support. + * + * @param request The HTTP request + * @param response The HTTP response + * @throws ServletException Not thrown by this implementation. + * @throws IOException If the error status cannot be reported back to the + * client. + */ + @Override + protected void doPost(final HttpServletRequest req, final HttpServletResponse resp) + throws ServletException, IOException { + sendProblemResponse(resp, ProblemBuilder.get().withStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED).build()); + } + + /** + * Called by the + * {@link #service(HttpServletRequest, HttpServletResponse)} method + * to handle an HTTP <em>PUT</em> request. + * <p> + * This default implementation reports back to the client that the method is + * not supported. + * <p> + * Implementations of this class should overwrite this method with their + * implementation for the HTTP <em>PATCH</em> method support. + * + * @param request The HTTP request + * @param response The HTTP response + * @throws ServletException Not thrown by this implementation. + * @throws IOException If the error status cannot be reported back to the + * client. + */ + @Override + protected void doPut(final HttpServletRequest req, final HttpServletResponse resp) + throws ServletException, IOException { + sendProblemResponse(resp, ProblemBuilder.get().withStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED).build()); + } + + /** + * Called by the + * {@link #service(HttpServletRequest, HttpServletResponse)} method + * to handle an HTTP <em>DELETE</em> request. + * <p> + * This default implementation reports back to the client that the method is + * not supported. + * <p> + * Implementations of this class should overwrite this method with their + * implementation for the HTTP <em>PATCH</em> method support. + * + * @param request The HTTP request + * @param response The HTTP response + * @throws ServletException Not thrown by this implementation. + * @throws IOException If the error status cannot be reported back to the + * client. + */ + @Override + protected void doDelete(final HttpServletRequest req, final HttpServletResponse resp) + throws ServletException, IOException { + sendProblemResponse(resp, ProblemBuilder.get().withStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED).build()); + } + + /** + * Called by the + * {@link #service(HttpServletRequest, HttpServletResponse)} method + * to handle an HTTP <em>PATCH</em> request. + * <p> + * This default implementation reports back to the client that the method is + * not supported. + * <p> + * Implementations of this class should overwrite this method with their + * implementation for the HTTP <em>PATCH</em> method support. + * + * @param request The HTTP request + * @param response The HTTP response + * @throws ServletException Not thrown by this implementation. + * @throws IOException If the error status cannot be reported back to the + * client. + */ + protected void doPatch(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) + throws ServletException, IOException { + handleMethodNotImplemented(request, response); + } + + /** + * Retrieves a <code>ResourceResolver</code> that can be used to perform various + * operations against the underlying repository. + * + * @return Resolver for performing operations. Will not be null. + * @throws LoginException unable to find resource resolver in request + */ + public @NotNull ResourceResolver getResourceResolver(@NotNull HttpServletRequest request) throws LoginException { + return Optional.ofNullable(request.getAttribute(AuthenticationSupport.REQUEST_ATTRIBUTE_RESOLVER)) + .map(ResourceResolver.class::cast) + .orElseThrow(() -> new LoginException("Could not get ResourceResolver from request")); + } + + /** + * Tries to handle the request by calling a Java method implemented for the + * respective HTTP request method. + * <p> + * This implementation first calls the base class implementation and only if + * the base class cannot dispatch will try to dispatch the supported methods + * <em>PATCH</em> + * <p> + * In addition, this method catches ServletException, IOException and + * RuntimeExceptions thrown from the called methods and sends a JSON + * Problem response based on the thrown exception + * + * @param request The HTTP request + * @param response The HTTP response + * @return <code>true</code> if the requested method + * (<code>request.getMethod()</code>) + * is known. Otherwise <code>false</code> is returned. + * @throws ServletException Forwarded from any of the dispatched methods + * @throws IOException Forwarded from any of the dispatched methods + */ + @Override + protected void service(@NotNull HttpServletRequest request, + @NotNull HttpServletResponse response) throws ServletException, + IOException { + final String method = request.getMethod(); + try { + // assume the method is known for now + if (SERVLET_SUPPORTED_METHODS.contains(method)) { + super.service(request, response); + } else if ("PATCH".equals(method)) { + doPatch(request, response); + } else { + handleMethodNotImplemented(request, response); + } + } catch (IOException | ServletException | RuntimeException e) { + if (e instanceof Problematic) { + sendProblemResponse(response, ((Problematic) e).getProblem()); + } else { + log.error("Handing uncaught exception", e); + sendProblemResponse(response, ProblemBuilder.get().fromException(e).build()); + } + } + + } + + /** + * Read an object from the request, handing invalid or missing request bodies + * and returning a 400 response. + * + * @param <T> the type of object to be read from the request + * @param request the request from which to read the object + * @param type the class of the type to read + * @return the object read from the request + */ + protected abstract <T> T readRequestBody(HttpServletRequest request, Class<T> type); + + /** + * Read an object from the request, handing invalid or missing request bodies + * and returning a 400 response. + * + * @param <T> the type of object to be read from the request + * @param request the request from which to read the object + * @param type the class of the type to read + * @return the object read from the request + */ + protected abstract <T> T readRequestBody(HttpServletRequest request, TypeReference<T> type); + + /** + * Sends a JSON response with the content type application/json and a 200 status + * code. + * + * @param response the response to which to write + * @param responseBody the object to write to the response + * @throws IOException an exception occurs writing the object to the response + */ + protected void sendJsonResponse(HttpServletResponse response, Object responseBody) + throws IOException { + sendJsonResponse(response, HttpServletResponse.SC_OK, responseBody); + } + + /** + * Sends a JSON response with the content type application/json + * + * @param response the response to which to write + * @param statusCode the status code to send for the response + * @param responseBody the object to write to the response + * @throws IOException an exception occurs writing the object to the response + */ + protected void sendJsonResponse(HttpServletResponse response, int statusCode, Object responseBody) + throws IOException { + sendJsonResponse(response, statusCode, RESPONSE_CONTENT_TYPE, responseBody); + } + + /** + * Sends a JSON response by serializing the responseBody object into JSON + * + * @param response the response to which to write + * @param statusCode the status code to send for the response + * @param contentType the content type to send for the response + * @param responseBody the object to write to the response + * @throws IOException an exception occurs writing the object to the response + */ + protected abstract void sendJsonResponse(HttpServletResponse response, int statusCode, String contentType, + Object responseBody) throws IOException; + + /** + * Sends a problem response, setting the status based on the status of the + * ProblemBuilder, the content type application/problem+json and the body being + * the the problem JSON + * + * @param response the response to which to write the problem + * @param problemBuilder the problem to write + * @throws IOException Thrown if the problem cannot be written to the response + */ + protected void sendProblemResponse(HttpServletResponse response, Problem problem) + throws IOException { + sendJsonResponse(response, problem.getStatus(), ProblemBuilder.RESPONSE_CONTENT_TYPE, + problem); + } + + /** + * Helper method which causes an method not allowed HTTP and JSON problem + * response to be sent for an unhandled HTTP request method. + * + * @param request Required for method override + * @param response The HTTP response to which the error status is sent. + * @throws IOException Thrown if the status cannot be sent to the client. + */ + protected void handleMethodNotImplemented(@NotNull HttpServletRequest request, + @NotNull HttpServletResponse response) throws IOException { + sendProblemResponse(response, + ProblemBuilder.get().withStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED).build()); + } +} diff --git a/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/JacksonJsonServlet.java b/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/JacksonJsonServlet.java new file mode 100644 index 00000000..d0402747 --- /dev/null +++ b/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/JacksonJsonServlet.java @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sling.servlets.json; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.sling.servlets.json.problem.ProblemBuilder; +import org.osgi.annotation.versioning.ConsumerType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.ObjectWriter; + +/** + * An extension of the BaseJsonServlet using Jackson for serialization. + */ +@ConsumerType +public abstract class JacksonJsonServlet extends BaseJsonServlet { + + private static final Logger log = LoggerFactory.getLogger(JacksonJsonServlet.class); + + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final ObjectWriter objectWriter = objectMapper.writer(); + private static final ObjectReader objectReader = objectMapper.reader(); + + /** + * Provides the Jackson ObjectWriter instance to use for writing objects to the + * response. + * <p> + * Implementations of this class can overwrite this method to customize the + * behavior of the ObjectWiter + * + * @return the ObjectWriter + */ + protected ObjectWriter getObjectWriter() { + return objectWriter; + } + + /** + * Provides the Jackson ObjectReader instance to use for reading objects from + * the request. + * <p> + * Implementations of this class can overwrite this method to customize the + * behavior of the ObjectReader + * + * @return the ObjectReader + */ + protected ObjectReader getObjectReader() { + return objectReader; + } + + /** + * Read an object from the request, handing invalid or missing request bodies + * and returning a 400 response. + * + * @param <T> the type of object to be read from the request + * @param request the request from which to read the object + * @param type the class of the type to read + * @return the object read from the request + */ + @Override + protected <T> T readRequestBody(HttpServletRequest request, Class<T> type) { + try { + return getObjectReader().readValue(request.getReader(), type); + } catch (IOException e) { + throw ProblemBuilder.get().withStatus(HttpServletResponse.SC_BAD_REQUEST) + .withDetail("Unable to parse request as JSON: " + e.getMessage()).buildThrowable(); + } + } + + /** + * Read an object from the request, handing invalid or missing request bodies + * and returning a 400 response. + * + * @param <T> the type of object to be read from the request + * @param request the request from which to read the object + * @param type the class of the type to read + * @return the object read from the request + */ + @Override + protected <T> T readRequestBody(HttpServletRequest request, TypeReference<T> type) { + try { + return getObjectReader().forType(type).readValue(request.getReader()); + } catch (IOException e) { + throw ProblemBuilder.get().withStatus(HttpServletResponse.SC_BAD_REQUEST) + .withDetail("Unable to parse request as JSON: " + e.getMessage()).buildThrowable(); + } + } + + /** + * Sends a JSON response + * + * @param response the response to which to write + * @param statusCode the status code to send for the response + * @param contentType the content type to send for the response + * @param responseBody the object to write to the response + * @throws IOException an exception occurs writing the object to the response + */ + @Override + protected void sendJsonResponse(HttpServletResponse response, int statusCode, String contentType, + Object responseBody) throws IOException { + if (!response.isCommitted()) { + response.reset(); + response.setStatus(statusCode); + response.setContentType(contentType); + response.setCharacterEncoding(StandardCharsets.UTF_8.toString()); + } else { + // Response already committed: don't change status + log.warn("Response already committed, unable to change status, output might not be well formed"); + } + response.getWriter().write(getObjectWriter().writeValueAsString(responseBody)); + } + +} diff --git a/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/problem/Problem.java b/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/problem/Problem.java new file mode 100644 index 00000000..fbb0b949 --- /dev/null +++ b/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/problem/Problem.java @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sling.servlets.json.problem; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.osgi.annotation.versioning.ConsumerType; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +@ConsumerType +@JsonInclude(Include.NON_NULL) +public class Problem { + + private final URI type; + private final String title; + private final int status; + private final String detail; + private final URI instance; + + @JsonAnySetter + @JsonAnyGetter + private final Map<String, Object> custom; + + /** + * @param type + * @param title + * @param status + * @param detail + * @param instance + * @param custom + */ + @JsonCreator + public Problem(@JsonProperty("type") String type, + @JsonProperty("title") String title, + @JsonProperty("status") int status, + @JsonProperty("detail") String detail, + @JsonProperty("instance") String instance) { + this.type = Optional.ofNullable(type).map(URI::create).orElse(null); + this.title = title; + this.status = status; + this.detail = detail; + this.instance = Optional.ofNullable(instance).map(URI::create).orElse(null); + this.custom = new HashMap<>(); + } + + /** + * @return the type + */ + public URI getType() { + return type; + } + + /** + * @return the title + */ + public String getTitle() { + return title; + } + + /** + * @return the status + */ + public int getStatus() { + return status; + } + + /** + * @return the detail + */ + public String getDetail() { + return detail; + } + + /** + * @return the instance + */ + public URI getInstance() { + return instance; + } + + /** + * @return the custom + */ + @JsonIgnore + public Map<String, Object> getCustom() { + return custom; + } + + /* + * (non-Javadoc) + * + * @see java.lang.Object#toString() + */ + + @Override + public String toString() { + return "Problem [custom=" + custom + ", detail=" + detail + ", instance=" + instance + ", status=" + status + + ", title=" + title + ", type=" + type + "]"; + } + +} diff --git a/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/problem/ProblemBuilder.java b/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/problem/ProblemBuilder.java new file mode 100644 index 00000000..8ad02e29 --- /dev/null +++ b/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/problem/ProblemBuilder.java @@ -0,0 +1,291 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sling.servlets.json.problem; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import javax.jcr.InvalidItemStateException; +import javax.jcr.InvalidLifecycleTransitionException; +import javax.jcr.InvalidSerializedDataException; +import javax.jcr.ItemExistsException; +import javax.jcr.ItemNotFoundException; +import javax.jcr.MergeException; +import javax.jcr.NoSuchWorkspaceException; +import javax.jcr.PathNotFoundException; +import javax.jcr.ReferentialIntegrityException; +import javax.jcr.RepositoryException; +import javax.jcr.UnsupportedRepositoryOperationException; +import javax.jcr.ValueFormatException; +import javax.jcr.lock.LockException; +import javax.jcr.nodetype.ConstraintViolationException; +import javax.jcr.nodetype.InvalidNodeTypeDefinitionException; +import javax.jcr.nodetype.NoSuchNodeTypeException; +import javax.jcr.nodetype.NodeTypeExistsException; +import javax.jcr.query.InvalidQueryException; +import javax.jcr.version.VersionException; +import javax.servlet.http.HttpServletResponse; + +import org.apache.sling.api.SlingException; +import org.apache.sling.api.resource.QuerySyntaxException; +import org.apache.sling.api.resource.ResourceNotFoundException; +import org.jetbrains.annotations.NotNull; +import org.osgi.annotation.versioning.ProviderType; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@ProviderType +public class ProblemBuilder { + + public static final String RESPONSE_CONTENT_TYPE = "application/problem+json"; + + private static final String PN_TYPE = "type"; + private static final String PN_TITLE = "title"; + private static final String PN_STATUS = "status"; + private static final String PN_DETAIL = "detail"; + private static final String PN_INSTANCE = "instance"; + private int status; + + private static final Set<String> RESERVED_PROPERTIES = Set.of(PN_TYPE, PN_TITLE, PN_STATUS, PN_DETAIL, PN_INSTANCE); + + private Map<String, Object> properties = new HashMap<>(); + + public static ProblemBuilder get() { + return new ProblemBuilder(); + } + + private ProblemBuilder() { + withStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + + public ProblemBuilder withType(@NotNull final URI type) { + properties.put(PN_TYPE, type); + return this; + } + + public ProblemBuilder withTitle(@NotNull final String title) { + properties.put(PN_TITLE, title); + return this; + } + + public ProblemBuilder withStatus(@NotNull final int status) { + properties.put(PN_STATUS, status); + this.status = status; + return this; + } + + public ProblemBuilder fromException(Exception ex) { + if (ex instanceof SlingException) { + return fromSlingException((SlingException) ex); + } + if (ex instanceof RepositoryException) { + return fromRepositoryException((RepositoryException) ex); + } + if (ex instanceof org.apache.sling.api.resource.LoginException) { + withStatus(HttpServletResponse.SC_UNAUTHORIZED); + withDetail(ex.toString()); + } else if (ex.getCause() instanceof Exception) { + fromException((Exception) ex.getCause()); + withDetail(ex.toString() + "\nCause: " + properties.get(PN_DETAIL)); + } else { + withStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + withDetail(ex.toString()); + } + return this; + } + + public ProblemBuilder fromSlingException(@NotNull SlingException exception) { + if (exception instanceof ResourceNotFoundException) { + withStatus(HttpServletResponse.SC_NOT_FOUND); + } else if (exception instanceof QuerySyntaxException) { + withStatus(HttpServletResponse.SC_BAD_REQUEST); + } else { + withStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + withDetail(exception.toString()); + return this; + } + + public ProblemBuilder fromRepositoryException(@NotNull RepositoryException exception) { + if (exception instanceof InvalidQueryException + || exception instanceof NoSuchNodeTypeException + || exception instanceof ConstraintViolationException + || exception instanceof InvalidLifecycleTransitionException + || exception instanceof InvalidNodeTypeDefinitionException + || exception instanceof InvalidSerializedDataException + || exception instanceof ReferentialIntegrityException + || exception instanceof UnsupportedRepositoryOperationException + || exception instanceof ValueFormatException + || exception instanceof VersionException) { + withStatus(HttpServletResponse.SC_BAD_REQUEST); + } else if (exception instanceof javax.jcr.LoginException) { + withStatus(HttpServletResponse.SC_UNAUTHORIZED); + } else if (exception instanceof javax.jcr.AccessDeniedException + || exception instanceof javax.jcr.security.AccessControlException) { + withStatus(HttpServletResponse.SC_FORBIDDEN); + } else if ((exception instanceof ItemNotFoundException) + || (exception instanceof PathNotFoundException) + || exception instanceof NoSuchWorkspaceException) { + withStatus(HttpServletResponse.SC_NOT_FOUND); + } else if (exception instanceof ItemExistsException + || exception instanceof InvalidItemStateException + || exception instanceof LockException + || exception instanceof MergeException + || exception instanceof NodeTypeExistsException) { + withStatus(HttpServletResponse.SC_CONFLICT); + } else { + withStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + withDetail(exception.toString()); + return this; + } + + public ProblemBuilder withDetail(@NotNull final String detail) { + properties.put(PN_DETAIL, detail); + return this; + } + + public ProblemBuilder withInstance(@NotNull final URI instance) { + properties.put(PN_INSTANCE, instance); + return this; + } + + public ProblemBuilder with(final String key, @NotNull final Object value) throws IllegalArgumentException { + if (RESERVED_PROPERTIES.contains(key)) { + throw new IllegalArgumentException("Property " + key + " is reserved"); + } + properties.put(key, value); + return this; + } + + public int getStatus() { + return status; + } + + public static String statusToString(int statusCode) { + switch (statusCode) { // NOSONAR + case 100: + return "Continue"; + case 101: + return "Switching Protocols"; + case 102: + return "Processing (WebDAV)"; + case 200: + return "OK"; + case 201: + return "Created"; + case 202: + return "Accepted"; + case 203: + return "Non-Authoritative Information"; + case 204: + return "No Content"; + case 205: + return "Reset Content"; + case 206: + return "Partial Content"; + case 207: + return "Multi-Status (WebDAV)"; + case 300: + return "Multiple Choices"; + case 301: + return "Moved Permanently"; + case 302: + return "Found"; + case 303: + return "See Other"; + case 304: + return "Not Modified"; + case 305: + return "Use Proxy"; + case 307: + return "Temporary Redirect"; + case 400: + return "Bad Request"; + case 401: + return "Unauthorized"; + case 402: + return "Payment Required"; + case 403: + return "Forbidden"; + case 404: + return "Not Found"; + case 405: + return "Method Not Allowed"; + case 406: + return "Not Acceptable"; + case 407: + return "Proxy Authentication Required"; + case 408: + return "Request Time-out"; + case 409: + return "Conflict"; + case 410: + return "Gone"; + case 411: + return "Length Required"; + case 412: + return "Precondition Failed"; + case 413: + return "Request Entity Too Large"; + case 414: + return "Request-URI Too Large"; + case 415: + return "Unsupported Media Type"; + case 416: + return "Requested range not satisfiable"; + case 417: + return "Expectation Failed"; + case 422: + return "Unprocessable Entity (WebDAV)"; + case 423: + return "Locked (WebDAV)"; + case 424: + return "Failed Dependency (WebDAV)"; + case 500: + return "Internal Server Error"; + case 501: + return "Not Implemented"; + case 502: + return "Bad Gateway"; + case 503: + return "Service Unavailable"; + case 504: + return "Gateway Time-out"; + case 505: + return "HTTP Version not supported"; + case 507: + return "Insufficient Storage (WebDAV)"; + case 510: + return "Not Extended"; + default: + return String.valueOf(statusCode); + } + } + + public Problem build() { + properties.computeIfAbsent(PN_TITLE, k -> statusToString(getStatus())); + return new ObjectMapper().convertValue(properties, Problem.class); + } + + public ThrowableProblem buildThrowable() { + return new ThrowableProblem(build()); + } + +} diff --git a/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/problem/Problematic.java b/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/problem/Problematic.java new file mode 100644 index 00000000..49885872 --- /dev/null +++ b/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/problem/Problematic.java @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sling.servlets.json.problem; + +import org.osgi.annotation.versioning.ConsumerType; + +@ConsumerType +public interface Problematic { + + Problem getProblem(); + +} diff --git a/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/problem/ThrowableProblem.java b/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/problem/ThrowableProblem.java new file mode 100644 index 00000000..0f1fdb28 --- /dev/null +++ b/org.apache.sling.servlets.json/src/main/java/org/apache/sling/servlets/json/problem/ThrowableProblem.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sling.servlets.json.problem; + +import org.osgi.annotation.versioning.ConsumerType; + +@ConsumerType +public class ThrowableProblem extends RuntimeException implements Problematic { + + private final transient Problem problem; + + public ThrowableProblem(Problem problem) { + this.problem = problem; + } + + /** + * @return the problemBuilder + */ + public Problem getProblem() { + return problem; + } +} diff --git a/org.apache.sling.servlets.json/src/test/java/org/apache/sling/servlets/json/JacksonJsonServletTest.java b/org.apache.sling.servlets.json/src/test/java/org/apache/sling/servlets/json/JacksonJsonServletTest.java new file mode 100644 index 00000000..d2697ab5 --- /dev/null +++ b/org.apache.sling.servlets.json/src/test/java/org/apache/sling/servlets/json/JacksonJsonServletTest.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sling.servlets.json; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.util.stream.Stream; + +import javax.jcr.PathNotFoundException; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.sling.testing.mock.sling.junit5.SlingContext; +import org.apache.sling.testing.mock.sling.junit5.SlingContextExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +@ExtendWith(SlingContextExtension.class) +class JacksonJsonServletTest { + + private final SlingContext context = new SlingContext(); + + @ParameterizedTest + @ValueSource(strings = { "GET", "PATCH", "POST" }) + void testBasicServlet(String method) throws ServletException, IOException { + String body = "{\"Hello\":\"World\"}"; + + context.request().setContent(body.getBytes()); + context.request().setMethod(method); + + TestJacksonJsonServlet testServlet = new TestJacksonJsonServlet(); + testServlet.service(context.request(), context.response()); + + assertEquals(200, context.response().getStatus()); + assertEquals("application/json;charset=UTF-8", context.response().getContentType()); + assertEquals(body, context.response().getOutputAsString()); + } + + @ParameterizedTest + @ValueSource(strings = { "PUT", "DELEEETE" }) + void testUnsupported(String method) throws ServletException, IOException { + context.request().setMethod(method); + + TestJacksonJsonServlet testServlet = new TestJacksonJsonServlet(); + testServlet.service(context.request(), context.response()); + + assertEquals(405, context.response().getStatus()); + assertEquals("application/problem+json;charset=UTF-8", context.response().getContentType()); + assertEquals("{\"title\":\"Method Not Allowed\",\"status\":405}", context.response().getOutputAsString()); + } + + @ParameterizedTest + @ValueSource(strings = { "GET", "PATCH", "POST", "PUT", "DELETE" }) + void sendsNotAllowedByDefault(String method) throws ServletException, IOException { + + context.request().setMethod(method); + + JacksonJsonServlet defaultServlet = new JacksonJsonServlet() { + }; + defaultServlet.service(context.request(), context.response()); + + assertEquals(405, context.response().getStatus()); + assertEquals("application/problem+json;charset=UTF-8", context.response().getContentType()); + assertEquals("{\"title\":\"Method Not Allowed\",\"status\":405}", context.response().getOutputAsString()); + } + + @Test + void canDeserializeObject() throws ServletException, IOException { + + JacksonJsonServlet defaultServlet = new JacksonJsonServlet() { + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + SamplePojo model = super.readRequestBody(req, SamplePojo.class); + assertEquals("Sling", model.getTitle()); + } + }; + + context.request().setMethod("POST"); + context.request().setContent("{\"title\":\"Sling\"}".getBytes()); + defaultServlet.service(context.request(), context.response()); + + assertEquals(200, context.response().getStatus()); + } + + @Test + void returns400OnInvalidJsonBody() throws ServletException, IOException { + + JacksonJsonServlet defaultServlet = new JacksonJsonServlet() { + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + SamplePojo model = super.readRequestBody(req, SamplePojo.class); + assertEquals("Sling", model.getTitle()); + } + }; + + context.request().setMethod("POST"); + context.request().setContent("{\"title\",\"Sling\"}".getBytes()); + defaultServlet.service(context.request(), context.response()); + + assertEquals(400, context.response().getStatus()); + assertEquals("application/problem+json;charset=UTF-8", context.response().getContentType()); + + assertEquals( + "{\"title\":\"Bad Request\",\"status\":400,\"detail\":\"Unable to parse request as JSON: Unexpected character (',' (code 44)): was expecting a colon to separate field name and value\\n at [Source: (BufferedReader); line: 1, column: 10]\"}", + context.response().getOutputAsString()); + } + + @ParameterizedTest + @MethodSource("provideExceptions") + void catchesExceptions(Exception ex, int statusCode) throws ServletException, IOException { + context.request().setMethod("GET"); + + JacksonJsonServlet throwyServlet = new JacksonJsonServlet() { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + if (ex instanceof RuntimeException) { + throw (RuntimeException) ex; + } + if (ex instanceof ServletException) { + throw (ServletException) ex; + } + if (ex instanceof IOException) { + throw (IOException) ex; + } + fail("Unexpected exception type"); + } + }; + throwyServlet.service(context.request(), context.response()); + + assertEquals(statusCode, context.response().getStatus()); + assertEquals("application/problem+json;charset=UTF-8", context.response().getContentType()); + } + + static Stream<Arguments> provideExceptions() throws Exception { + return Stream.of( + Arguments.of(new RuntimeException(), 500), + Arguments.of(new RuntimeException("Bad", new PathNotFoundException()), 404)); + } + +} diff --git a/org.apache.sling.servlets.json/src/test/java/org/apache/sling/servlets/json/SamplePojo.java b/org.apache.sling.servlets.json/src/test/java/org/apache/sling/servlets/json/SamplePojo.java new file mode 100644 index 00000000..a56b717c --- /dev/null +++ b/org.apache.sling.servlets.json/src/test/java/org/apache/sling/servlets/json/SamplePojo.java @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sling.servlets.json; + +public class SamplePojo { + + private String title; + + public String getTitle() { + return title; + } +} diff --git a/org.apache.sling.servlets.json/src/test/java/org/apache/sling/servlets/json/TestJacksonJsonServlet.java b/org.apache.sling.servlets.json/src/test/java/org/apache/sling/servlets/json/TestJacksonJsonServlet.java new file mode 100644 index 00000000..3c550c91 --- /dev/null +++ b/org.apache.sling.servlets.json/src/test/java/org/apache/sling/servlets/json/TestJacksonJsonServlet.java @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sling.servlets.json; + +import java.io.IOException; +import java.util.Map; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.jetbrains.annotations.NotNull; + +import com.fasterxml.jackson.core.type.TypeReference; + +public class TestJacksonJsonServlet extends JacksonJsonServlet { + + private void echo(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) + throws ServletException, IOException { + + Map<String, Object> properties = super.readRequestBody(request, new TypeReference<Map<String, Object>>() { + }); + super.sendJsonResponse(response, properties); + } + + protected void doGet(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) + throws ServletException, IOException { + echo(request, response); + } + + protected void doPatch(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) + throws ServletException, IOException { + echo(request, response); + } + + protected void doPost(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) + throws ServletException, IOException { + echo(request, response); + } + +} diff --git a/org.apache.sling.servlets.json/src/test/java/org/apache/sling/servlets/json/problem/ProblemBuilderTest.java b/org.apache.sling.servlets.json/src/test/java/org/apache/sling/servlets/json/problem/ProblemBuilderTest.java new file mode 100644 index 00000000..4bea61d0 --- /dev/null +++ b/org.apache.sling.servlets.json/src/test/java/org/apache/sling/servlets/json/problem/ProblemBuilderTest.java @@ -0,0 +1,201 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sling.servlets.json.problem; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +import javax.jcr.AccessDeniedException; +import javax.jcr.InvalidItemStateException; +import javax.jcr.InvalidLifecycleTransitionException; +import javax.jcr.InvalidSerializedDataException; +import javax.jcr.ItemExistsException; +import javax.jcr.ItemNotFoundException; +import javax.jcr.LoginException; +import javax.jcr.MergeException; +import javax.jcr.NoSuchWorkspaceException; +import javax.jcr.PathNotFoundException; +import javax.jcr.ReferentialIntegrityException; +import javax.jcr.RepositoryException; +import javax.jcr.UnsupportedRepositoryOperationException; +import javax.jcr.ValueFormatException; +import javax.jcr.lock.LockException; +import javax.jcr.nodetype.ConstraintViolationException; +import javax.jcr.nodetype.InvalidNodeTypeDefinitionException; +import javax.jcr.nodetype.NoSuchNodeTypeException; +import javax.jcr.nodetype.NodeTypeExistsException; +import javax.jcr.query.InvalidQueryException; +import javax.jcr.query.Query; +import javax.jcr.security.AccessControlException; +import javax.jcr.version.VersionException; +import javax.servlet.http.HttpServletResponse; + +import org.apache.sling.api.SlingException; +import org.apache.sling.api.resource.QuerySyntaxException; +import org.apache.sling.api.resource.ResourceNotFoundException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +class ProblemBuilderTest { + + private ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void noParams() throws JsonProcessingException { + Problem built = ProblemBuilder.get().build(); + assertEquals(Collections.emptyMap(), built.getCustom()); + assertNull(built.getDetail()); + assertNull(built.getInstance()); + assertEquals(500, built.getStatus()); + assertEquals("Internal Server Error", built.getTitle()); + assertNull(built.getType()); + + assertEquals("{\"title\":\"Internal Server Error\",\"status\":500}", objectMapper.writeValueAsString(built)); + } + + @Test + void supportsStatusOnly() throws JsonProcessingException { + Problem built = ProblemBuilder.get().withStatus(HttpServletResponse.SC_GONE).build(); + assertEquals(Collections.emptyMap(), built.getCustom()); + assertNull(built.getDetail()); + assertNull(built.getInstance()); + assertEquals(410, built.getStatus()); + assertEquals("Gone", built.getTitle()); + assertNull(built.getType()); + assertEquals("{\"title\":\"Gone\",\"status\":410}", objectMapper.writeValueAsString(built)); + } + + @Test + void supportsAllProps() { + Problem built = ProblemBuilder.get().withStatus(HttpServletResponse.SC_GONE).withDetail("DETAIL") + .withInstance(URI.create("https://www.apache.org/")).withTitle( + "TITLE") + .withType(URI.create("https://sling.apache.org/")).build(); + assertEquals(Collections.emptyMap(), built.getCustom()); + assertEquals("DETAIL", built.getDetail()); + assertEquals(URI.create("https://www.apache.org/"), built.getInstance()); + assertEquals(410, built.getStatus()); + assertEquals("TITLE", built.getTitle()); + assertEquals(URI.create("https://sling.apache.org/"), built.getType()); + } + + @Test + void supportsCustom() throws JsonProcessingException { + Problem built = ProblemBuilder.get().with("test", "value").build(); + assertEquals("value", built.getCustom().get("test")); + assertEquals("{\"title\":\"Internal Server Error\",\"status\":500,\"test\":\"value\"}", + objectMapper.writeValueAsString(built)); + } + + @Test + void protectsReservedProperties() throws JsonProcessingException { + assertThrows(IllegalArgumentException.class, () -> ProblemBuilder.get().with("type", "test")); + } + + @Test + void assertStatusCodesMaptoStrings() { + for (int i = 100; i < 600; i++) { + Problem problem = ProblemBuilder.get().withStatus(i).build(); + assertNotNull(problem.getTitle()); + } + } + + @Test + void canGetThrowableProblem() throws JsonProcessingException { + ProblemBuilder builder = ProblemBuilder.get().with("test", "value"); + Problem built = builder.build(); + assertEquals(objectMapper.writeValueAsString(built), + objectMapper.writeValueAsString(builder.buildThrowable().getProblem())); + } + + @ParameterizedTest + @MethodSource("provideExceptions") + void supportsWithException(Exception exception, int status, String title, String detail) { + Problem built = ProblemBuilder.get().fromException(exception).build(); + assertEquals(detail, built.getDetail()); + assertEquals(status, built.getStatus()); + assertEquals(title, built.getTitle()); + } + + static Arguments createArg(Exception ex, int status, String title) throws Exception { + return Arguments.of(ex, status, title, ex.toString()); + } + + static Stream<Arguments> provideExceptions() throws Exception { + + List<Arguments> testContent = List.of( + + // 400's + createArg(new InvalidQueryException("Bad"), 400, "Bad Request"), + createArg(new NoSuchNodeTypeException("Bad"), 400, "Bad Request"), + createArg(new ConstraintViolationException("Bad"), 400, "Bad Request"), + createArg(new InvalidLifecycleTransitionException("Bad"), 400, "Bad Request"), + createArg(new InvalidNodeTypeDefinitionException("Bad"), 400, "Bad Request"), + createArg(new InvalidSerializedDataException("Bad"), 400, "Bad Request"), + createArg(new ReferentialIntegrityException("Bad"), 400, "Bad Request"), + createArg(new UnsupportedRepositoryOperationException("Bad"), 400, "Bad Request"), + createArg(new ValueFormatException("Bad"), 400, "Bad Request"), + createArg(new VersionException("Bad"), 400, "Bad Request"), + createArg(new QuerySyntaxException("Bad", "SELECT * FROM [nt:file]", Query.JCR_SQL2), 400, + "Bad Request"), + + // 401's + createArg(new LoginException("Bad"), 401, "Unauthorized"), + createArg(new org.apache.sling.api.resource.LoginException("Bad"), 401, "Unauthorized"), + + // 403's + createArg(new AccessDeniedException("Bad"), 403, "Forbidden"), + createArg(new AccessControlException("Bad"), 403, "Forbidden"), + + // 404's + createArg(new ResourceNotFoundException("/content", "Bad"), 404, "Not Found"), + createArg(new ItemNotFoundException("Bad"), 404, "Not Found"), + createArg(new PathNotFoundException("Bad"), 404, "Not Found"), + createArg(new NoSuchWorkspaceException("Bad"), 404, "Not Found"), + + // 409's + createArg(new ItemExistsException("Bad"), 409, "Conflict"), + createArg(new InvalidItemStateException("Bad"), 409, "Conflict"), + createArg(new LockException("Bad"), 409, "Conflict"), + createArg(new MergeException("Bad"), 409, "Conflict"), + createArg(new NodeTypeExistsException("Bad"), 409, "Conflict"), + + // 500's + createArg(new RepositoryException("Bad"), 500, "Internal Server Error"), + createArg(new SlingException("Bad", new Exception()), 500, "Internal Server Error"), + createArg(new IOException("Bad"), 500, "Internal Server Error")); + + return Stream.concat(testContent.stream(), testContent.stream().map(a -> { + Object[] args = a.get(); + return Arguments.of(new Exception("Wrapped", (Exception) args[0]), args[1], args[2], + "java.lang.Exception: Wrapped\nCause: " + args[3]); + })); + } +}