You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tapestry.apache.org by th...@apache.org on 2021/10/07 21:40:18 UTC

[tapestry-5] branch rest updated: TAP5-2696: REST support

This is an automated email from the ASF dual-hosted git repository.

thiagohp pushed a commit to branch rest
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git


The following commit(s) were added to refs/heads/rest by this push:
     new 7430e94  TAP5-2696: REST support
7430e94 is described below

commit 7430e94d9e563eb085a45fe025df58fe49c62344
Author: Thiago H. de Paula Figueiredo <th...@arsmachina.com.br>
AuthorDate: Thu Oct 7 18:40:07 2021 -0300

    TAP5-2696: REST support
---
 .../commons/internal/services/TypeCoercerImpl.java |   8 +-
 .../commons/util/CoercionFailedException.java      |  24 +-
 .../commons/util/CoercionNotFoundException.java    |  55 +++
 .../java/org/apache/tapestry5/EventConstants.java  |  48 +-
 .../java/org/apache/tapestry5/SymbolConstants.java |  38 ++
 .../apache/tapestry5/annotations/RequestBody.java  |  53 ++
 .../tapestry5/internal/InternalConstants.java      |  82 ++++
 .../services/ComponentResultProcessorWrapper.java  |   9 +
 .../DefaultOpenApiDescriptionGenerator.java        | 539 +++++++++++++++++++++
 .../services/DefaultRequestExceptionHandler.java   |  20 +-
 .../internal/services/PageActivatorImpl.java       |  43 +-
 .../internal/services/ResourceStreamerImpl.java    |  15 +-
 .../services/RestEndpointNotFoundException.java    |  35 ++
 .../internal/transform/OnEventWorker.java          | 114 ++++-
 .../java/org/apache/tapestry5/modules/.gitignore   |   1 +
 .../apache/tapestry5/modules/TapestryModule.java   |  37 +-
 .../apache/tapestry5/runtime/ComponentEvent.java   |   2 +-
 .../services/OpenApiDescriptionGenerator.java      |  41 ++
 .../test/app1/StaticActivationContextValueDemo.tml |   4 +-
 tapestry-core/src/test/app1/WEB-INF/app.properties |  26 +
 .../integration/app1/base/BaseRestDemoPage.java    |  46 ++
 .../tapestry5/integration/app1/pages/Index.java    |   4 +
 .../app1/pages/OpenApiDescriptionDemo.java         |  26 +-
 .../app1/pages/RestRequestNotHandledDemo.java      |  22 +-
 .../pages/RestWithEventHandlerMethodNameDemo.java  |  62 +++
 .../app1/pages/RestWithOnEventDemo.java            |  76 +++
 .../integration/app1/services/AppModule.java       |   1 +
 .../tapestry5/integration/rest/RestTests.java      | 201 ++++++++
 .../DefaultRequestExceptionHandlerTest.java        |   2 +-
 .../internal/services/RestSupportImplTest.java     | 155 ++++++
 .../integration/app1/pages/nested/AssetDemo.tml    |   2 +-
 .../TypeCoercerHttpRequestBodyConverter.java       |  49 ++
 .../http/internal/services/RestSupportImpl.java    |  87 ++++
 .../tapestry5/http/modules/TapestryHttpModule.java |  77 +++
 .../http/services/HttpRequestBodyConverter.java    |  41 ++
 .../tapestry5/http/services/RestSupport.java       |  76 +++
 .../tapestry5/internal/json/StringToJSONArray.java |   2 +-
 .../internal/json/StringToJSONObject.java          |   2 +-
 38 files changed, 2041 insertions(+), 84 deletions(-)

diff --git a/commons/src/main/java/org/apache/tapestry5/commons/internal/services/TypeCoercerImpl.java b/commons/src/main/java/org/apache/tapestry5/commons/internal/services/TypeCoercerImpl.java
index b89eb64..8afa737 100644
--- a/commons/src/main/java/org/apache/tapestry5/commons/internal/services/TypeCoercerImpl.java
+++ b/commons/src/main/java/org/apache/tapestry5/commons/internal/services/TypeCoercerImpl.java
@@ -28,6 +28,8 @@ import org.apache.tapestry5.commons.services.Coercion;
 import org.apache.tapestry5.commons.services.CoercionTuple;
 import org.apache.tapestry5.commons.services.TypeCoercer;
 import org.apache.tapestry5.commons.util.AvailableValues;
+import org.apache.tapestry5.commons.util.CoercionFailedException;
+import org.apache.tapestry5.commons.util.CoercionNotFoundException;
 import org.apache.tapestry5.commons.util.CollectionFactory;
 import org.apache.tapestry5.commons.util.StringToEnumCoercion;
 import org.apache.tapestry5.commons.util.UnknownValueException;
@@ -76,7 +78,7 @@ public class TypeCoercerImpl extends LockSupport implements TypeCoercer
                 return type.cast(c.coerce(input));
             } catch (Exception ex)
             {
-                throw new RuntimeException(ServiceMessages.failedCoercion(input, type, c, ex), ex);
+                throw new CoercionFailedException(ServiceMessages.failedCoercion(input, type, c, ex), ex);
             }
         }
 
@@ -338,8 +340,8 @@ public class TypeCoercerImpl extends LockSupport implements TypeCoercer
         // Not found anywhere. Identify the source and target type and a (sorted) list of
         // all the known coercions.
 
-        throw new UnknownValueException(String.format("Could not find a coercion from type %s to type %s.",
-                sourceType.getName(), targetType.getName()), buildCoercionCatalog());
+        throw new CoercionNotFoundException(String.format("Could not find a coercion from type %s to type %s.",
+                sourceType.getName(), targetType.getName()), buildCoercionCatalog(), sourceType, targetType);
     }
 
     /**
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONObject.java b/commons/src/main/java/org/apache/tapestry5/commons/util/CoercionFailedException.java
similarity index 54%
copy from tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONObject.java
copy to commons/src/main/java/org/apache/tapestry5/commons/util/CoercionFailedException.java
index 0663567..b66b8c5 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONObject.java
+++ b/commons/src/main/java/org/apache/tapestry5/commons/util/CoercionFailedException.java
@@ -1,4 +1,4 @@
-// Copyright  2011 The Apache Software Foundation
+// Copyright 2021 The Apache Software Foundation
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,17 +12,25 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package org.apache.tapestry5.internal.json;
+package org.apache.tapestry5.commons.util;
 
+import org.apache.tapestry5.commons.internal.util.TapestryException;
 import org.apache.tapestry5.commons.services.Coercion;
-import org.apache.tapestry5.json.JSONObject;
 
 /**
- * @since 5.3
+ * Exception used when a {@link Coercion} throws an exception while
+ * trying to coerce a value. 
+ * 
+ * @since 5.8.0
  */
-public class StringToJSONObject  implements Coercion<String,JSONObject> {
-    @Override
-    public JSONObject coerce(String input) {
-        return new JSONObject(input);
+public class CoercionFailedException extends TapestryException
+{
+    
+    private static final long serialVersionUID = 1L;
+
+    public CoercionFailedException(String message, Throwable cause) 
+    {
+        super(message, cause);
     }
+    
 }
diff --git a/commons/src/main/java/org/apache/tapestry5/commons/util/CoercionNotFoundException.java b/commons/src/main/java/org/apache/tapestry5/commons/util/CoercionNotFoundException.java
new file mode 100644
index 0000000..7b0a98b
--- /dev/null
+++ b/commons/src/main/java/org/apache/tapestry5/commons/util/CoercionNotFoundException.java
@@ -0,0 +1,55 @@
+// Copyright 2021 The Apache Software Foundation
+//
+// Licensed 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.tapestry5.commons.util;
+
+import org.apache.tapestry5.commons.services.TypeCoercer;
+
+/**
+ * Exception used when {@link TypeCoercer} doesn't find a coercion from a type to another.
+ * 
+ * @since 5.8.0
+ */
+public class CoercionNotFoundException extends UnknownValueException
+{
+    
+    private static final long serialVersionUID = 1L;
+
+    final private Class<?> sourceType;
+    
+    final private Class<?> targetType;
+
+    public CoercionNotFoundException(String message, AvailableValues availableValues, Class<?> sourceType, Class<?> targetType) 
+    {
+        super(message, availableValues);
+        this.sourceType = sourceType;
+        this.targetType = targetType;
+    }
+    
+    /**
+     * Returns the source type.
+     */
+    public Class<?> getSourceType() {
+        return sourceType;
+    }
+
+    
+    /**
+     * Returns the target type.
+     */
+    public Class<?> getTargetType() {
+        return targetType;
+    }
+
+}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/EventConstants.java b/tapestry-core/src/main/java/org/apache/tapestry5/EventConstants.java
index 9e72c10..fdb9b86 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/EventConstants.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/EventConstants.java
@@ -13,6 +13,7 @@
 package org.apache.tapestry5;
 
 import org.apache.tapestry5.http.Link;
+import org.apache.tapestry5.internal.InternalConstants;
 import org.apache.tapestry5.ioc.util.IdAllocator;
 import org.apache.tapestry5.services.ComponentEventRequestParameters;
 import org.apache.tapestry5.services.ComponentSource;
@@ -213,7 +214,7 @@ public class EventConstants
     public static final String PREALLOCATE_FORM_CONTROL_NAMES = "preallocateFormControlNames";
 
     /**
-     * Event  triggered by the {@link org.apache.tapestry5.corelib.components.Tree}
+     * Event triggered by the {@link org.apache.tapestry5.corelib.components.Tree}
      * component when a leaf node is selected.
      *
      * @since 5.3
@@ -221,7 +222,7 @@ public class EventConstants
     public static final String NODE_SELECTED = "nodeSelected";
 
     /**
-     * Event  triggered by the {@link org.apache.tapestry5.corelib.components.Tree}
+     * Event triggered by the {@link org.apache.tapestry5.corelib.components.Tree}
      * component when a leaf node is unselected.
      *
      * @since 5.3
@@ -235,4 +236,47 @@ public class EventConstants
      * @since 5.3
      */
     public static final String REFRESH = "refresh";
+    
+    /**
+     * Event triggered when a page receives an HTTP GET request. 
+     * Handling this event creates a REST endpoint.
+     * @since 5.8.0
+     */
+    public static final String HTTP_GET = InternalConstants.HTTP_METHOD_EVENT_PREFIX + "Get";
+    
+    /**
+     * Event triggered when a page receives an HTTP POST request.
+     * Handling this event creates a REST endpoint.
+     * @since 5.8.0
+     */
+    public static final String HTTP_POST = InternalConstants.HTTP_METHOD_EVENT_PREFIX + "Post";
+    
+    /**
+     * Event triggered when a page receives an HTTP DELETE request.
+     * Handling this event creates a REST endpoint.
+     * @since 5.8.0
+     */
+    public static final String HTTP_DELETE = InternalConstants.HTTP_METHOD_EVENT_PREFIX + "Delete";
+    
+    /**
+     * Event triggered when a page receives an HTTP PUT request.
+     * Handling this event creates a REST endpoint.
+     * @since 5.8.0
+     */
+    public static final String HTTP_PUT = InternalConstants.HTTP_METHOD_EVENT_PREFIX + "Put";
+
+    /**
+     * Event triggered when a page receives an HTTP HEAD request.
+     * Handling this event creates a REST endpoint.
+     * @since 5.8.0
+     */
+    public static final String HTTP_HEAD = InternalConstants.HTTP_METHOD_EVENT_PREFIX + "Head";
+
+    /**
+     * Event triggered when a page receives an HTTP PATCH request.
+     * Handling this event creates a REST endpoint.
+     * @since 5.8.0
+     */
+    public static final String HTTP_PATCH = InternalConstants.HTTP_METHOD_EVENT_PREFIX + "Patch";
+
 }
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java b/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java
index 7b23fb4..a8c37b4 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java
@@ -21,8 +21,10 @@ import org.apache.tapestry5.corelib.components.Errors;
 import org.apache.tapestry5.corelib.mixins.FormGroup;
 import org.apache.tapestry5.http.TapestryHttpSymbolConstants;
 import org.apache.tapestry5.internal.services.AssetDispatcher;
+import org.apache.tapestry5.internal.services.DefaultOpenApiDescriptionGenerator;
 import org.apache.tapestry5.modules.NoBootstrapModule;
 import org.apache.tapestry5.services.Html5Support;
+import org.apache.tapestry5.services.OpenApiDescriptionGenerator;
 import org.apache.tapestry5.services.assets.AssetPathConstructor;
 import org.apache.tapestry5.services.assets.ResourceMinimizer;
 import org.apache.tapestry5.services.compatibility.Trait;
@@ -643,4 +645,40 @@ public class SymbolConstants
      * @since 5.4
      */
     public static final String PRELOADER_MODE = "tapestry.page-preload-mode";
+    
+    /**
+     * Defines the OpenAPI version to be used in the generated OpenAPI description.
+     * Default value is <code>3.0.0<code>.
+     * @see DefaultOpenApiDescriptionGenerator
+     * @see OpenApiDescriptionGenerator
+     * @since 5.8.0
+     */
+    public static final String OPENAPI_VERSION = "tapestry.openapi-version";
+    
+    /**
+     * Defines the title of this application in the generated OpenAPI description. No default value is provided.
+     * @see DefaultOpenApiDescriptionGenerator
+     * @see OpenApiDescriptionGenerator
+     * @since 5.8.0
+     */
+    public static final String OPENAPI_TITLE = "tapestry.openapi-title";
+
+    /**
+     * Defines the description of this application in the generated OpenAPI description. 
+     * No default value is provided.
+     * @see DefaultOpenApiDescriptionGenerator
+     * @see OpenApiDescriptionGenerator
+     * @since 5.8.0
+     */
+    public static final String OPENAPI_DESCRIPTION = "tapestry.openapi-description";
+
+    /**
+     * Defines the version of this application in the generated OpenAPI description (i.e. info/version). 
+     * No default value is provided.
+     * @see DefaultOpenApiDescriptionGenerator
+     * @see OpenApiDescriptionGenerator
+     * @since 5.8.0
+     */
+    public static final String OPENAPI_APPLICATION_VERSION = "tapestry.openapi-application-version";
+
 }
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/annotations/RequestBody.java b/tapestry-core/src/main/java/org/apache/tapestry5/annotations/RequestBody.java
new file mode 100644
index 0000000..c4c19a1
--- /dev/null
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/annotations/RequestBody.java
@@ -0,0 +1,53 @@
+// Licensed 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.tapestry5.annotations;
+
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import static org.apache.tapestry5.ioc.annotations.AnnotationUseContext.PAGE;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import org.apache.tapestry5.commons.services.TypeCoercer;
+import org.apache.tapestry5.http.services.HttpRequestBodyConverter;
+import org.apache.tapestry5.internal.transform.OnEventWorker;
+import org.apache.tapestry5.ioc.annotations.UseWith;
+
+/**
+ * Annotation that may be placed on parameters of event handler methods,
+ * usually in page classes.
+ * Annotated parameters will be extracted fro the request body and converted
+ * to the parameter type using {@linkplain HttpRequestBodyConverter}, which uses 
+ * {@linkplain TypeCoercer} as a fallback.
+ * An event handler method having more than one {@linkplain RequestBody} 
+ * parameter is considered an error.
+ * 
+ * @since 5.8.0
+ * @see OnEventWorker
+ */
+@Target(
+{ PARAMETER })
+@Retention(RUNTIME)
+@Documented
+@UseWith(
+{ PAGE })
+public @interface RequestBody
+{
+    /**
+     * If false (the default), then an exception is thrown when the request body is empty (i.e. zero bytes).
+     * If true, then empty bodies are allowed and the parameter will receive a null value.
+     */
+    boolean allowEmpty() default false;
+}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/InternalConstants.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/InternalConstants.java
index 15805d5..9481301 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/InternalConstants.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/InternalConstants.java
@@ -12,7 +12,15 @@
 
 package org.apache.tapestry5.internal;
 
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.tapestry5.EventConstants;
 import org.apache.tapestry5.annotations.PublishEvent;
+import org.apache.tapestry5.annotations.RequestBody;
+import org.apache.tapestry5.annotations.RequestParameter;
 import org.apache.tapestry5.commons.util.CommonsUtils;
 import org.apache.tapestry5.commons.util.TimeInterval;
 import org.apache.tapestry5.dom.MarkupModel;
@@ -243,4 +251,78 @@ public final class InternalConstants
      */
     public static final String PUBLISH_COMPONENT_EVENTS_URL_PROPERTY = "url";
 
+    /**
+     * The prefix used to create the names of the events triggered for REST endpoint 
+     * event handler methods.
+     * 
+     * @since 5.8.0
+     */
+    public static final String HTTP_METHOD_EVENT_PREFIX = "http";
+
+    /**
+     * The name of the {@link ComponentModel} meta attribute which tells whether
+     * REST endpoint event handler methods are present
+     * 
+     * @since 5.8.0
+     */
+    public static final String REST_ENDPOINT_EVENT_HANDLER_METHOD_PRESENT = "restEndpointEventHandlerMethodsPresent";
+
+    /**
+     * The name of the {@link ComponentModel} meta attribute which lists the 
+     * REST endpoint event handler methods.
+     * 
+     * @since 5.8.0
+     */
+    public static final String REST_ENDPOINT_EVENT_HANDLER_METHODS = "restEndpointEventHandlerMethods";
+
+    /**
+     * Constant for a true boolean value to be used in {@link ComponentModel} meta attributes.
+     */
+    public static final String TRUE = "true";
+    
+    /**
+     * Constant for a false boolean value to be used in {@link ComponentModel} meta attributes.
+     */
+    public static final String FALSE = "false";
+    
+    /**
+     * Annotation types for event handler method parameters which have injected values, not ones 
+     * provided by the URL.
+     */
+    public static Class<?>[] INJECTED_PARAMETERS = new Class<?>[]{
+            RequestParameter.class, RequestBody.class
+    };
+    
+    /**
+     * 
+     */
+    public static final Set<String> SUPPORTED_HTTP_METHOD_EVENTS;
+    
+    public static final Set<String> SUPPORTED_HTTP_METHOD_EVENT_HANDLER_METHOD_NAMES;
+
+    static 
+    {
+        
+        String[] httpEvents = new String[] 
+        {
+            EventConstants.HTTP_GET,
+            EventConstants.HTTP_POST,
+            EventConstants.HTTP_PUT,
+            EventConstants.HTTP_DELETE,
+            EventConstants.HTTP_HEAD,
+            EventConstants.HTTP_PATCH
+        };
+        
+        SUPPORTED_HTTP_METHOD_EVENTS = Collections.unmodifiableSet(
+                Stream.of(httpEvents)
+                    .map(String::toLowerCase)
+                    .collect(Collectors.toSet()));
+
+        SUPPORTED_HTTP_METHOD_EVENT_HANDLER_METHOD_NAMES = Collections.unmodifiableSet(
+                Stream.of(httpEvents)
+                    .map(s -> "on" + s.toLowerCase())
+                    .collect(Collectors.toSet()));
+
+    }
+
 }
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentResultProcessorWrapper.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentResultProcessorWrapper.java
index 05e3f35..148e747 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentResultProcessorWrapper.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentResultProcessorWrapper.java
@@ -30,6 +30,8 @@ public class ComponentResultProcessorWrapper implements TrackableComponentEventC
     private IOException exception;
 
     private final ComponentEventResultProcessor processor;
+    
+    private Object result;
 
     public ComponentResultProcessorWrapper(ComponentEventResultProcessor processor)
     {
@@ -42,6 +44,8 @@ public class ComponentResultProcessorWrapper implements TrackableComponentEventC
             throw new IllegalStateException(
                     "Event callback has already received and processed a result value and can not do so again.");
 
+        this.result = result;
+        
         try
         {
             processor.processResultValue(result);
@@ -72,5 +76,10 @@ public class ComponentResultProcessorWrapper implements TrackableComponentEventC
         if (exception != null)
             throw exception;
     }
+    
+    public Object getResult() 
+    {
+        return result;
+    }
 
 }
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DefaultOpenApiDescriptionGenerator.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DefaultOpenApiDescriptionGenerator.java
new file mode 100644
index 0000000..c3ed3bb
--- /dev/null
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DefaultOpenApiDescriptionGenerator.java
@@ -0,0 +1,539 @@
+// 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.tapestry5.internal.services;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.tapestry5.SymbolConstants;
+import org.apache.tapestry5.annotations.OnEvent;
+import org.apache.tapestry5.annotations.StaticActivationContextValue;
+import org.apache.tapestry5.commons.Messages;
+import org.apache.tapestry5.http.services.BaseURLSource;
+import org.apache.tapestry5.internal.InternalConstants;
+import org.apache.tapestry5.internal.structure.Page;
+import org.apache.tapestry5.ioc.services.SymbolSource;
+import org.apache.tapestry5.ioc.services.ThreadLocale;
+import org.apache.tapestry5.json.JSONArray;
+import org.apache.tapestry5.json.JSONObject;
+import org.apache.tapestry5.model.ComponentModel;
+import org.apache.tapestry5.runtime.Component;
+import org.apache.tapestry5.services.ComponentClassResolver;
+import org.apache.tapestry5.services.ComponentSource;
+import org.apache.tapestry5.services.OpenApiDescriptionGenerator;
+import org.apache.tapestry5.services.PageRenderLinkSource;
+import org.apache.tapestry5.services.messages.ComponentMessagesSource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * {@linkplain OpenApiDescriptionGenerator} that generates lots, if not most, of the application's 
+ * OpenAPI 3.0 documentation.
+ * 
+ * @since 5.8.0
+ */
+public class DefaultOpenApiDescriptionGenerator implements OpenApiDescriptionGenerator 
+{
+    
+    final private static Logger LOGGER = LoggerFactory.getLogger(DefaultOpenApiDescriptionGenerator.class);
+    
+    final private BaseURLSource baseUrlSource;
+    
+    final private SymbolSource symbolSource;
+    
+    final private ComponentMessagesSource componentMessagesSource;
+    
+    final private ThreadLocale threadLocale;
+    
+    final private PageSource pageSource;
+    
+    final private ThreadLocal<Messages> messages;
+    
+    final private ComponentClassResolver componentClassResolver;
+    
+    final private Set<String> failedPageNames;
+    
+    final private PageRenderLinkSource pageRenderLinkSource;
+    
+    final private ComponentSource componentSource;
+    
+    final private static String KEY_PREFIX = "openapi.";
+    
+    public DefaultOpenApiDescriptionGenerator(
+            final BaseURLSource baseUrlSource, 
+            final SymbolSource symbolSource, 
+            final ComponentMessagesSource componentMessagesSource,
+            final ThreadLocale threadLocale,
+            final PageSource pageSource,
+            final ComponentClassResolver componentClassResolver,
+            final PageRenderLinkSource pageRenderLinkSource,
+            final ComponentSource componentSource) 
+    {
+        super();
+        this.baseUrlSource = baseUrlSource;
+        this.symbolSource = symbolSource;
+        this.componentMessagesSource = componentMessagesSource;
+        this.threadLocale = threadLocale;
+        this.pageSource = pageSource;
+        this.componentClassResolver = componentClassResolver;
+        this.pageRenderLinkSource = pageRenderLinkSource;
+        this.componentSource = componentSource;
+        messages = new ThreadLocal<>();
+        failedPageNames = new HashSet<>();
+    }
+
+    @Override
+    public JSONObject generate(JSONObject documentation) 
+    {
+
+        // Making sure all pages have been loaded and transformed
+        for (String pageName : componentClassResolver.getPageNames())
+        {
+            if (!failedPageNames.contains(pageName))
+            {
+                try
+                {
+                    pageSource.getPage(pageName);
+                }
+                catch (Exception e)
+                {
+                    // Ignoring exception, since some classes may not
+                    // be instantiable.
+                    failedPageNames.add(pageName);
+                }
+            }
+        }
+
+        messages.set(componentMessagesSource.getApplicationCatalog(threadLocale.getLocale()));
+
+        if (documentation == null)
+        {
+            documentation = new JSONObject();
+        }
+        
+        documentation.put("openapi", symbolSource.valueForSymbol(SymbolConstants.OPENAPI_VERSION));
+        
+        generateInfo(documentation);
+        
+        JSONArray servers = new JSONArray();
+        servers.add(new JSONObject("url", baseUrlSource.getBaseURL(false)));
+        servers.add(new JSONObject("url", baseUrlSource.getBaseURL(true)));
+        
+        documentation.put("servers", servers);
+        
+        try
+        {
+            addPaths(documentation);
+        }
+        catch (Exception e)
+        {
+            throw new RuntimeException(e);
+        }
+        
+        return documentation;
+        
+    }
+
+    private void generateInfo(JSONObject documentation) {
+        JSONObject info = new JSONObject();
+        putIfNotEmpty(info, "title", SymbolConstants.OPENAPI_TITLE);
+        putIfNotEmpty(info, "description", SymbolConstants.OPENAPI_DESCRIPTION);
+        info.put("version", getValueFromSymbol(SymbolConstants.OPENAPI_APPLICATION_VERSION).orElse("?"));
+        documentation.put("info", info);
+    }
+    
+    private void addPaths(JSONObject documentation) throws NoSuchMethodException, SecurityException 
+    {
+        
+        List<Page> pagesWithRestEndpoints = pageSource.getAllPages().stream()
+                .filter(DefaultOpenApiDescriptionGenerator::hasRestEndpoint)
+                .collect(Collectors.toList());
+        
+        JSONObject paths = new JSONObject();
+        JSONArray tags = new JSONArray();
+        
+        for (Page page : pagesWithRestEndpoints) 
+        {
+            processPageClass(page, paths, tags);
+        }
+        
+        documentation.put("tags", tags);
+        documentation.put("paths", paths);
+        
+    }
+
+    private void processPageClass(Page page, JSONObject paths, JSONArray tags) throws NoSuchMethodException {
+        final Class<?> pageClass = page.getRootComponent().getClass();
+
+        final String tagName = addPageTag(tags, pageClass);
+        
+        ComponentModel model = page.getRootComponent().getComponentResources().getComponentModel();
+        
+        JSONArray methodsAsJson = getMethodsAsJson(model);
+        
+        List<Method> methods = toMethods(methodsAsJson, pageClass);
+        
+        for (Method method : methods) 
+        {
+            processMethod(method, pageClass, paths, tagName);
+        }
+    }
+
+    private String addPageTag(JSONArray tags, final Class<?> pageClass) 
+    {
+        final String tagName = getValue(pageClass, "tag.name").orElse(pageClass.getSimpleName());
+        JSONObject tag = new JSONObject();
+        tag.put("name", tagName);
+        putIfNotEmpty(tag, "description", getValue(pageClass, "tag.description"));
+        tags.add(tag);
+        return tagName;
+    }
+
+    private JSONArray getMethodsAsJson(ComponentModel model) 
+    {
+        JSONArray methodsAsJson = new JSONArray();
+        while (model != null)
+        {
+            JSONArray thisMethodArray = new JSONArray(model.getMeta(
+                    InternalConstants.REST_ENDPOINT_EVENT_HANDLER_METHODS));
+            addElementsIfNotPresent(methodsAsJson, thisMethodArray);
+            model = model.getParentModel();
+        }
+        return methodsAsJson;
+    }
+
+    private void processMethod(Method method, final Class<?> pageClass, JSONObject paths, final String tagName) 
+    {
+        final String uri = getPath(method, pageClass);
+        final JSONObject path;
+        if (paths.containsKey(uri))
+        {
+            path = paths.getJSONObject(uri);
+        }
+        else
+        {
+            path = new JSONObject();
+            paths.put(uri, path);
+        }
+        
+        final String httpMethod = getHttpMethod(method);
+        
+        if (path.containsKey(httpMethod))
+        {
+            throw new RuntimeException(String.format(
+                    "There are at least two different REST endpoints for path %s and HTTP method %s in class %s",
+                    uri, httpMethod, pageClass.getName()));
+        }
+        else
+        {
+            
+            final JSONObject methodDocumentation = new JSONObject();
+            
+            putIfNotEmpty(methodDocumentation, "summary", getValue(method, uri, httpMethod, "summary"));
+            putIfNotEmpty(methodDocumentation, "description", getValue(method, uri, httpMethod, "description"));
+            
+            JSONArray methodTags = new JSONArray();
+            methodTags.add(tagName);
+            methodDocumentation.put("tags", methodTags);
+            
+            JSONObject responses = new JSONObject();
+            JSONObject defaultResponse = new JSONObject();
+            int statusCode = httpMethod.equals("post") ? 
+                    HttpServletResponse.SC_CREATED : HttpServletResponse.SC_OK;
+            putIfNotEmpty(defaultResponse, "description", getValue(method, uri, httpMethod, statusCode));
+            responses.put(String.valueOf(statusCode), defaultResponse);
+            
+            methodDocumentation.put("responses", responses);
+            
+            path.put(httpMethod, methodDocumentation);
+        }
+    }
+
+    private void addElementsIfNotPresent(JSONArray accumulator, JSONArray array) 
+    {
+        if (array != null)
+        {
+            for (int i = 0; i < array.size(); i++)
+            {
+                JSONObject method = array.getJSONObject(i);
+                boolean present = isPresent(accumulator, method);
+                if (!present)
+                {
+                    accumulator.add(method);
+                }
+            }
+        }
+    }
+
+    private boolean isPresent(JSONArray array, JSONObject object) 
+    {
+        boolean present = false;
+        for (int i = 0; i < array.size(); i++)
+        {
+            if (object.equals(array.getJSONObject(i)))
+            {
+                present = false;
+            }
+        }
+        return present;
+    }
+
+    private Optional<String> getValue(Class<?> clazz, String property) 
+    {
+        Optional<String> value = getValue(
+                KEY_PREFIX + clazz.getName() + "." + property);
+        if (!value.isPresent())
+        {
+            value = getValue(
+                    KEY_PREFIX + clazz.getSimpleName() + "." + property);
+        }
+        return value;
+    }
+    
+    private Optional<String> getValue(Method method, String path, String httpMethod, String property) 
+    {
+        return getValue(method, path + "." + httpMethod + "." + property, true);
+    }
+    
+    public Optional<String> getValue(Method method, String path, String httpMethod, int statusCode) 
+    {
+        Optional<String> value = getValue(method, path + "." + httpMethod + ".response." + String.valueOf(statusCode), true);
+        if (!value.isPresent())
+        {
+            value = getValue(method, httpMethod + ".response." + String.valueOf(statusCode), false);
+        }
+        if (!value.isPresent())
+        {
+            value = getValue(method, "response." + String.valueOf(statusCode), false);
+        }
+        if (!value.isPresent())
+        {
+            value = getValue("response." + String.valueOf(statusCode));
+        }
+        return value;
+    }
+
+    public Optional<String> getValue(Method method, final String suffix, final boolean skipClassNameLookup) 
+    {
+        Optional<String> value = Optional.empty();
+
+        if (!skipClassNameLookup)
+        {
+            value = getValue(
+                    KEY_PREFIX + method.getDeclaringClass().getName() + "." + suffix);
+            if (!value.isPresent())
+            {
+                value = getValue(
+                        KEY_PREFIX + method.getDeclaringClass().getSimpleName() + "." + suffix);
+            }
+        }
+        if (!value.isPresent())
+        {
+            value = getValue(KEY_PREFIX + suffix);
+        }
+        return value;
+    }
+
+    private List<Method> toMethods(JSONArray methodsAsJson, Class<?> pageClass) throws NoSuchMethodException, SecurityException 
+    {
+        List<Method> methods = new ArrayList<>(methodsAsJson.size());
+        for (Object object : methodsAsJson)
+        {
+            JSONObject methodAsJason = (JSONObject) object;
+            final String name = methodAsJason.getString("name");
+            final JSONArray parametersAsJson = methodAsJason.getJSONArray("parameters");
+            @SuppressWarnings("rawtypes")
+            List<Class> parameterTypes = parametersAsJson.stream()
+                .map(o -> ((String) o))
+                .map(s -> toClass(s))
+                .collect(Collectors.toList());
+            methods.add(findMethod(pageClass, name, parameterTypes));
+        }
+        return methods;
+    }
+
+    @SuppressWarnings("rawtypes")
+    public Method findMethod(Class<?> pageClass, final String name, List<Class> parameterTypes) throws NoSuchMethodException 
+    {
+        Method method = null;
+        try
+        {
+            method = pageClass.getDeclaredMethod(name, 
+                    parameterTypes.toArray(new Class[parameterTypes.size()]));
+        }
+        catch (NoSuchMethodException e)
+        {
+            // Let's try the supertypes
+            List<Class> superTypes = new ArrayList<>();
+            superTypes.add(pageClass.getSuperclass());
+            superTypes.addAll((Arrays.asList(pageClass.getInterfaces())));
+            for (Class clazz : superTypes)
+            {
+                method = findMethod(clazz, name, parameterTypes);
+                if (method != null)
+                {
+                    break;
+                }
+            }
+        }
+        return method;
+    }
+    
+    private static Class<?> toClass(String string)
+    {
+        try {
+            return Class.forName(string);
+        } catch (ClassNotFoundException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private String getPath(Method method, Class<?> pageClass)
+    {
+        final StringBuilder builder = new StringBuilder();
+        builder.append(pageRenderLinkSource.createPageRenderLink(pageClass).toString());
+        for (Parameter parameter : method.getParameters())
+        {
+            if (!isIgnored(parameter))
+            {
+                builder.append("/");
+                final StaticActivationContextValue staticValue = parameter.getAnnotation(StaticActivationContextValue.class);
+                if (staticValue != null)
+                {
+                    builder.append(staticValue.value());
+                }
+                else
+                {
+                    builder.append("{");
+                    builder.append(parameter.getName());
+                    builder.append("}");
+                }
+            }
+        }
+        return builder.toString();
+    }
+    
+    @SuppressWarnings({ "rawtypes", "unchecked" })
+    private static boolean isIgnored(Parameter parameter)
+    {
+        boolean ignored = false;
+        for (Class clazz : InternalConstants.INJECTED_PARAMETERS)
+        {
+            if (parameter.getAnnotation(clazz) != null)
+            {
+                ignored = true;
+                break;
+            }
+        }
+        return ignored;
+    }
+
+    private void putIfNotEmpty(JSONObject object, String propertyName, Optional<String> value)
+    {
+        value.ifPresent((v) -> object.put(propertyName, v));
+    }
+    
+    private void putIfNotEmpty(JSONObject object, String propertyName, String key)
+    {
+        getValue(key).ifPresent((value) -> object.put(propertyName, value));
+    }
+    
+    private Optional<String> getValue(String key)
+    {
+        Optional<String> value = getValueFromMessages(key);
+        return value.isPresent() ? value : getValueFromSymbol(key);
+    }
+
+    private Optional<String> getValueFromMessages(String key)
+    {
+        logMessageLookup(key);
+        final String value = messages.get().get(key.replace("tapestry.", "")).trim();
+        return value.startsWith("[") && value.endsWith("]") ? Optional.empty() : Optional.of(value);
+    }
+
+    private void logSymbolLookup(String key) {
+        if (LOGGER.isDebugEnabled())
+        {
+            LOGGER.debug("Looking up symbol  " + key);
+        }
+    }
+    
+    private void logMessageLookup(String key) {
+        if (LOGGER.isDebugEnabled())
+        {
+            LOGGER.debug("Looking up message " + key);
+        }
+    }
+    
+    private Optional<String> getValueFromSymbol(String key)
+    {
+        String value;
+        final String symbol = "tapestry." + key;
+        logSymbolLookup(symbol);
+        try
+        {
+            value = symbolSource.valueForSymbol(symbol);
+        }
+        catch (RuntimeException e)
+        {
+            // value not found;
+            value = null;
+        }
+        return Optional.ofNullable(value);
+    }
+    
+    private static final String PREFIX = InternalConstants.HTTP_METHOD_EVENT_PREFIX.toLowerCase();
+    
+    private static String getHttpMethod(Method method)
+    {
+        String httpMethod;
+        OnEvent onEvent = method.getAnnotation(OnEvent.class);
+        if (onEvent != null)
+        {
+            httpMethod = onEvent.value();
+        }
+        else
+        {
+            httpMethod = method.getName().replace("on", "");
+        }
+        httpMethod = httpMethod.toLowerCase();
+        httpMethod = httpMethod.replace(PREFIX, "");
+        return httpMethod;
+    }
+
+    private static boolean hasRestEndpoint(Page page) 
+    {
+        return hasRestEndpoint(page.getRootComponent());
+    }
+
+    private static boolean hasRestEndpoint(final Component component) 
+    {
+        final ComponentModel componentModel = component.getComponentResources().getComponentModel();
+        return InternalConstants.TRUE.equals(componentModel.getMeta(
+                InternalConstants.REST_ENDPOINT_EVENT_HANDLER_METHOD_PRESENT));
+    }
+
+}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DefaultRequestExceptionHandler.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DefaultRequestExceptionHandler.java
index b512990..dc823ac 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DefaultRequestExceptionHandler.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DefaultRequestExceptionHandler.java
@@ -76,6 +76,8 @@ public class DefaultRequestExceptionHandler implements RequestExceptionHandler
     private final LinkSource linkSource;
 
     private final ExceptionReporter exceptionReporter;
+    
+    private final boolean productionMode;
 
     // should be Class<? extends Throwable>, Object but it's not allowed to configure subtypes
     private final Map<Class, Object> configuration;
@@ -96,6 +98,8 @@ public class DefaultRequestExceptionHandler implements RequestExceptionHandler
                                           LinkSource linkSource,
                                           ServiceResources serviceResources,
                                           ExceptionReporter exceptionReporter,
+                                          @Symbol(SymbolConstants.PRODUCTION_MODE)
+                                          boolean productionMode,
                                           Map<Class, Object> configuration)
     {
         this.pageCache = pageCache;
@@ -106,6 +110,7 @@ public class DefaultRequestExceptionHandler implements RequestExceptionHandler
         this.response = response;
         this.componentClassResolver = componentClassResolver;
         this.linkSource = linkSource;
+        this.productionMode = productionMode;
         this.exceptionReporter = exceptionReporter;
 
         Map<Class<ExceptionHandlerAssistant>, ExceptionHandlerAssistant> handlerAssistants = new HashMap<Class<ExceptionHandlerAssistant>, ExceptionHandlerAssistant>();
@@ -238,6 +243,19 @@ public class DefaultRequestExceptionHandler implements RequestExceptionHandler
 
     private void renderException(Throwable exception) throws IOException
     {
+        int statusCode = HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+        // Some special handling for REST endpoint not found exceptions
+        if (exception instanceof RestEndpointNotFoundException ||
+                exception.getCause() instanceof RestEndpointNotFoundException)
+        {
+            if (productionMode)
+            {
+                response.sendError(HttpServletResponse.SC_NOT_FOUND, "REST endpoint not found or endpoint found but no response provided");
+                return;
+            }
+            statusCode = HttpServletResponse.SC_NOT_FOUND;
+        }
+        
         logger.error("Processing of request failed with uncaught exception: {}", exception, exception);
 
         // In the case where one of the contributed rules, above, changes the behavior, then we don't report the
@@ -246,7 +264,7 @@ public class DefaultRequestExceptionHandler implements RequestExceptionHandler
 
         // TAP5-233: Make sure the client knows that an error occurred.
 
-        response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+        response.setStatus(statusCode);
 
         String rawMessage = ExceptionUtils.toMessage(exception);
 
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/PageActivatorImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/PageActivatorImpl.java
index c5c6bea..a72d866 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/PageActivatorImpl.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/PageActivatorImpl.java
@@ -16,16 +16,17 @@ package org.apache.tapestry5.internal.services;
 
 import java.io.IOException;
 
-import org.apache.tapestry5.*;
-import org.apache.tapestry5.internal.EmptyEventContext;
-import org.apache.tapestry5.ioc.annotations.Symbol;
+import org.apache.tapestry5.ComponentResources;
+import org.apache.tapestry5.EventConstants;
+import org.apache.tapestry5.EventContext;
+import org.apache.tapestry5.MetaDataConstants;
+import org.apache.tapestry5.TrackableComponentEventCallback;
+import org.apache.tapestry5.http.services.Request;
+import org.apache.tapestry5.internal.InternalConstants;
 import org.apache.tapestry5.services.ComponentEventResultProcessor;
-import org.apache.tapestry5.services.HttpError;
 import org.apache.tapestry5.services.MetaDataLocator;
 import org.slf4j.Logger;
 
-import javax.servlet.http.HttpServletResponse;
-
 public class PageActivatorImpl implements PageActivator
 {
     private final Logger logger;
@@ -33,16 +34,20 @@ public class PageActivatorImpl implements PageActivator
     private final MetaDataLocator metaDataLocator;
 
     private final UnknownActivationContextHandler unknownActivationContextHandler;
+    
+    private final Request request;
 
     public PageActivatorImpl(Logger logger, MetaDataLocator metaDataLocator,
-                             UnknownActivationContextHandler unknownActivationContextHandler)
+                             UnknownActivationContextHandler unknownActivationContextHandler,
+                             Request request)
     {
         this.logger = logger;
         this.metaDataLocator = metaDataLocator;
         this.unknownActivationContextHandler = unknownActivationContextHandler;
+        this.request = request;
     }
 
-    @SuppressWarnings("unchecked")
+    @SuppressWarnings("rawtypes")
     public boolean activatePage(ComponentResources pageResources, EventContext activationContext,
             ComponentEventResultProcessor resultProcessor) throws IOException
     {
@@ -69,6 +74,28 @@ public class PageActivatorImpl implements PageActivator
             callback.rethrow();
             return true;
         }
+        else
+        {
+            if (InternalConstants.TRUE.equals(pageResources.getComponentModel().getMeta(
+                    InternalConstants.REST_ENDPOINT_EVENT_HANDLER_METHOD_PRESENT)))
+            {
+                callback = new ComponentResultProcessorWrapper(resultProcessor);
+                handled = pageResources.triggerContextEvent(
+                        InternalConstants.HTTP_METHOD_EVENT_PREFIX + request.getMethod(), activationContext, callback);
+                if (callback.isAborted())
+                {
+                    callback.rethrow();
+                    return true;
+                }
+                else
+                {
+                    throw new RestEndpointNotFoundException(
+                            String.format("Page %s (%s) has at least one REST endpoint event handler method "
+                                    + "but none handled %s for this request", pageResources.getPageName(),
+                                    pageResources.getPage().getClass().getName(), request.getMethod()));
+                }
+            }
+        }
 
         return false;
     }
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ResourceStreamerImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ResourceStreamerImpl.java
index c803282..51f6122 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ResourceStreamerImpl.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ResourceStreamerImpl.java
@@ -212,8 +212,6 @@ public class ResourceStreamerImpl implements ResourceStreamer
             response.setHeader("Cache-Control", omitExpirationCacheControlHeader);
         }
 
-        response.setContentLength(streamable.getSize());
-
         if (streamable.getCompression() == CompressionStatus.COMPRESSED)
         {
             response.setHeader(TapestryHttpInternalConstants.CONTENT_ENCODING_HEADER, TapestryHttpInternalConstants.GZIP_CONTENT_ENCODING);
@@ -226,11 +224,14 @@ public class ResourceStreamerImpl implements ResourceStreamer
             responseCustomizer.customizeResponse(streamable, response);
         }
 
-        OutputStream os = response.getOutputStream(streamable.getContentType().toString());
-
-        streamable.streamTo(os);
-
-        os.close();
+        if (!request.getMethod().equals("HEAD"))
+        {
+            response.setContentLength(streamable.getSize());
+            
+            OutputStream os = response.getOutputStream(streamable.getContentType().toString());
+            streamable.streamTo(os);
+            os.close();
+        }
 
         return true;
     }
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/RestEndpointNotFoundException.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/RestEndpointNotFoundException.java
new file mode 100644
index 0000000..3c3cc35
--- /dev/null
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/RestEndpointNotFoundException.java
@@ -0,0 +1,35 @@
+// Copyright 2021 The Apache Software Foundation
+//
+// Licensed 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.tapestry5.internal.services;
+
+/**
+ * Exception used when a request is made to a page with REST endpoint event handlers
+ * but doesn't match any of them.
+ */
+public class RestEndpointNotFoundException extends RuntimeException
+{
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * Creates a new instance of this class.
+     * @param message A {@linkplain String} contaning an error message.
+     */
+    public RestEndpointNotFoundException(String message) 
+    {
+        super(message);
+    }
+    
+}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/transform/OnEventWorker.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/transform/OnEventWorker.java
index 9b570e2..5468bc4 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/transform/OnEventWorker.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/transform/OnEventWorker.java
@@ -16,16 +16,19 @@ import java.lang.reflect.Array;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
 import java.util.regex.Pattern;
 
 import org.apache.tapestry5.ComponentResources;
 import org.apache.tapestry5.EventContext;
 import org.apache.tapestry5.ValueEncoder;
+import org.apache.tapestry5.annotations.DisableStrictChecks;
 import org.apache.tapestry5.annotations.OnEvent;
 import org.apache.tapestry5.annotations.PublishEvent;
+import org.apache.tapestry5.annotations.RequestBody;
 import org.apache.tapestry5.annotations.RequestParameter;
 import org.apache.tapestry5.annotations.StaticActivationContextValue;
-import org.apache.tapestry5.annotations.DisableStrictChecks;
 import org.apache.tapestry5.commons.internal.util.TapestryException;
 import org.apache.tapestry5.commons.util.CollectionFactory;
 import org.apache.tapestry5.commons.util.ExceptionUtils;
@@ -36,11 +39,14 @@ import org.apache.tapestry5.func.Flow;
 import org.apache.tapestry5.func.Mapper;
 import org.apache.tapestry5.func.Predicate;
 import org.apache.tapestry5.http.services.Request;
+import org.apache.tapestry5.http.services.RestSupport;
 import org.apache.tapestry5.internal.InternalConstants;
 import org.apache.tapestry5.internal.services.ComponentClassCache;
+import org.apache.tapestry5.ioc.Invokable;
 import org.apache.tapestry5.ioc.OperationTracker;
 import org.apache.tapestry5.ioc.internal.util.InternalUtils;
 import org.apache.tapestry5.json.JSONArray;
+import org.apache.tapestry5.json.JSONObject;
 import org.apache.tapestry5.model.MutableComponentModel;
 import org.apache.tapestry5.plastic.Condition;
 import org.apache.tapestry5.plastic.InstructionBuilder;
@@ -72,6 +78,8 @@ public class OnEventWorker implements ComponentClassTransformWorker2
     private final Request request;
 
     private final ValueEncoderSource valueEncoderSource;
+    
+    private final RestSupport restSupport;
 
     private final ComponentClassCache classCache;
 
@@ -299,6 +307,7 @@ public class OnEventWorker implements ComponentClassTransformWorker2
             final List<EventHandlerMethodParameterProvider> providers = CollectionFactory.newList();
 
             int contextIndex = 0;
+            boolean hasBodyRequestParameters = false;
 
             for (int i = 0; i < parameterTypes.length; i++)
             {
@@ -324,6 +333,24 @@ public class OnEventWorker implements ComponentClassTransformWorker2
                     continue;
                 }
 
+                RequestBody bodyAnnotation = method.getParameters().get(i).getAnnotation(RequestBody.class);
+
+                if (bodyAnnotation != null)
+                {
+                    if (!hasBodyRequestParameters)
+                    {
+                        providers.add(createRequestBodyProvider(method, i, type,
+                                bodyAnnotation.allowEmpty()));
+                        hasBodyRequestParameters = true;
+                    }
+                    else
+                    {
+                        throw new RuntimeException(
+                                String.format("Method %s has more than one @RequestBody parameter", method.getDescription()));
+                    }
+                    continue;
+                }
+
                 // Note: probably safe to do the conversion to Class early (class load time)
                 // as parameters are rarely (if ever) component classes.
 
@@ -378,12 +405,13 @@ public class OnEventWorker implements ComponentClassTransformWorker2
         });
     }
 
-    public OnEventWorker(Request request, ValueEncoderSource valueEncoderSource, ComponentClassCache classCache, OperationTracker operationTracker)
+    public OnEventWorker(Request request, ValueEncoderSource valueEncoderSource, ComponentClassCache classCache, OperationTracker operationTracker, RestSupport restSupport)
     {
         this.request = request;
         this.valueEncoderSource = valueEncoderSource;
         this.classCache = classCache;
         this.operationTracker = operationTracker;
+        this.restSupport = restSupport;
     }
 
     public void transform(PlasticClass plasticClass, TransformationSupport support, MutableComponentModel model)
@@ -398,6 +426,9 @@ public class OnEventWorker implements ComponentClassTransformWorker2
         addEventHandlingLogic(plasticClass, support.isRootTransformation(), methods, model);
     }
 
+    private static final Set<String> HTTP_EVENT_HANDLER_NAMES = InternalConstants.SUPPORTED_HTTP_METHOD_EVENT_HANDLER_METHOD_NAMES;
+    
+    private static final Set<String> HTTP_METHOD_EVENTS = InternalConstants.SUPPORTED_HTTP_METHOD_EVENTS;
 
     private void addEventHandlingLogic(final PlasticClass plasticClass, final boolean isRoot, final Flow<PlasticMethod> plasticMethods, final MutableComponentModel model)
     {
@@ -493,6 +524,8 @@ public class OnEventWorker implements ComponentClassTransformWorker2
                             builder.loadConstant(false).storeVariable(resultVariable);
                         }
 
+                        boolean hasRestEndpointEventHandlerMethod = false;
+                        JSONArray restEndpointEventHandlerMethods = null;
                         for (EventHandlerMethod method : eventHandlerMethods)
                         {
                             method.buildMatchAndInvocation(builder, resultVariable);
@@ -500,11 +533,55 @@ public class OnEventWorker implements ComponentClassTransformWorker2
                             model.addEventHandler(method.eventType);
 
                             if (method.handleActivationEventContext)
+                            {
                                 model.doHandleActivationEventContext();
+                            }
+
+                            // We're collecting this info for all components, even considering REST
+                            // events are only triggered in pages, because we can have REST event
+                            // handler methods in base classes too, and we need this info
+                            // for generating complete, correct OpenAPI documentation.
+                            final OnEvent onEvent = method.method.getAnnotation(OnEvent.class);
+                            final String methodName = method.method.getDescription().methodName;
+                            if (isRestEndpointEventHandlerMethod(onEvent, methodName))
+                            {
+                                hasRestEndpointEventHandlerMethod = true;
+                                if (restEndpointEventHandlerMethods == null)
+                                {
+                                    restEndpointEventHandlerMethods = new JSONArray();
+                                }
+                                JSONObject methodMeta = new JSONObject();
+                                methodMeta.put("name", methodName);
+                                JSONArray parameters = new JSONArray();
+                                for (MethodParameter parameter : method.method.getParameters())
+                                {
+                                    parameters.add(parameter.getType());
+                                }
+                                methodMeta.put("parameters", parameters);
+                                restEndpointEventHandlerMethods.add(methodMeta);
+                            }
+                        }
+                        
+                        // This meta property is only ever checked in pages, so we avoid using more
+                        // memory by not setting it to all component models.
+                        if (model.isPage())
+                        {
+                            model.setMeta(InternalConstants.REST_ENDPOINT_EVENT_HANDLER_METHOD_PRESENT, 
+                                    hasRestEndpointEventHandlerMethod ? InternalConstants.TRUE : InternalConstants.FALSE);
+                        }
+                        
+                        // See comment on the top of isRestEndpointEventHandlerMethod() above.
+                        // This shouldn't waste memory unless there are REST event handler
+                        // methods in components, something that would be ignored anyway.
+                        if (restEndpointEventHandlerMethods != null)
+                        {
+                            model.setMeta(InternalConstants.REST_ENDPOINT_EVENT_HANDLER_METHODS, 
+                                    restEndpointEventHandlerMethods.toCompactString());
                         }
 
                         builder.loadVariable(resultVariable).returnResult();
                     }
+
                 });
             }
         });
@@ -515,6 +592,27 @@ public class OnEventWorker implements ComponentClassTransformWorker2
         return F.flow(plasticClass.getMethods()).filter(IS_EVENT_HANDLER);
     }
 
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    private EventHandlerMethodParameterProvider createRequestBodyProvider(PlasticMethod method, final int parameterIndex, 
+            final String parameterTypeName, final boolean allowEmpty)
+    {
+        final String methodIdentifier = method.getMethodIdentifier();
+        return (event) -> {
+            Invokable<Object> operation = () -> {
+                Class parameterType = classCache.forName(parameterTypeName);
+                Optional result = restSupport.getRequestBodyAs(parameterType);
+                if (!allowEmpty && !result.isPresent())
+                {
+                    throw new RuntimeException(
+                            String.format("The request has an empty body and %s has one parameter with @RequestBody(allowEmpty=false)", methodIdentifier));
+                }
+                return result.orElse(null);
+            };
+            return operationTracker.invoke(
+                    "Converting HTTP request body for @RequestBody parameter", 
+                    operation);
+        };
+    }
 
     private EventHandlerMethodParameterProvider createQueryParameterProvider(PlasticMethod method, final int parameterIndex, final String parameterName,
                                                                              final String parameterTypeName, final boolean allowBlank)
@@ -595,7 +693,7 @@ public class OnEventWorker implements ComponentClassTransformWorker2
             }
         };
     }
-
+    
     private EventHandlerMethodParameterProvider createEventContextProvider(final String type, final int parameterIndex)
     {
         return new EventHandlerMethodParameterProvider()
@@ -643,4 +741,14 @@ public class OnEventWorker implements ComponentClassTransformWorker2
         // The first two characters are always "on" as in "onActionFromFoo".
         return fromx == -1 ? methodName.substring(2) : methodName.substring(2, fromx);
     }
+    
+    /**
+     * Tells whether a method with a given name and possibly {@link OnEvent} annotation
+     * is a REST endpoint event handler method or not.
+     */
+    public static boolean isRestEndpointEventHandlerMethod(final OnEvent onEvent, final String methodName) {
+        return onEvent != null && HTTP_METHOD_EVENTS.contains(onEvent.value().toLowerCase())
+            || HTTP_EVENT_HANDLER_NAMES.contains(methodName.toLowerCase());
+    }
+
 }
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/modules/.gitignore b/tapestry-core/src/main/java/org/apache/tapestry5/modules/.gitignore
new file mode 100644
index 0000000..d970588
--- /dev/null
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/modules/.gitignore
@@ -0,0 +1 @@
+/TapestryHttpModule.java
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/modules/TapestryModule.java b/tapestry-core/src/main/java/org/apache/tapestry5/modules/TapestryModule.java
index e591e5b..6def71f 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/modules/TapestryModule.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/modules/TapestryModule.java
@@ -15,10 +15,8 @@ package org.apache.tapestry5.modules;
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.annotation.Annotation;
-import java.lang.reflect.Method;
 import java.math.BigDecimal;
 import java.math.BigInteger;
-import java.net.URISyntaxException;
 import java.net.URL;
 import java.text.DateFormat;
 import java.text.SimpleDateFormat;
@@ -32,10 +30,6 @@ import java.util.Properties;
 import java.util.Set;
 import java.util.regex.Pattern;
 
-import javax.servlet.ServletContext;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
 import org.apache.tapestry5.Asset;
 import org.apache.tapestry5.BindingConstants;
 import org.apache.tapestry5.Block;
@@ -118,29 +112,18 @@ import org.apache.tapestry5.http.Link;
 import org.apache.tapestry5.http.TapestryHttpSymbolConstants;
 import org.apache.tapestry5.http.internal.TapestryHttpInternalConstants;
 import org.apache.tapestry5.http.internal.TapestryHttpInternalSymbols;
-import org.apache.tapestry5.http.internal.gzip.GZipFilter;
-import org.apache.tapestry5.http.internal.services.ApplicationGlobalsImpl;
-import org.apache.tapestry5.http.internal.services.RequestGlobalsImpl;
-import org.apache.tapestry5.http.internal.services.RequestImpl;
-import org.apache.tapestry5.http.internal.services.ResponseImpl;
-import org.apache.tapestry5.http.internal.services.TapestrySessionFactory;
-import org.apache.tapestry5.http.internal.services.TapestrySessionFactoryImpl;
-import org.apache.tapestry5.http.modules.TapestryHttpModule;
+//import org.apache.tapestry5.http.modules.TapestryHttpModule;
 import org.apache.tapestry5.http.services.ApplicationGlobals;
 import org.apache.tapestry5.http.services.ApplicationInitializer;
 import org.apache.tapestry5.http.services.ApplicationInitializerFilter;
-import org.apache.tapestry5.http.services.BaseURLSource;
 import org.apache.tapestry5.http.services.Context;
 import org.apache.tapestry5.http.services.Dispatcher;
 import org.apache.tapestry5.http.services.HttpServletRequestFilter;
-import org.apache.tapestry5.http.services.HttpServletRequestHandler;
 import org.apache.tapestry5.http.services.Request;
 import org.apache.tapestry5.http.services.RequestFilter;
 import org.apache.tapestry5.http.services.RequestGlobals;
 import org.apache.tapestry5.http.services.RequestHandler;
 import org.apache.tapestry5.http.services.Response;
-import org.apache.tapestry5.http.services.ServletApplicationInitializer;
-import org.apache.tapestry5.http.services.ServletApplicationInitializerFilter;
 import org.apache.tapestry5.http.services.Session;
 import org.apache.tapestry5.internal.ComponentOverrideImpl;
 import org.apache.tapestry5.internal.DefaultNullFieldStrategy;
@@ -268,6 +251,7 @@ import org.apache.tapestry5.ioc.services.UpdateListener;
 import org.apache.tapestry5.ioc.services.UpdateListenerHub;
 import org.apache.tapestry5.json.JSONArray;
 import org.apache.tapestry5.json.JSONObject;
+import org.apache.tapestry5.json.modules.JSONModule;
 import org.apache.tapestry5.plastic.MethodAdvice;
 import org.apache.tapestry5.plastic.MethodDescription;
 import org.apache.tapestry5.plastic.MethodInvocation;
@@ -333,6 +317,7 @@ import org.apache.tapestry5.services.MarkupWriterFactory;
 import org.apache.tapestry5.services.MetaDataLocator;
 import org.apache.tapestry5.services.NullFieldStrategySource;
 import org.apache.tapestry5.services.ObjectRenderer;
+import org.apache.tapestry5.services.OpenApiDescriptionGenerator;
 import org.apache.tapestry5.services.PageDocumentGenerator;
 import org.apache.tapestry5.services.PageRenderLinkSource;
 import org.apache.tapestry5.services.PageRenderRequestFilter;
@@ -399,7 +384,7 @@ import org.slf4j.Logger;
  */
 @Marker(Core.class)
 @ImportModule(
-        {InternalModule.class, AssetsModule.class, PageLoadModule.class, JavaScriptModule.class, CompatibilityModule.class, DashboardModule.class, TapestryHttpModule.class})
+        {InternalModule.class, AssetsModule.class, PageLoadModule.class, JavaScriptModule.class, CompatibilityModule.class, DashboardModule.class, TapestryHttpModule.class, JSONModule.class})
 public final class TapestryModule
 {
     private final PipelineBuilder pipelineBuilder;
@@ -2187,6 +2172,8 @@ public final class TapestryModule
 
         configuration.add(SymbolConstants.ENABLE_PAGELOADING_MASK, true);
         configuration.add(SymbolConstants.PRELOADER_MODE, PreloaderMode.PRODUCTION);
+        
+        configuration.add(SymbolConstants.OPENAPI_VERSION, "3.0.0");
     }
 
     /**
@@ -2728,6 +2715,18 @@ public final class TapestryModule
         configuration.addInstance("Maven", MavenComponentLibraryInfoSource.class);
         configuration.add("TapestryCore", new TapestryCoreComponentLibraryInfoSource());
     }
+    
+    public static OpenApiDescriptionGenerator buildOpenApiDocumentationGenerator(List<OpenApiDescriptionGenerator> configuration,
+            ChainBuilder chainBuilder) 
+    {
+        return chainBuilder.build(OpenApiDescriptionGenerator.class, configuration);
+    }
+
+    @Contribute(OpenApiDescriptionGenerator.class)
+    public static void addBuiltInOpenApiDocumentationGenerator(
+            OrderedConfiguration<OpenApiDescriptionGenerator> configuration) {
+        configuration.addInstance("Default", DefaultOpenApiDescriptionGenerator.class, "before:*");
+    }
 
     private static final class TapestryCoreComponentLibraryInfoSource implements
             ComponentLibraryInfoSource
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/runtime/ComponentEvent.java b/tapestry-core/src/main/java/org/apache/tapestry5/runtime/ComponentEvent.java
index e7e21fe..50af268 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/runtime/ComponentEvent.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/runtime/ComponentEvent.java
@@ -36,7 +36,6 @@ public interface ComponentEvent extends Event
      * @param parameterCount
      *            minimum number of context values
      * @return true if the event matches (and has not yet been aborted)
-     * @since 5.8.0
      */
     boolean matches(String eventType, String componentId, int parameterCount);
 
@@ -55,6 +54,7 @@ public interface ComponentEvent extends Event
      *            I any value in the arra isn't null, it's compared to the corresponding
      *            activation context value. If it doesn't match, this method will return null.
      * @return true if the event matches (and has not yet been aborted)
+     * @since 5.8.0
      */
     default boolean matches(String eventType, String componentId, int parameterCount, String[] staticActivationContextValues)
     {
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/services/OpenApiDescriptionGenerator.java b/tapestry-core/src/main/java/org/apache/tapestry5/services/OpenApiDescriptionGenerator.java
new file mode 100644
index 0000000..42e4b71
--- /dev/null
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/services/OpenApiDescriptionGenerator.java
@@ -0,0 +1,41 @@
+// 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.tapestry5.services;
+
+import org.apache.tapestry5.internal.services.DefaultOpenApiDescriptionGenerator;
+import org.apache.tapestry5.ioc.annotations.UsesOrderedConfiguration;
+import org.apache.tapestry5.json.JSONObject;
+
+/**
+ * Service used to generate OpenAPI 3.0 description in JSON format for an application 
+ * REST endpoints (i.e. REST endpoint event handler methods). A base implementation, 
+ * {@linkplain DefaultOpenApiDescriptionGenerator}, is automatically added as the first
+ * contribution to the service's distributed configuration. Other implementations of this 
+ * interface can be contributed to further customize the description.
+ */
+@UsesOrderedConfiguration(OpenApiDescriptionGenerator.class)
+public interface OpenApiDescriptionGenerator
+{
+    /**
+     * Generates or customizes the OpenAPI 3.0 documentation for this webapp's REST endpoints.
+     * 
+     * @param documentation a {@link JSONObject} object.
+     * @return the generated or customized OpenAPI 3.0 documentation as a JSON object.
+     */
+    JSONObject generate(JSONObject documentation);
+}
diff --git a/tapestry-core/src/test/app1/StaticActivationContextValueDemo.tml b/tapestry-core/src/test/app1/StaticActivationContextValueDemo.tml
index 609b85c..df7bc3e 100644
--- a/tapestry-core/src/test/app1/StaticActivationContextValueDemo.tml
+++ b/tapestry-core/src/test/app1/StaticActivationContextValueDemo.tml
@@ -3,8 +3,8 @@
 
     <p>
         This is way more of a test for <code>@StaticActivationContextValue</code>,
-        which is targeted at implementing multiple REST endpoints in a single component page
-        than an example of usage. Changing page or component
+        which is targeted at implementing multiple REST endpoints in a single component page,
+        than a good, real life example of usage. Changing page or component
         state is better done with <code>EventLink</code>. 
     </p>
 
diff --git a/tapestry-core/src/test/app1/WEB-INF/app.properties b/tapestry-core/src/test/app1/WEB-INF/app.properties
index 50a7f3b..3e42ca1 100644
--- a/tapestry-core/src/test/app1/WEB-INF/app.properties
+++ b/tapestry-core/src/test/app1/WEB-INF/app.properties
@@ -21,3 +21,29 @@ client-accessible=Client Accessible
 
 not-visible=Contains a %, not visible.
 private-is-not-visible=Not visible because of private- prefix.
+
+openapi-title=Tapestry Demo title
+openapi-description=Tapestry Demo description
+openapi-application-version=1.2.3.4.23333332323
+
+openapi./restrequestnothandleddemo.put.response.200=SQN
+openapi.put.response.200 = Generic 200
+openapi.response.200 = Generic 200
+openapi.response.201 = PUT request succesful
+
+openapi.org.apache.tapestry5.integration.app1.pages.RestRequestNotHandledDemo.tag.name=fcqnTag
+openapi.RestRequestNotHandledDemo.tag.name=nameTag
+
+openapi.org.apache.tapestry5.integration.app1.pages.RestRequestNotHandledDemo.tag.description=Description from FCQN
+openapi.RestRequestNotHandledDemo.tag.description=Description from name
+
+#openapi.RestWithOnEventDemo.tag.name=
+
+
+openapi.org.apache.tapestry5.integration.app1.pages.RestRequestNotHandledDemo./restrequestnothandleddemo.put.summary=Summary from FQCN name: put!
+openapi.RestRequestNotHandledDemo./restrequestnothandleddemo.put.summary=Summary from class name: put!
+openapi./restrequestnothandleddemo.put.summary=Summary from path: put!
+
+openapi.org.apache.tapestry5.integration.app1.pages.RestRequestNotHandledDemo./restrequestnothandleddemo.put.description=Description from FQCN name: put!
+openapi.RestRequestNotHandledDemo./restrequestnothandleddemo.put.description=Description from class name: put!
+openapi./restrequestnothandleddemo.put.description=Description from path: put!
\ No newline at end of file
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/base/BaseRestDemoPage.java b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/base/BaseRestDemoPage.java
new file mode 100644
index 0000000..19fa7a7
--- /dev/null
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/base/BaseRestDemoPage.java
@@ -0,0 +1,46 @@
+// Licensed 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.tapestry5.integration.app1.base;
+
+import org.apache.tapestry5.EventConstants;
+import org.apache.tapestry5.annotations.OnEvent;
+import org.apache.tapestry5.annotations.StaticActivationContextValue;
+import org.apache.tapestry5.http.services.Response;
+import org.apache.tapestry5.util.TextStreamResponse;
+
+public class BaseRestDemoPage {
+    
+    public static final String EXTRA_HTTP_HEADER = "X-Event";
+    
+    public static final String SUBPATH = "something";
+    
+    final protected static TextStreamResponse createResponse(String eventName, String body, String parameter)
+    {
+        String content = eventName + ":" + parameter + (body == null ? "" : ":" + body);
+        return new TextStreamResponse("text/plain", content) 
+        {
+            @Override
+            public void prepareResponse(Response response) {
+                super.prepareResponse(response);
+                response.addHeader(EXTRA_HTTP_HEADER, eventName);
+            }
+            
+        };
+    }
+    
+    @OnEvent(EventConstants.HTTP_GET)
+    protected Object superclassEndpoint(@StaticActivationContextValue("superclassEndpoint") String parameter)
+    {
+        return new TextStreamResponse("text/plain", parameter);
+    }
+    
+}
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/Index.java b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/Index.java
index d2fbd68..6ffb262 100644
--- a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/Index.java
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/Index.java
@@ -61,6 +61,10 @@ public class Index
                     new Item("PublishEventDemo", "@PublishEvent Demo", "Publishing server-side events to client-side code (JavaScript)"),
 
                     new Item("StaticActivationContextValueDemo", "@StaticActivationContextValue Demo", "Demonstrates the usage of @StaticActivationContextValue"),
+                    
+                    new Item("RestWithOnEventDemo", "REST with @OnEvent Demo", "Demonstrates the usage of @OnEvent to handle REST requests"),                    
+                    
+                    new Item("RestWithEventHandlerMethodNameDemo", "REST with Event Handler Method Name Demo", "Demonstrates the usage of event handler method names to handle REST requests"),
 
                     new Item("Html5DateFieldDemo", "Html5DateField Demo", "Choosing dates using the native HTML5 date picker"),
 
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONObject.java b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/OpenApiDescriptionDemo.java
similarity index 52%
copy from tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONObject.java
copy to tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/OpenApiDescriptionDemo.java
index 0663567..9539bc2 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONObject.java
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/OpenApiDescriptionDemo.java
@@ -1,5 +1,3 @@
-// Copyright  2011 The Apache Software Foundation
-//
 // Licensed 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
@@ -11,18 +9,20 @@
 // 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.tapestry5.integration.app1.pages;
 
-package org.apache.tapestry5.internal.json;
-
-import org.apache.tapestry5.commons.services.Coercion;
-import org.apache.tapestry5.json.JSONObject;
+import org.apache.tapestry5.ioc.annotations.Inject;
+import org.apache.tapestry5.services.OpenApiDescriptionGenerator;
+import org.apache.tapestry5.util.TextStreamResponse;
 
-/**
- * @since 5.3
- */
-public class StringToJSONObject  implements Coercion<String,JSONObject> {
-    @Override
-    public JSONObject coerce(String input) {
-        return new JSONObject(input);
+public class OpenApiDescriptionDemo  {
+    
+    @Inject
+    private OpenApiDescriptionGenerator openApiDescriptionGenerator;
+    
+    Object onActivate()
+    {
+        return new TextStreamResponse("application/json", openApiDescriptionGenerator.generate(null).toString());
     }
+    
 }
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONArray.java b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/RestRequestNotHandledDemo.java
similarity index 59%
copy from tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONArray.java
copy to tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/RestRequestNotHandledDemo.java
index 2013f93..63b3d7a 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONArray.java
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/RestRequestNotHandledDemo.java
@@ -1,5 +1,3 @@
-// Copyright 2011 The Apache Software Foundation
-//
 // Licensed 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
@@ -11,18 +9,16 @@
 // 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.tapestry5.integration.app1.pages;
 
-package org.apache.tapestry5.internal.json;
-
-import org.apache.tapestry5.commons.services.Coercion;
-import org.apache.tapestry5.json.JSONArray;
+import org.apache.tapestry5.EventConstants;
+import org.apache.tapestry5.integration.app1.base.BaseRestDemoPage;
 
-/**
- * @since 5.3
- */
-public class StringToJSONArray implements Coercion<String,JSONArray> {
-    @Override
-    public JSONArray coerce(String input) {
-        return new JSONArray(input);
+public class RestRequestNotHandledDemo extends BaseRestDemoPage {
+    
+    Object onHttpPut()
+    {
+        return createResponse(EventConstants.HTTP_PUT, "no body", "no parameter");
     }
+
 }
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/RestWithEventHandlerMethodNameDemo.java b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/RestWithEventHandlerMethodNameDemo.java
new file mode 100644
index 0000000..e07c256
--- /dev/null
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/RestWithEventHandlerMethodNameDemo.java
@@ -0,0 +1,62 @@
+// Licensed 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.tapestry5.integration.app1.pages;
+
+import org.apache.tapestry5.EventConstants;
+import org.apache.tapestry5.annotations.OnEvent;
+import org.apache.tapestry5.annotations.RequestBody;
+import org.apache.tapestry5.annotations.StaticActivationContextValue;
+import org.apache.tapestry5.integration.app1.base.BaseRestDemoPage;
+
+public class RestWithEventHandlerMethodNameDemo extends BaseRestDemoPage {
+    
+    Object onHttpGet(@StaticActivationContextValue(SUBPATH) String subpath, String parameter)
+    {
+        return createResponse(EventConstants.HTTP_GET, null, parameter);
+    }
+
+    Object onHttpDelete(@StaticActivationContextValue(SUBPATH) String subpath, String parameter) 
+    {
+        return createResponse(EventConstants.HTTP_DELETE, null, parameter);
+    }
+
+    Object onHttpHead(@StaticActivationContextValue(SUBPATH) String subpath, String parameter)
+    {
+        return createResponse(EventConstants.HTTP_HEAD, null, parameter);
+    }
+
+    Object onHttpPatch(
+            @StaticActivationContextValue(SUBPATH) String subpath, 
+            String parameter, 
+            @RequestBody String body)
+    {
+        return createResponse(EventConstants.HTTP_PATCH, body, parameter);
+    }
+
+    Object onHttpPost(
+            @StaticActivationContextValue(SUBPATH) String subpath, 
+            String parameter, 
+            @RequestBody String body)
+    {
+        return createResponse(EventConstants.HTTP_POST, body, parameter);
+    }
+
+    @OnEvent(EventConstants.HTTP_PUT)
+    Object onHttpPut(
+            @StaticActivationContextValue(SUBPATH) String subpath, 
+            String parameter, 
+            @RequestBody String body)
+    {
+        return createResponse(EventConstants.HTTP_PUT, body, parameter);
+    }
+
+}
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/RestWithOnEventDemo.java b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/RestWithOnEventDemo.java
new file mode 100644
index 0000000..4d6ee77
--- /dev/null
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/RestWithOnEventDemo.java
@@ -0,0 +1,76 @@
+// Licensed 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.tapestry5.integration.app1.pages;
+
+import org.apache.tapestry5.EventConstants;
+import org.apache.tapestry5.annotations.OnEvent;
+import org.apache.tapestry5.annotations.RequestBody;
+import org.apache.tapestry5.annotations.StaticActivationContextValue;
+import org.apache.tapestry5.integration.app1.base.BaseRestDemoPage;
+import org.apache.tapestry5.util.TextStreamResponse;
+
+public class RestWithOnEventDemo extends BaseRestDemoPage {
+    
+    @OnEvent(EventConstants.HTTP_GET)
+    Object get(@StaticActivationContextValue(SUBPATH) String subpath, String parameter)
+    {
+        return createResponse(EventConstants.HTTP_GET, null, parameter);
+    }
+
+    @OnEvent(value = EventConstants.HTTP_DELETE)
+    Object delete(@StaticActivationContextValue(SUBPATH) String subpath, String parameter) 
+    {
+        return createResponse(EventConstants.HTTP_DELETE, null, parameter);
+    }
+
+    @OnEvent(EventConstants.HTTP_HEAD)
+    Object head(@StaticActivationContextValue(SUBPATH) String subpath, String parameter)
+    {
+        return createResponse(EventConstants.HTTP_HEAD, null, parameter);
+    }
+
+    @OnEvent(EventConstants.HTTP_PATCH)
+    Object patch(
+            @StaticActivationContextValue(SUBPATH) String subpath, 
+            String parameter, 
+            @RequestBody String body)
+    {
+        return createResponse(EventConstants.HTTP_PATCH, body, parameter);
+    }
+
+    @OnEvent(EventConstants.HTTP_POST)
+    Object post(
+            @StaticActivationContextValue(SUBPATH) String subpath, 
+            String parameter, 
+            @RequestBody String body)
+    {
+        return createResponse(EventConstants.HTTP_POST, body, parameter);
+    }
+
+    @OnEvent(EventConstants.HTTP_PUT)
+    Object put(
+            @StaticActivationContextValue(SUBPATH) String subpath, 
+            String parameter, 
+            @RequestBody String body)
+    {
+        return createResponse(EventConstants.HTTP_PUT, body, parameter);
+    }
+    
+    @OnEvent(EventConstants.HTTP_GET)
+    @Override
+    public Object superclassEndpoint(@StaticActivationContextValue("superclassEndpoint") String parameter)
+    {
+        return new TextStreamResponse("text/plain", parameter + " overridden!");
+    }
+
+
+}
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/services/AppModule.java b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/services/AppModule.java
index db04ba5..a97b930 100644
--- a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/services/AppModule.java
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/services/AppModule.java
@@ -181,6 +181,7 @@ public class AppModule
 
         configuration.add(D3_URL_SYMBOL, "cdnjs.cloudflare.com/ajax/libs/d3/3.0.0/d3.js");
         configuration.add(SymbolConstants.PRELOADER_MODE, PreloaderMode.ALWAYS);
+        configuration.add(SymbolConstants.OPENAPI_APPLICATION_VERSION, "1.2.3.4");
 //        configuration.add(SymbolConstants.ERROR_CSS_CLASS, "yyyy");
 //        configuration.add(SymbolConstants.DEFAULT_STYLESHEET, "classpath:/org/apache/tapestry5/integration/app1/app1.css");
     }
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/integration/rest/RestTests.java b/tapestry-core/src/test/java/org/apache/tapestry5/integration/rest/RestTests.java
new file mode 100644
index 0000000..5072084
--- /dev/null
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/integration/rest/RestTests.java
@@ -0,0 +1,201 @@
+// Copyright 2011 The Apache Software Foundation
+//
+// Licensed 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.tapestry5.integration.rest;
+
+import java.io.IOException;
+
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.http.Header;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpEntityEnclosingRequest;
+import org.apache.http.client.ClientProtocolException;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpDelete;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpHead;
+import org.apache.http.client.methods.HttpPatch;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.client.methods.HttpRequestBase;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.tapestry5.EventConstants;
+import org.apache.tapestry5.integration.app1.App1TestCase;
+import org.apache.tapestry5.integration.app1.pages.RestRequestNotHandledDemo;
+import org.apache.tapestry5.integration.app1.pages.RestWithOnEventDemo;
+import org.testng.annotations.Test;
+
+/**
+ * Tests REST-related stuff.
+ */
+public class RestTests extends App1TestCase
+{
+    final private static String POST_CONTENT = "órgão and ôthèr words with äccents";
+    
+    final private static String ENDPOINT_URL = RestWithOnEventDemo.class.getSimpleName();
+    
+    final private static String PATH_PARAMETER_VALUE = "nice";
+    
+    @Test
+    public void on_event_http_get() throws IOException
+    {
+        test(EventConstants.HTTP_GET, new HttpGet(getUrl()));
+    }
+    
+    @Test
+    public void on_event_http_post() throws IOException
+    {
+        test(EventConstants.HTTP_POST, new HttpPost(getUrl()));
+    }
+
+    @Test
+    public void on_event_http_put() throws IOException
+    {
+        test(EventConstants.HTTP_PUT, new HttpPut(getUrl()));
+    }
+
+    @Test
+    public void on_event_http_delete() throws IOException
+    {
+        test(EventConstants.HTTP_DELETE, new HttpDelete(getUrl()));
+    }
+
+    @Test
+    public void on_event_http_patch() throws IOException
+    {
+        test(EventConstants.HTTP_PATCH, new HttpPatch(getUrl()));
+    }
+
+    @Test
+    public void on_event_http_head() throws IOException
+    {
+        test(EventConstants.HTTP_HEAD, new HttpHead(getUrl()));
+    }
+    
+    @Test
+    public void on_http_get() throws IOException
+    {
+        test(EventConstants.HTTP_GET, new HttpGet(getUrl()));
+    }
+    
+    @Test
+    public void on_http_post() throws IOException
+    {
+        test(EventConstants.HTTP_POST, new HttpPost(getUrl()));
+    }
+
+    @Test
+    public void on_http_put() throws IOException
+    {
+        test(EventConstants.HTTP_PUT, new HttpPut(getUrl()));
+    }
+
+    @Test
+    public void on_http_delete() throws IOException
+    {
+        test(EventConstants.HTTP_DELETE, new HttpDelete(getUrl()));
+    }
+
+    @Test
+    public void on_http_patch() throws IOException
+    {
+        test(EventConstants.HTTP_PATCH, new HttpPatch(getUrl()));
+    }
+
+    @Test
+    public void on_http_head() throws IOException
+    {
+        test(EventConstants.HTTP_HEAD, new HttpHead(getUrl()));
+    }
+
+    @Test
+    public void no_matching_rest_event_handler() throws IOException
+    {
+        final String url = getBaseURL() + "/" + RestRequestNotHandledDemo.class.getSimpleName();
+        try (final CloseableHttpClient httpClient = HttpClients.createDefault())
+        {
+            HttpHead httpHead = new HttpHead(url);
+            try (CloseableHttpResponse response = httpClient.execute(httpHead))
+            {
+                assertEquals(response.getStatusLine().getStatusCode(), HttpServletResponse.SC_NOT_FOUND);
+            }
+        }
+    }
+    
+    @Test
+    public void asset_requested_with_head() throws IOException
+    {
+        openLinks("AssetDemo");
+        String url = getBaseURL() + getText("assetUrl");
+        try (final CloseableHttpClient httpClient = HttpClients.createDefault())
+        {
+            // Copied directly from AssetWithWrongChecksum.js, which shouldn't change anyway
+            final String assetContents = "document.getElementById('assetWithWrongChecksum').style.display = 'block';";
+            
+            final HttpGet httpGet = new HttpGet(url);
+            try (CloseableHttpResponse httpGetResponse = httpClient.execute(httpGet))
+            {
+                assertEquals(httpGetResponse.getEntity().getContentLength(), assetContents.length());
+                assertEquals(IOUtils.toString(httpGetResponse.getEntity().getContent()), assetContents);
+            }
+
+            final HttpHead httpHead = new HttpHead(url);
+            try (CloseableHttpResponse httpHeadResponse = httpClient.execute(httpHead))
+            {
+                assertNull(httpHeadResponse.getEntity());
+                assertEquals(httpHeadResponse.getFirstHeader("Content-Length").getValue(), "0");
+            }
+            
+        }
+        
+    }
+    
+    private void test(String eventName, HttpRequestBase method) throws ClientProtocolException, IOException
+    {
+        String expectedResponse = eventName + ":" + PATH_PARAMETER_VALUE;;
+        if (method instanceof HttpEntityEnclosingRequest)
+        {
+            HttpEntityEnclosingRequest heer = (HttpEntityEnclosingRequest) method;
+            heer.setEntity(new StringEntity(POST_CONTENT, "UTF-8"));
+            expectedResponse = expectedResponse + ":" + POST_CONTENT;
+        }
+        try (CloseableHttpClient httpClient = HttpClients.createDefault();
+                CloseableHttpResponse response = httpClient.execute(method))
+        {
+            assertEquals(response.getStatusLine().getStatusCode(), 200);
+            HttpEntity entity = response.getEntity();
+            if (entity != null)
+            {
+                assertEquals(IOUtils.toString(entity.getContent()), expectedResponse);
+            }
+            else
+            {
+                assertEquals(eventName, EventConstants.HTTP_HEAD);
+            }
+            Header[] headers = response.getHeaders(RestWithOnEventDemo.EXTRA_HTTP_HEADER);
+            assertEquals(headers.length, 1);
+            assertEquals(headers[0].getValue(), eventName);
+        }
+    }
+
+    private String getUrl() {
+        return getBaseURL() + ENDPOINT_URL + "/" + 
+                RestWithOnEventDemo.SUBPATH + "/" + PATH_PARAMETER_VALUE;
+    }
+
+}
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/DefaultRequestExceptionHandlerTest.java b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/DefaultRequestExceptionHandlerTest.java
index a042bb4..bcd9233 100644
--- a/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/DefaultRequestExceptionHandlerTest.java
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/DefaultRequestExceptionHandlerTest.java
@@ -102,7 +102,7 @@ public class DefaultRequestExceptionHandlerTest extends InternalBaseTestCase
             }
         };
 
-        exceptionHandler = new DefaultRequestExceptionHandler(pageCache, renderer, logger, "exceptionpage", request, response, componentClassResolver, linkSource, serviceResources, noopExceptionReporter, mockConfiguration);
+        exceptionHandler = new DefaultRequestExceptionHandler(pageCache, renderer, logger, "exceptionpage", request, response, componentClassResolver, linkSource, serviceResources, noopExceptionReporter, false, mockConfiguration);
     }
 
 
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/RestSupportImplTest.java b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/RestSupportImplTest.java
new file mode 100644
index 0000000..d169397
--- /dev/null
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/RestSupportImplTest.java
@@ -0,0 +1,155 @@
+// Copyright 2011 The Apache Software Foundation
+//
+// Licensed 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.tapestry5.internal.services;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+import javax.servlet.ServletInputStream;
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.tapestry5.http.internal.services.RestSupportImpl;
+import org.apache.tapestry5.http.services.HttpRequestBodyConverter;
+import org.apache.tapestry5.http.services.RestSupport;
+import org.apache.tapestry5.internal.test.InternalBaseTestCase;
+import org.easymock.EasyMock;
+import org.testng.annotations.Test;
+
+/**
+ * Tests {@link RestSupportImpl}.
+ */
+public class RestSupportImplTest extends InternalBaseTestCase
+{
+    final private static byte[] EMPTY_ARRAY = new byte[0];
+    
+    @Test
+    public void is_get() throws IOException
+    {
+        test("GET", EMPTY_ARRAY, (rs) -> assertTrue(rs.isHttpGet()));
+        test("notGET", EMPTY_ARRAY, (rs) -> assertFalse(rs.isHttpGet()));
+    }
+
+    @Test
+    public void is_post() throws IOException
+    {
+        test("POST", EMPTY_ARRAY, (rs) -> assertTrue(rs.isHttpPost()));
+        test("notPOST", EMPTY_ARRAY, (rs) -> assertFalse(rs.isHttpPost()));        
+    }
+
+    @Test
+    public void is_head() throws IOException
+    {
+        test("HEAD", EMPTY_ARRAY, (rs) -> assertTrue(rs.isHttpHead()));
+        test("notHEAD", EMPTY_ARRAY, (rs) -> assertFalse(rs.isHttpHead()));
+    }
+    
+    @Test
+    public void is_put() throws IOException
+    {
+        test("PUT", EMPTY_ARRAY, (rs) -> assertTrue(rs.isHttpPut()));
+        test("notPUT", EMPTY_ARRAY, (rs) -> assertFalse(rs.isHttpPut()));        
+    }
+
+    @Test
+    public void is_delete() throws IOException
+    {
+        test("DELETE", EMPTY_ARRAY, (rs) -> assertTrue(rs.isHttpDelete()));
+        test("notDELETE", EMPTY_ARRAY, (rs) -> assertFalse(rs.isHttpDelete()));
+    }
+
+    @Test
+    public void is_patch() throws IOException
+    {
+        test("PATCH", EMPTY_ARRAY, (rs) -> assertTrue(rs.isHttpPatch()));
+        test("notPATCH", EMPTY_ARRAY, (rs) -> assertFalse(rs.isHttpPatch()));
+    }
+    
+    @Test
+    public void get_request_body_as_result_provided()
+    {
+        final String TEXT_CONTENT = "asdfadfasdfs";
+        HttpRequestBodyConverter converter = new TestHttpRequestBodyConverter(TEXT_CONTENT);
+        RestSupport restSupport = new RestSupportImpl(null, converter);
+        Optional<String> result = restSupport.getRequestBodyAs(String.class);
+        assertTrue(result.isPresent());
+        assertEquals(result.get(), TEXT_CONTENT);
+    }
+
+    @Test
+    public void get_request_body_as_null_result()
+    {
+        HttpRequestBodyConverter converter = new TestHttpRequestBodyConverter(null);
+        RestSupport restSupport = new RestSupportImpl(null, converter);
+        Optional<String> result = restSupport.getRequestBodyAs(String.class);
+        assertFalse(result.isPresent());
+    }
+    
+    final private static class TestHttpRequestBodyConverter implements HttpRequestBodyConverter
+    {
+        final private String value;
+
+        public TestHttpRequestBodyConverter(String value) 
+        {
+            super();
+            this.value = value;
+        }
+
+        @SuppressWarnings("unchecked")
+        @Override
+        public <T> T convert(HttpServletRequest request, Class<T> type) {
+            return (T) value;
+        }
+        
+    }
+
+    private void test(String method, byte[] body, Consumer<RestSupport> testCode) throws IOException
+    {
+        HttpServletRequest request = createRequest(method, body);
+        RestSupport restSupport = new RestSupportImpl(request, null);
+        testCode.accept(restSupport);
+        EasyMock.verify(request);
+    }
+
+    private HttpServletRequest createRequest(String method, byte[] body) throws IOException
+    {
+        HttpServletRequest request = EasyMock.createMock(HttpServletRequest.class);
+        EasyMock.expect(request.getMethod()).andReturn(method).anyTimes();
+        EasyMock.expect(request.getInputStream()).andReturn(new TestServletInputStream(body)).anyTimes();
+        EasyMock.replay(request);
+        return request;
+    }
+    
+    final private static class TestServletInputStream extends ServletInputStream
+    {
+        
+        final private InputStream inputStream;
+        
+        public TestServletInputStream(byte[] bytes) 
+        {
+            inputStream = new ByteArrayInputStream(bytes);
+        }
+
+        @Override
+        public int read() throws IOException 
+        {
+            return inputStream.read();
+        }
+        
+    }
+    
+}
diff --git a/tapestry-core/src/test/resources/org/apache/tapestry5/integration/app1/pages/nested/AssetDemo.tml b/tapestry-core/src/test/resources/org/apache/tapestry5/integration/app1/pages/nested/AssetDemo.tml
index 695a825..ab2b1d1 100644
--- a/tapestry-core/src/test/resources/org/apache/tapestry5/integration/app1/pages/nested/AssetDemo.tml
+++ b/tapestry-core/src/test/resources/org/apache/tapestry5/integration/app1/pages/nested/AssetDemo.tml
@@ -85,7 +85,7 @@
 
 <div id="viz"></div>
 
-<p>Asset with good checksum: ${assetWithCorrectChecksum}</p>
+<p>Asset with good checksum: <span id="assetUrl">${assetWithCorrectChecksum}</span></p>
 <p>Asset with bad checksum: ${assetWithWrongChecksumUrl}</p>
 <p id="assetWithWrongChecksum" style="display: none">Asset with wrong checksum handled correctly.</p>
 
diff --git a/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/TypeCoercerHttpRequestBodyConverter.java b/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/TypeCoercerHttpRequestBodyConverter.java
new file mode 100644
index 0000000..b3d48f5
--- /dev/null
+++ b/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/TypeCoercerHttpRequestBodyConverter.java
@@ -0,0 +1,49 @@
+// Licensed 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.tapestry5.http.internal;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.tapestry5.commons.internal.util.TapestryException;
+import org.apache.tapestry5.commons.services.TypeCoercer;
+import org.apache.tapestry5.commons.util.CoercionNotFoundException;
+import org.apache.tapestry5.http.services.HttpRequestBodyConverter;
+
+final public class TypeCoercerHttpRequestBodyConverter implements HttpRequestBodyConverter
+{
+    
+    final private TypeCoercer typeCoercer;
+
+    public TypeCoercerHttpRequestBodyConverter(TypeCoercer typeCoercer) 
+    {
+        super();
+        this.typeCoercer = typeCoercer;
+    }
+
+    @Override
+    public <T> T convert(HttpServletRequest request, Class<T> type) 
+    {
+        T value;
+        try
+        {
+            value = typeCoercer.coerce(request, type);
+        } catch (CoercionNotFoundException e)
+        {
+            throw new TapestryException(
+                    String.format("Couldn't find a coercion from InputStream to %s "
+                            + " since no %s converted it", type.getName(), HttpRequestBodyConverter.class.getSimpleName())
+                    , e);
+        }
+        return value;
+    }
+    
+}
\ No newline at end of file
diff --git a/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/services/RestSupportImpl.java b/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/services/RestSupportImpl.java
new file mode 100644
index 0000000..5831af5
--- /dev/null
+++ b/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/services/RestSupportImpl.java
@@ -0,0 +1,87 @@
+// Copyright 2021 The Apache Software Foundation
+//
+// Licensed 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.tapestry5.http.internal.services;
+
+import java.util.Optional;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.tapestry5.http.services.HttpRequestBodyConverter;
+import org.apache.tapestry5.http.services.RestSupport;
+
+/**
+ * Default {@linkplain RestSupport} implementation.
+ */
+public class RestSupportImpl implements RestSupport 
+{
+    
+    final private HttpServletRequest request;
+    
+    final private HttpRequestBodyConverter converter;
+
+    public RestSupportImpl(final HttpServletRequest request, final HttpRequestBodyConverter converter) 
+    {
+        super();
+        this.request = request;
+        this.converter = converter;
+    }
+
+    @Override
+    public boolean isHttpGet() 
+    {
+        return isMethod("GET");
+    }
+
+    @Override
+    public boolean isHttpPost() 
+    {
+        return isMethod("POST");
+    }
+
+    @Override
+    public boolean isHttpHead() 
+    {
+        return isMethod("HEAD");
+    }
+
+    @Override
+    public boolean isHttpPut() 
+    {
+        return isMethod("PUT");
+    }
+
+    @Override
+    public boolean isHttpDelete() 
+    {
+        return isMethod("DELETE");
+    }
+
+    @Override
+    public boolean isHttpPatch() 
+    {
+        return isMethod("PATCH");
+    }
+
+    private boolean isMethod(String string) 
+    {
+        return request.getMethod().equals(string);
+    }
+
+    @Override
+    public <T> Optional<T> getRequestBodyAs(Class<T> type) 
+    {
+        return Optional.ofNullable(converter.convert(request, type));
+    }
+    
+}
diff --git a/tapestry-http/src/main/java/org/apache/tapestry5/http/modules/TapestryHttpModule.java b/tapestry-http/src/main/java/org/apache/tapestry5/http/modules/TapestryHttpModule.java
index 42d054c..87f500a 100644
--- a/tapestry-http/src/main/java/org/apache/tapestry5/http/modules/TapestryHttpModule.java
+++ b/tapestry-http/src/main/java/org/apache/tapestry5/http/modules/TapestryHttpModule.java
@@ -12,7 +12,10 @@
 
 package org.apache.tapestry5.http.modules;
 
+import java.io.BufferedReader;
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
 import java.util.List;
 import java.util.Map;
 
@@ -20,10 +23,14 @@ import javax.servlet.ServletContext;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
+import org.apache.commons.io.IOUtils;
 import org.apache.tapestry5.commons.MappedConfiguration;
 import org.apache.tapestry5.commons.OrderedConfiguration;
+import org.apache.tapestry5.commons.internal.util.TapestryException;
+import org.apache.tapestry5.commons.services.CoercionTuple;
 import org.apache.tapestry5.http.OptimizedSessionPersistedObject;
 import org.apache.tapestry5.http.TapestryHttpSymbolConstants;
+import org.apache.tapestry5.http.internal.TypeCoercerHttpRequestBodyConverter;
 import org.apache.tapestry5.http.internal.gzip.GZipFilter;
 import org.apache.tapestry5.http.internal.services.ApplicationGlobalsImpl;
 import org.apache.tapestry5.http.internal.services.BaseURLSourceImpl;
@@ -34,6 +41,7 @@ import org.apache.tapestry5.http.internal.services.RequestGlobalsImpl;
 import org.apache.tapestry5.http.internal.services.RequestImpl;
 import org.apache.tapestry5.http.internal.services.ResponseCompressionAnalyzerImpl;
 import org.apache.tapestry5.http.internal.services.ResponseImpl;
+import org.apache.tapestry5.http.internal.services.RestSupportImpl;
 import org.apache.tapestry5.http.internal.services.TapestrySessionFactory;
 import org.apache.tapestry5.http.internal.services.TapestrySessionFactoryImpl;
 import org.apache.tapestry5.http.services.ApplicationGlobals;
@@ -42,6 +50,7 @@ import org.apache.tapestry5.http.services.ApplicationInitializerFilter;
 import org.apache.tapestry5.http.services.BaseURLSource;
 import org.apache.tapestry5.http.services.Context;
 import org.apache.tapestry5.http.services.Dispatcher;
+import org.apache.tapestry5.http.services.HttpRequestBodyConverter;
 import org.apache.tapestry5.http.services.HttpServletRequestFilter;
 import org.apache.tapestry5.http.services.HttpServletRequestHandler;
 import org.apache.tapestry5.http.services.Request;
@@ -50,6 +59,7 @@ import org.apache.tapestry5.http.services.RequestGlobals;
 import org.apache.tapestry5.http.services.RequestHandler;
 import org.apache.tapestry5.http.services.Response;
 import org.apache.tapestry5.http.services.ResponseCompressionAnalyzer;
+import org.apache.tapestry5.http.services.RestSupport;
 import org.apache.tapestry5.http.services.ServletApplicationInitializer;
 import org.apache.tapestry5.http.services.ServletApplicationInitializerFilter;
 import org.apache.tapestry5.http.services.SessionPersistedObjectAnalyzer;
@@ -91,6 +101,7 @@ public final class TapestryHttpModule {
         binder.bind(TapestrySessionFactory.class, TapestrySessionFactoryImpl.class);
         binder.bind(BaseURLSource.class, BaseURLSourceImpl.class);
         binder.bind(ResponseCompressionAnalyzer.class, ResponseCompressionAnalyzerImpl.class);
+        binder.bind(RestSupport.class, RestSupportImpl.class);
     }
     
     /**
@@ -292,6 +303,72 @@ public final class TapestryHttpModule {
         
     }
     
+    public static HttpRequestBodyConverter buildHttpRequestBodyConverter(
+            final List<HttpRequestBodyConverter> converters,
+            final ChainBuilder chainBuilder)
+    {
+        return chainBuilder.build(HttpRequestBodyConverter.class, converters);
+    }
+    
+    public static void contributeHttpRequestBodyConverter(
+            final OrderedConfiguration<HttpRequestBodyConverter> configuration)
+    {
+        configuration.addInstance("TypeCoercer", TypeCoercerHttpRequestBodyConverter.class);
+    }
+    
+    public static void contributeTypeCoercer(MappedConfiguration<CoercionTuple.Key, CoercionTuple> configuration)
+    {
+        CoercionTuple.add(configuration, HttpServletRequest.class, String.class, TapestryHttpModule::toString);
+        CoercionTuple.add(configuration, HttpServletRequest.class, byte[].class, TapestryHttpModule::toByteArray);
+        CoercionTuple.add(configuration, HttpServletRequest.class, InputStream.class, TapestryHttpModule::toInputStream);
+        CoercionTuple.add(configuration, HttpServletRequest.class, Reader.class, TapestryHttpModule::toBufferedReader);
+        CoercionTuple.add(configuration, HttpServletRequest.class, BufferedReader.class, TapestryHttpModule::toBufferedReader);
+    }
+    
+    private final static InputStream toInputStream(HttpServletRequest request)
+    {
+        try 
+        {
+            return request.getInputStream();
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+    
+    private final static BufferedReader toBufferedReader(HttpServletRequest request)
+    {
+        try 
+        {
+            return request.getReader();
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+    
+    private final static String toString(HttpServletRequest request)
+    {
+        try (Reader reader = request.getReader())
+        {
+            String string = IOUtils.toString(reader);
+            return string.isEmpty() ? null : string;
+        }
+        catch (IOException e) {
+            throw new TapestryException(
+                    "Exception converting body from HttpServletRequest (getReader()) to String", e);
+        }        
+    }
+    
+    private final static byte[] toByteArray(HttpServletRequest request)
+    {
+        try (InputStream inputStream = request.getInputStream()) 
+        {
+            byte[] byteArray = IOUtils.toByteArray(inputStream);
+            return byteArray.length == 0 ? null : byteArray;
+        } catch (IOException e) {
+            throw new TapestryException(
+                    "Exception converting from HttpServletRequest (getInputStream()) to String", e);
+        }
+    }
     
     // A bunch of classes "promoted" from inline inner class to nested classes,
     // just so that the stack trace would be more readable. Most of these
diff --git a/tapestry-http/src/main/java/org/apache/tapestry5/http/services/HttpRequestBodyConverter.java b/tapestry-http/src/main/java/org/apache/tapestry5/http/services/HttpRequestBodyConverter.java
new file mode 100644
index 0000000..0765e84
--- /dev/null
+++ b/tapestry-http/src/main/java/org/apache/tapestry5/http/services/HttpRequestBodyConverter.java
@@ -0,0 +1,41 @@
+// Copyright 2011 The Apache Software Foundation
+//
+// Licensed 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.tapestry5.http.services;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.tapestry5.ioc.annotations.UsesOrderedConfiguration;
+
+/**
+ * Service that converts the body of an HTTP request to a given target class.
+ * Each implementation, which should be contributed to the {@link HttpRequestBodyConverter} service,
+ * should check whether it can actually handled that request. If not, it should return <code>null</code>,
+ * which means trying the next HttpRequestBodyConverter instance.
+ */
+@UsesOrderedConfiguration(HttpRequestBodyConverter.class)
+public interface HttpRequestBodyConverter
+{
+
+    /**
+     * Converts the body of this request. If this implementation cannot handle this request,
+     * probably by not handling its content type, it should return <code>null</code>.
+     * In addition, if the request body is empty, this method should also return
+     * <code>null<code>.
+     * @param request an {@linkplain HttpServletRequest}.
+     * @param type the target type.
+     * @return an object of the target type or <code>null</code>.
+     */
+    <T> T convert(HttpServletRequest request, Class<T> type);
+    
+}
diff --git a/tapestry-http/src/main/java/org/apache/tapestry5/http/services/RestSupport.java b/tapestry-http/src/main/java/org/apache/tapestry5/http/services/RestSupport.java
new file mode 100644
index 0000000..cc13e10
--- /dev/null
+++ b/tapestry-http/src/main/java/org/apache/tapestry5/http/services/RestSupport.java
@@ -0,0 +1,76 @@
+// Copyright 2021 The Apache Software Foundation
+//
+// Licensed 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.tapestry5.http.services;
+
+import java.util.Optional;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.tapestry5.commons.services.TypeCoercer;
+
+/**
+ * Service which provides REST-related utilities.
+ * @since 5.8.0
+ */
+public interface RestSupport
+{
+    
+    /**
+     * Is this request a GET?
+     * @return <code>true</code> or <code>false</code>
+     */
+    boolean isHttpGet();
+    
+    /**
+     * Is this request a POST?
+     * @return <code>true</code> or <code>false</code>
+     */
+    boolean isHttpPost();
+
+    /**
+     * Is this request a HEAD?
+     * @return <code>true</code> or <code>false</code>
+     */
+    boolean isHttpHead();
+
+    /**
+     * Is this request a PUT?
+     * @return <code>true</code> or <code>false</code>
+     */
+    boolean isHttpPut();
+    
+    /**
+     * Is this request a HEAD?
+     * @return <code>true</code> or <code>false</code>
+     */
+    boolean isHttpDelete();
+
+    /**
+     * Is this request a HEAD?
+     * @return <code>true</code> or <code>false</code>
+     */
+    boolean isHttpPatch();
+
+    /**
+     * Returns, if present, the body of the request body coerced to a given type. If the body is empty,
+     * an empty {@linkplain Optional} is returned. Coercions are done through, which uses
+     * {@linkplain TypeCoercer} as a fallback (coercing {@linkplain HttpServletRequest} to the target type).
+     * @param <T> the type of the return value.
+     * @param type the target type.
+     * @return an <code>Optional</code> wrapping the resulting object.
+     */
+    <T> Optional<T> getRequestBodyAs(Class<T> type);
+    
+}
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONArray.java b/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONArray.java
index 2013f93..e63b64c 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONArray.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONArray.java
@@ -23,6 +23,6 @@ import org.apache.tapestry5.json.JSONArray;
 public class StringToJSONArray implements Coercion<String,JSONArray> {
     @Override
     public JSONArray coerce(String input) {
-        return new JSONArray(input);
+        return input != null ? new JSONArray(input) : null;
     }
 }
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONObject.java b/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONObject.java
index 0663567..a66922b 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONObject.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/internal/json/StringToJSONObject.java
@@ -23,6 +23,6 @@ import org.apache.tapestry5.json.JSONObject;
 public class StringToJSONObject  implements Coercion<String,JSONObject> {
     @Override
     public JSONObject coerce(String input) {
-        return new JSONObject(input);
+        return input != null ? new JSONObject(input) : null;
     }
 }