You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by dk...@apache.org on 2022/06/29 01:14:32 UTC
[sling-whiteboard] branch master updated: Adding initial version of JSON Servlet
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 <kl...@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]);
+ }));
+ }
+}