One of the essential features inspired by drupal 7 and imported to Drupal 8 with enhancement is its REST API. Drupal provides inbuilt Rest support for exposing entities and creating new entities. You can view all those REST endpoints by installing the rest_ui module. However, sometimes we also need to define custom Rest Resources / Endpoints for our custom requirements.

So here is how we can define custom Rest Resources.

I believe you all know how to develop a custom drupal module, so let's start with understanding annotation for both GET and POST.

To enable the discovery of our Rest Resource by the system, we must declare annotation correctly as shown in the example below.

Annotation for GET:-

<?php
/**
 * @RestResource(
 *   id = "custom_rest_endpoint_get",
 *   label = @Translation("Endpoint GET"),
 *   uri_paths = {
 *     "canonical" = "/api/v1/article/{id}"
 *   }
 * )
 */

Annotation for POST:-

<?php
/**
 * @RestResource(
 *   id = "custom_rest_endpoint_post",
 *   label = @Translation("Endpoint POST"),
 *   serialization_class = "",
 *   uri_paths = {
 *     "create" = "/api/v1/email",
 *   }
 * )
 */

Id and label are common in both, id will be unique or you can say it’s machine name for our resource and label appears as a resource name in the backend. uri_path will define unique access/endpoint of our resource and so we do not need to define it in routing.yml.

In POST annotation, we are not using any serialization class so we leave it empty.

Now here is the complete code of both. our namespace is Drupal\custom_rest\Plugin\rest\resource. it means our directory will be custom_reset/src/Plugin/rest/resource/

REST Resource class GET:-

<?php

namespace Drupal\custom_rest\Plugin\rest\resource;

use Drupal\node\Entity\Node;
use Drupal\node\NodeInterface;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse;
use Drupal\Core\Session\AccountProxyInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Cache\CacheableResponseInterface;

/**
 * Annotation for get method
 *
 * @RestResource(
 *   id = "custom_rest_endpoint_get",
 *   label = @Translation("Endpoint GET"),
 *   uri_paths = {
 *     "canonical" = "/api/v1/article/{id}"
 *   }
 * )
 */
class ArticleSingleResources extends ResourceBase {

  /**
   * A current user instance.
   *
   * @var \Drupal\Core\Session\AccountProxyInterface
   */
  protected $currentUser;

  /**
   * Constructs a Drupal\rest\Plugin\ResourceBase object.
   *
   * @param array $configuration
   *   A configuration array containing information about the plugin instance.
   * @param string $plugin_id
   *   The plugin_id for the plugin instance.
   * @param mixed $plugin_definition
   *   The plugin implementation definition.
   * @param array $serializer_formats
   *   The available serialization formats.
   * @param \Psr\Log\LoggerInterface $logger
   *   A logger instance.
   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
   *   A current user instance.
   */
  public function __construct(
    array                 $configuration,
                          $plugin_id,
                          $plugin_definition,
    array                 $serializer_formats,
    LoggerInterface       $logger,
    AccountProxyInterface $current_user) {
    parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger);
    $this->currentUser = $current_user;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->getParameter('serializer.formats'),
      $container->get('logger.factory')->get('custom_rest'),
      $container->get('current_user')
    );
  }

  /**
   * Responds to GET requests. It will return serialize json format of node
   * object.
   *
   * @param $id
   *   Node id.
   */
  public function get($id) {

    if ($id) {
      // Load node
      $node = Node::load($id);
      if ($node instanceof NodeInterface) {
        $response = new ResourceResponse($node);
        // Configure caching for results
        if ($response instanceof CacheableResponseInterface) {
          $response->addCacheableDependency($node);
        }
        return $response;
      }
      return new ResourceResponse('Article doesn\'t exist', 400);
    }
    return new ResourceResponse('Article Id is required', 400);
  }

}

Requesting GET.

<?php
// Exported from the postman.
$request = new HttpRequest();
$request->setUrl('https://example.com/api/v1/article/5');
$request->setMethod(HTTP_METH_GET);

$request->setQueryData(array(
  '_format' => 'json'
));

$request->setHeaders(array(
  'cache-control' => 'no-cache',
  'accept' => 'application/json',
  'content-type' => 'application/json'
));

try {
  $response = $request->send();

  echo $response->getBody();
} catch (HttpException $ex) {
  echo $ex;
}

REST Resource class POST:-

<?php

namespace Drupal\custom_rest\Plugin\rest\resource;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Mail\MailManagerInterface;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse;
use Drupal\Core\Session\AccountProxyInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

/**
 * Annotation for post method
 *
 * @RestResource(
 *   id = "custom_rest_endpoint_post",
 *   label = @Translation("Endpoint POST"),
 *   serialization_class = "",
 *   uri_paths = {
 *     "create" = "/api/v1/email",
 *   }
 * )
 */
class EmailResources extends ResourceBase {

  /**
   * A current user instance.
   *
   * @var \Drupal\Core\Session\AccountProxyInterface
   */
  protected $currentUser;

  /**
   * Mail manager service.
   *
   * @var \Drupal\Core\Mail\MailManagerInterface
   */
  protected $mailManager;

  /**
   * Site configs.
   *
   * @var \Drupal\Core\Config\ImmutableConfig
   */
  protected $config;

  /**
   * Constructs a Drupal\rest\Plugin\ResourceBase object.
   *
   * @param array $configuration
   *   A configuration array containing information about the plugin instance.
   * @param string $plugin_id
   *   The plugin_id for the plugin instance.
   * @param mixed $plugin_definition
   *   The plugin implementation definition.
   * @param array $serializer_formats
   *   The available serialization formats.
   * @param \Psr\Log\LoggerInterface $logger
   *   A logger instance.
   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
   *   A current user instance.
   * @param \Drupal\Core\Mail\MailManagerInterface $mailManager
   *   Mail manager service.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Config factory service.
   */
  public function __construct(
    array                  $configuration,
                           $plugin_id,
                           $plugin_definition,
    array                  $serializer_formats,
    LoggerInterface        $logger,
    AccountProxyInterface  $current_user,
    MailManagerInterface   $mailManager,
    ConfigFactoryInterface $configFactory) {
    parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger);
    $this->currentUser = $current_user;
    $this->mailManager = $mailManager;
    $this->config = $configFactory->get('system.site');
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->getParameter('serializer.formats'),
      $container->get('logger.factory')->get('custom_rest'),
      $container->get('current_user'),
      $container->get('plugin.manager.mail'),
      $container->get('config.factory')
    );
  }

  /**
   * Send a test email.
   *
   * @param $data
   *   Post date.
   */
  public function post($data) {
    $response_status['status'] = FALSE;
    // You must to implement the logic of your REST Resource here.
    // Use current user after pass authentication to validate access.
    if (!$this->currentUser->hasPermission('access content')) {
      throw new AccessDeniedHttpException();
    }

    if (!empty($data['email'])) {
      $site_email = $this->config->get('mail');
      $module = 'custom_rest';
      $key = 'notice';
      $to = $site_email;
      $params['message'] = $data['message']['value'];
      $params['title'] = $data['subject']['value'];
      $params['from'] = $data['email']['value'];
      $langcode = $data['lang']['value'];
      $send = TRUE;

      $result = $this->mailManager->mail($module, $key, $to, $langcode, $params, NULL, $send);
      $response_status['status'] = $result['result'];
    }
    $response = new ResourceResponse($response_status);
    return $response;
  }

}

Requesting POST.

<?php
// Exported from the postman.
$request = new HttpRequest();
$request->setUrl('https://example.com/api/v1/email');
$request->setMethod(HTTP_METH_POST);

$request->setQueryData([
  '_format' => 'json',
]);

$request->setHeaders([
  'cache-control' => 'no-cache',
  'content-type' => 'application/json',
]);

$request->setBody('{
  "email": {
    "value": "[email protected]"
  },
  "message": {
    "value": "Test email custom body"
  },
  "subject": {
    "value": "Test Email"
  },
  "lang": {
    "value": "en"
  }
}');

try {
  $response = $request->send();

  echo $response->getBody();
} catch (HttpException $ex) {
  echo $ex;
}

Extra

Defining cacheable dependency class to deal with catchable metadata (cache tags, cache contexts, and max-age)

<?php

namespace Drupal\custom_rest\Plugin\rest\resource;

use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\Cache;

class ArticleCacheableDepenency implements CacheableDependencyInterface {

  // Number of results
  protected $result_count = 0;

  // node array
  protected $node_array = [];

  /**
   * ArticleCacheableDepenency constructor.
   *
   * @param $result_count
   * @param array $node_array
   */
  public function __construct($result_count, array $node_array) {
    $this->result_count = $result_count;
    $this->node_array = $node_array;
  }

  /**
   * {@inheritdoc}
   */
  public function getCacheContexts() {
    $contexts = [];
    // URL parameters as contexts
    if ($this->result_count > 0 && !empty($this->node_array)) {
      $contexts[] = 'url.query_args';
    }
    return $contexts;
  }

  /**
   * {@inheritdoc}
   */
  public function getCacheTags() {
    $tags = [];
    foreach ($this->node_array as $key => $node_id) {
      $tags[] = 'node:' . $node_id;
    }
    return $tags;
  }

  /**
   * {@inheritdoc}
   */
  public function getCacheMaxAge() {
    return Cache::PERMANENT;
  }

}

To use the above cacheable dependency class we need to make a few changes in our GET Rest Resource as shown below.

<?php
// Change these lines
if ($response instanceof CacheableResponseInterface) {
    $response->addCacheableDependency($response_result);
}
// To
if ($response instanceof CacheableResponseInterface) {
    $response->addCacheableDependency(new ArticleCacheableDepenency(1,[1=>$node->id()]));
}