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]);
+        }));
+    }
+}