| name | api-development |
| description | Guide for building REST APIs, JSON:API endpoints, and external integrations in Drupal. |
API Development Skill
Comprehensive guide for building and consuming APIs in Drupal.
API Options in Drupal
| Approach | Best For | Authentication |
|---|
| JSON:API | Standard CRUD on entities | Cookie, Basic, OAuth |
| REST Resources | Custom endpoints, complex logic | Cookie, Basic, OAuth |
| Controllers | Full control, non-standard responses | Custom |
| GraphQL | Complex queries, mobile apps | Token-based |
1. JSON:API (Built-in)
Enable and Configure
./vendor/bin/drush en jsonapi jsonapi_extras -y
Default Endpoints
GET /jsonapi/node/article # List articles
GET /jsonapi/node/article/{uuid} # Single article
POST /jsonapi/node/article # Create article
PATCH /jsonapi/node/article/{uuid} # Update article
DELETE /jsonapi/node/article/{uuid} # Delete article
Filtering
GET /jsonapi/node/article?filter[status]=1
GET /jsonapi/node/article?filter[uid.id]={user-uuid}
GET /jsonapi/node/article?filter[and-group][group][conjunction]=AND&filter[status][condition][path]=status&filter[status][condition][value]=1&filter[status][condition][memberOf]=and-group
GET /jsonapi/node/article?include=uid,field_category
GET /jsonapi/node/article?fields[node--article]=title,body,created
Permission Configuration
permissions:
- 'access jsonapi resource list'
2. Custom REST Resources
Basic REST Resource
<?php
namespace Drupal\mymodule\Plugin\rest\resource;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Psr\Log\LoggerInterface;
class DataResource extends ResourceBase {
public function get(string $id): ResourceResponse {
$data = $this->loadData($id);
if (!$data) {
return new ResourceResponse(['error' => 'Not found'], 404);
}
$response = new ResourceResponse($data, 200);
$response->addCacheableDependency($data);
$response->getCacheableMetadata()
->addCacheTags(['mymodule_data:' . $id])
->addCacheContexts(['user.permissions']);
return $response;
}
public function post(array $data): ResourceResponse {
if (empty($data['title'])) {
return new ResourceResponse([
'error' => 'Validation failed',
'details' => ['title' => 'Title is required'],
], 400);
}
$result = $this->createData($data);
return new ResourceResponse($result, 201);
}
public function patch(string $id, array $data): ResourceResponse {
$existing = $this->loadData($id);
if (!$existing) {
return new ResourceResponse(['error' => 'Not found'], 404);
}
$result = $this->updateData($id, $data);
return new ResourceResponse($result, 200);
}
public function delete(string $id): ResourceResponse {
$existing = $this->loadData($id);
if (!$existing) {
return new ResourceResponse(['error' => 'Not found'], 404);
}
$this->deleteData($id);
return new ResourceResponse(NULL, 204);
}
}
REST Resource Configuration
id: mymodule_data_resource
plugin_id: mymodule_data_resource
granularity: resource
configuration:
methods:
- GET
- POST
- PATCH
- DELETE
formats:
- json
authentication:
- cookie
- basic_auth
3. Controller-Based API
API Controller
<?php
namespace Drupal\mymodule\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\CacheableMetadata;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\DependencyInjection\ContainerInterface;
class ApiController extends ControllerBase {
protected $dataService;
public static function create(ContainerInterface $container): static {
$instance = parent::create($container);
$instance->dataService = $container->get('mymodule.data_service');
return $instance;
}
public function list(Request $request): CacheableJsonResponse {
$page = (int) $request->query->get('page', 0);
$limit = min((int) $request->query->get('limit', 20), 100);
$filter = $request->query->get('filter', '');
$items = $this->dataService->getList($page, $limit, $filter);
$total = $this->dataService->getCount($filter);
$data = [
'data' => $items,
'meta' => [
'page' => $page,
'limit' => $limit,
'total' => $total,
'pages' => ceil($total / $limit),
],
'links' => $this->buildPaginationLinks($request, $page, $limit, $total),
];
$response = new CacheableJsonResponse($data);
$cacheMetadata = new CacheableMetadata();
$cacheMetadata->addCacheTags(['mymodule_data_list']);
$cacheMetadata->addCacheContexts(['url.query_args']);
$cacheMetadata->setCacheMaxAge(300);
$response->addCacheableDependency($cacheMetadata);
return $response;
}
public function get(string $id): JsonResponse {
$item = $this->dataService->get($id);
if (!$item) {
return new JsonResponse([
'error' => [
'code' => 'NOT_FOUND',
'message' => 'Resource not found',
],
], 404);
}
return new JsonResponse(['data' => $item]);
}
public function create(Request $request): JsonResponse {
$data = json_decode($request->getContent(), TRUE);
$errors = $this->validate($data);
if ($errors) {
return new JsonResponse([
'error' => [
'code' => 'VALIDATION_ERROR',
'message' => 'Validation failed',
'details' => $errors,
],
], 400);
}
$item = $this->dataService->create($data);
return new JsonResponse(['data' => $item], 201);
}
protected function buildPaginationLinks(
Request $request,
int $page,
int $limit,
int $total
): array {
$baseUrl = $request->getSchemeAndHttpHost() . $request->getPathInfo();
$pages = ceil($total / $limit);
$links = [
'self' => $baseUrl . '?page=' . $page . '&limit=' . $limit,
'first' => $baseUrl . '?page=0&limit=' . $limit,
'last' => $baseUrl . '?page=' . max(0, $pages - 1) . '&limit=' . $limit,
];
if ($page > 0) {
$links['prev'] = $baseUrl . '?page=' . ($page - 1) . '&limit=' . $limit;
}
if ($page < $pages - 1) {
$links['next'] = $baseUrl . '?page=' . ($page + 1) . '&limit=' . $limit;
}
return $links;
}
}
Routing
mymodule.api.list:
path: '/api/v1/items'
defaults:
_controller: '\Drupal\mymodule\Controller\ApiController::list'
methods: [GET]
requirements:
_permission: 'access content'
options:
no_cache: FALSE
mymodule.api.get:
path: '/api/v1/items/{id}'
defaults:
_controller: '\Drupal\mymodule\Controller\ApiController::get'
methods: [GET]
requirements:
_permission: 'access content'
id: '[a-zA-Z0-9_-]+'
mymodule.api.create:
path: '/api/v1/items'
defaults:
_controller: '\Drupal\mymodule\Controller\ApiController::create'
methods: [POST]
requirements:
_permission: 'create mymodule_data'
4. HTTP Client for External APIs
Service Definition
services:
mymodule.external_client:
class: Drupal\mymodule\Client\ExternalApiClient
arguments:
- '@http_client'
- '@logger.factory'
- '@config.factory'
- '@cache.default'
HTTP Client Implementation
<?php
namespace Drupal\mymodule\Client;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Log\LoggerInterface;
class ExternalApiClient {
protected ClientInterface $httpClient;
protected LoggerInterface $logger;
protected string $baseUrl;
protected string $apiKey;
protected CacheBackendInterface $cache;
public function __construct(
ClientInterface $http_client,
LoggerChannelFactoryInterface $logger_factory,
ConfigFactoryInterface $config_factory,
CacheBackendInterface $cache,
) {
$this->httpClient = $http_client;
$this->logger = $logger_factory->get('mymodule');
$this->cache = $cache;
$config = $config_factory->get('mymodule.settings');
$this->baseUrl = $config->get('external_api_url');
$this->apiKey = $config->get('external_api_key');
}
public function get(string $endpoint, array $query = []): ?array {
$cacheKey = 'mymodule_api:' . md5($endpoint . serialize($query));
if ($cached = $this->cache->get($cacheKey)) {
return $cached->data;
}
try {
$response = $this->httpClient->request('GET', $this->baseUrl . $endpoint, [
'query' => $query,
'headers' => $this->getHeaders(),
'timeout' => 30,
]);
$data = json_decode($response->getBody()->getContents(), TRUE);
$this->cache->set($cacheKey, $data, time() + 300);
return $data;
}
catch (GuzzleException $e) {
$this->logger->error('API request failed: @message', [
'@message' => $e->getMessage(),
]);
return NULL;
}
}
public function post(string $endpoint, array $data): ?array {
try {
$response = $this->httpClient->request('POST', $this->baseUrl . $endpoint, [
'json' => $data,
'headers' => $this->getHeaders(),
'timeout' => 30,
]);
return json_decode($response->getBody()->getContents(), TRUE);
}
catch (GuzzleException $e) {
$this->logger->error('API POST failed: @message', [
'@message' => $e->getMessage(),
]);
return NULL;
}
}
protected function getHeaders(): array {
return [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $this->apiKey,
];
}
}
5. Authentication
Cookie Authentication (default)
curl -c cookies.txt -X POST "https://example.com/user/login" \
-H "Content-Type: application/json" \
-d '{"name":"user","pass":"password"}'
curl -b cookies.txt "https://example.com/api/v1/data"
Basic Auth
./vendor/bin/drush en basic_auth -y
curl -u username:password "https://example.com/api/v1/data"
Simple OAuth (OAuth 2.0)
composer require drupal/simple_oauth
./vendor/bin/drush en simple_oauth -y
./vendor/bin/drush simple-oauth:generate-keys ../keys
6. API Response Standards
Success Response
{
"data": {
"id": "123",
"type": "article",
"attributes": {
"title": "Example",
"body": "Content..."
}
},
"meta": {
"timestamp": "2024-01-15T10:30:00Z"
}
}
List Response
{
"data": [],
"meta": {
"page": 0,
"limit": 20,
"total": 100,
"pages": 5
},
"links": {
"self": "/api/v1/items?page=0",
"first": "/api/v1/items?page=0",
"last": "/api/v1/items?page=4",
"next": "/api/v1/items?page=1"
}
}
Error Response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{"field": "email", "message": "Invalid email format"},
{"field": "title", "message": "Title is required"}
]
}
}
HTTP Status Codes
| Code | Use For |
|---|
| 200 | Success (GET, PATCH) |
| 201 | Created (POST) |
| 204 | No Content (DELETE) |
| 400 | Bad Request (validation) |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not Found |
| 422 | Unprocessable Entity |
| 500 | Server Error |
7. Testing APIs
curl -X GET "https://example.com/api/v1/items" \
-H "Accept: application/json"
curl -X POST "https://example.com/api/v1/items" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"title": "Test", "body": "Content"}'
curl -u user:pass -X GET "https://example.com/api/v1/items"
curl -X GET "https://example.com/jsonapi/node/article" \
-H "Accept: application/vnd.api+json"