 core/modules/contact/contact.module                |   8 ++
 .../rest/resource/ContactMessageResource.php       |  25 ++++
 .../Message/MessageHalJsonAnonTest.php             |  45 +++++++
 .../Message/MessageHalJsonBasicAuthTest.php        |  24 ++++
 .../Message/MessageHalJsonCookieTest.php           |  19 +++
 .../EntityResource/EntityResourceTestBase.php      |  60 +++++----
 .../EntityResource/Message/MessageJsonAnonTest.php |  24 ++++
 .../Message/MessageJsonBasicAuthTest.php           |  34 +++++
 .../Message/MessageJsonCookieTest.php              |  29 ++++
 .../Message/MessageResourceTestBase.php            | 147 +++++++++++++++++++++
 10 files changed, 391 insertions(+), 24 deletions(-)

diff --git a/core/modules/contact/contact.module b/core/modules/contact/contact.module
index 65e7592..5234949 100644
--- a/core/modules/contact/contact.module
+++ b/core/modules/contact/contact.module
@@ -7,6 +7,7 @@
 
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\contact\Plugin\rest\resource\ContactMessageResource;
 use Drupal\user\Entity\User;
 
 /**
@@ -235,3 +236,10 @@ function contact_form_user_admin_settings_submit($form, FormStateInterface $form
     ->set('user_default_enabled', $form_state->getValue('contact_default_status'))
     ->save();
 }
+
+/**
+ * Implements hook_rest_resource_alter().
+ */
+function contact_rest_resource_alter(&$definitions) {
+  $definitions['entity:contact_message']['class'] = ContactMessageResource::class;
+}
diff --git a/core/modules/contact/src/Plugin/rest/resource/ContactMessageResource.php b/core/modules/contact/src/Plugin/rest/resource/ContactMessageResource.php
new file mode 100644
index 0000000..34ea0d4
--- /dev/null
+++ b/core/modules/contact/src/Plugin/rest/resource/ContactMessageResource.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Drupal\contact\Plugin\rest\resource;
+
+use Drupal\rest\Plugin\rest\resource\EntityResource;
+
+/**
+ * Customizes the entity REST Resource plugin for Contact's Message entities.
+ *
+ * Message entities are not stored, so they cannot be:
+ * - retrieved (GET)
+ * - modified (PATCH)
+ * - deleted (DELETE)
+ * Messages can only be sent/created (POST).
+ */
+class ContactMessageResource extends EntityResource {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function availableMethods() {
+    return ['POST'];
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Message/MessageHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Message/MessageHalJsonAnonTest.php
new file mode 100644
index 0000000..a9b3ae1
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Message/MessageHalJsonAnonTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Message;
+
+use Drupal\Tests\hal\Functional\EntityResource\HalEntityNormalizationTrait;
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\Message\MessageResourceTestBase;
+
+/**
+ * @group hal
+ */
+class MessageHalJsonAnonTest extends MessageResourceTestBase {
+
+  use HalEntityNormalizationTrait;
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return parent::getNormalizedPostEntity() + [
+      '_links' => [
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/contact_message/camelids',
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Message/MessageHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Message/MessageHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..a24fd6e
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Message/MessageHalJsonBasicAuthTest.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Message;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+
+/**
+ * @group hal
+ */
+class MessageHalJsonBasicAuthTest extends MessageHalJsonAnonTest {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Message/MessageHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Message/MessageHalJsonCookieTest.php
new file mode 100644
index 0000000..c6ea4ac
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Message/MessageHalJsonCookieTest.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Message;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group hal
+ */
+class MessageHalJsonCookieTest extends MessageHalJsonAnonTest {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
index fe4ebb8..41d9eec 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
@@ -6,6 +6,7 @@
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Cache\CacheableResponseInterface;
 use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\ContentEntityNullStorage;
 use Drupal\Core\Entity\FieldableEntityInterface;
 use Drupal\Core\Url;
 use Drupal\field\Entity\FieldConfig;
@@ -176,11 +177,15 @@ public function setUp() {
         ->save();
 
       // Reload entity so that it has the new field.
-      $this->entity = $this->entityStorage->loadUnchanged($this->entity->id());
-
-      // Set a default value on the field.
-      $this->entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']);
-      $this->entity->save();
+      $reloaded_entity = $this->entityStorage->loadUnchanged($this->entity->id());
+      // Some entity types are not stored, hence they cannot be reloaded.
+      if ($reloaded_entity !== NULL) {
+        $this->entity = $reloaded_entity;
+
+        // Set a default value on the field.
+        $this->entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']);
+        $this->entity->save();
+      }
     }
   }
 
@@ -846,23 +851,27 @@ public function testPost() {
       $this->assertSame([], $response->getHeader('Location'));
     }
     $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
-    // Assert that the entity was indeed created, and that the response body
-    // contains the serialized created entity.
-    $created_entity = $this->entityStorage->loadUnchanged(static::$firstCreatedEntityId);
-    $created_entity_normalization = $this->serializer->normalize($created_entity, static::$format, ['account' => $this->account]);
-    // @todo Remove this if-test in https://www.drupal.org/node/2543726: execute
-    // its body unconditionally.
-    if (static::$entityTypeId !== 'taxonomy_term') {
-      $this->assertSame($created_entity_normalization, $this->serializer->decode((string) $response->getBody(), static::$format));
-    }
-    // Assert that the entity was indeed created using the POSTed values.
-    foreach ($this->getNormalizedPostEntity() as $field_name => $field_normalization) {
-      // Some top-level keys in the normalization may not be fields on the
-      // entity (for example '_links' and '_embedded' in the HAL normalization).
-      if ($created_entity->hasField($field_name)) {
-        // Subset, not same, because we can e.g. send just the target_id for the
-        // bundle in a POST request; the response will include more properties.
-        $this->assertArraySubset(static::castToString($field_normalization), $created_entity->get($field_name)->getValue(), TRUE);
+    // If the entity is stored, perform extra checks.
+    if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
+      // Assert that the entity was indeed created, and that the response body
+      // contains the serialized created entity.
+      $created_entity = $this->entityStorage->loadUnchanged(static::$firstCreatedEntityId);
+      $created_entity_normalization = $this->serializer->normalize($created_entity, static::$format, ['account' => $this->account]);
+      // @todo Remove this if-test in https://www.drupal.org/node/2543726: execute
+      // its body unconditionally.
+      if (static::$entityTypeId !== 'taxonomy_term') {
+        $this->assertSame($created_entity_normalization, $this->serializer->decode((string) $response->getBody(), static::$format));
+      }
+      // Assert that the entity was indeed created using the POSTed values.
+      foreach ($this->getNormalizedPostEntity() as $field_name => $field_normalization) {
+        // Some top-level keys in the normalization may not be fields on the
+        // entity (for example '_links' and '_embedded' in the HAL normalization).
+        if ($created_entity->hasField($field_name)) {
+          // Subset, not same, because we can e.g. send just the target_id for the
+          // bundle in a POST request; the response will include more properties.
+          $this->assertArraySubset(static::castToString($field_normalization), $created_entity->get($field_name)
+            ->getValue(), TRUE);
+        }
       }
     }
 
@@ -881,8 +890,11 @@ public function testPost() {
 
 
     // 201 for well-formed request.
-    // Delete the first created entity in case there is a uniqueness constraint.
-    $this->entityStorage->load(static::$firstCreatedEntityId)->delete();
+    // If the entity is stored, delete the first created entity (in case there
+    // is a uniqueness constraint).
+    if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
+      $this->entityStorage->load(static::$firstCreatedEntityId)->delete();
+    }
     $response = $this->request('POST', $url, $request_options);
     $this->assertResourceResponse(201, FALSE, $response);
     if ($has_canonical_url) {
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageJsonAnonTest.php
new file mode 100644
index 0000000..9f5b858
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageJsonAnonTest.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Message;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class MessageJsonAnonTest extends MessageResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageJsonBasicAuthTest.php
new file mode 100644
index 0000000..1232fb7
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageJsonBasicAuthTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Message;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class MessageJsonBasicAuthTest extends MessageResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageJsonCookieTest.php
new file mode 100644
index 0000000..ebe7281
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageJsonCookieTest.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Message;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class MessageJsonCookieTest extends MessageResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageResourceTestBase.php
new file mode 100644
index 0000000..3f1cab9
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageResourceTestBase.php
@@ -0,0 +1,147 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Message;
+
+use Drupal\contact\Entity\ContactForm;
+use Drupal\contact\Entity\Message;
+use Drupal\Core\Url;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+use Symfony\Component\Routing\Exception\RouteNotFoundException;
+
+abstract class MessageResourceTestBase extends EntityResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['contact'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'contact_message';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $labelFieldName = 'subject';
+
+  /**
+   * The Message entity.
+   *
+   * @var \Drupal\contact\MessageInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['access site-wide contact form']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    if (!ContactForm::load('camelids')) {
+      // Create a "Camelids" contact form.
+      ContactForm::create([
+        'id' => 'camelids',
+        'label' => 'Llama',
+        'message' => 'Let us know what you think about llamas',
+        'reply' => 'Llamas are indeed awesome!',
+        'recipients' => [
+          'llama@example.com',
+          'contact@example.com',
+        ],
+      ])->save();
+    }
+
+    $message = Message::create([
+      'contact_form' => 'camelids',
+      'subject' => 'Llama Gabilondo',
+      'message' => 'Llamas are awesome!',
+    ]);
+    $message->save();
+
+    return $message;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return [
+      'subject' => [
+        [
+          'value' => 'Dramallama',
+        ],
+      ],
+      'contact_form' => [
+        [
+          'target_id' => 'camelids',
+        ],
+      ],
+      'message' => [
+        [
+          'value' => 'http://www.urbandictionary.com/define.php?term=drama%20llama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    throw new \Exception('Not yet supported.');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) {
+      return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+
+    if ($method === 'POST') {
+      return "The 'access site-wide contact form' permission is required.";
+    }
+    return parent::getExpectedUnauthorizedAccessMessage($method);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testGet() {
+    // Contact Message entities are not stored, so they cannot be retrieved.
+    $this->setExpectedException(RouteNotFoundException::class, 'Route "rest.entity.contact_message.GET" does not exist.');
+
+    $this->provisionEntityResource();
+    Url::fromRoute('rest.entity.contact_message.GET')->toString(TRUE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatch() {
+    // Contact Message entities are not stored, so they cannot be modified.
+    $this->setExpectedException(RouteNotFoundException::class, 'Route "rest.entity.contact_message.PATCH" does not exist.');
+
+    $this->provisionEntityResource();
+    Url::fromRoute('rest.entity.contact_message.PATCH')->toString(TRUE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testDelete() {
+    // Contact Message entities are not stored, so they cannot be deleted.
+    $this->setExpectedException(RouteNotFoundException::class, 'Route "rest.entity.contact_message.DELETE" does not exist.');
+
+    $this->provisionEntityResource();
+    Url::fromRoute('rest.entity.contact_message.DELETE')->toString(TRUE);
+  }
+
+}
