Creating a Custom Versioned Response Type for a Drupal API
A Drupal API usually starts with a simple JSON response and gradually becomes part of several applications, mobile clients, internal tools, and third-party integrations. Once consumers depend on field names and data structures, changing the response can break production systems. A versioned response type gives each client a predictable contract while allowing the Drupal implementation to evolve.
In this guide, a Versioned Response Type (VRT) means a deliberate API response format identified by a media type such as application/vnd.drupalwoo.product.v1+json. The approach uses a custom Drupal module, a controller, response headers, and explicit serializers rather than exposing the result of an entity query directly.
This pattern is useful for Australian organisations serving websites across Sydney, Melbourne, Brisbane, and regional areas, where an API may support a local mobile application, a headless frontend, and external business systems at the same time. It also gives development teams a controlled way to manage releases around Australian public holidays, retail campaigns, and peak traffic periods.
Define The Response Contract Before Coding
A VRT should describe a stable representation, not simply a Drupal entity version. The Drupal content model can change internally while the public API continues returning the same contract. For example, a product node might contain dozens of fields, while version one exposes only an identifier, title, summary, price, and availability.
A useful versioned response has a predictable envelope:
{
"data": {
"id": "42",
"type": "product",
"attributes": {
"title": "Outdoor Table",
"summary": "A compact timber table",
"price": 249.95,
"currency": "AUD",
"available": true
}
},
"meta": {
"api_version": "1",
"generated_at": "2025-03-08T04:15:00Z"
}
}
Keep the envelope consistent across resources. Put resource-specific values under attributes, and reserve meta for information about the response rather than the content itself. Use ISO 8601 timestamps in UTC, even when the website serves Australian users in AEST, ACST, or AWST. This avoids confusion when a Brisbane warehouse and a Perth distribution centre process the same record.
Choose the versioning mechanism before implementing the route. URL versioning, such as /api/v1/products, is easy to inspect and cache. Header versioning keeps URLs stable and uses an Accept header such as application/vnd.drupalwoo.product.v1+json. A practical VRT can support both, while treating the URL as the primary selector and the media type as an explicit response contract.
Build A Small Custom Drupal Module
Create a module named drupalwoo_api in web/modules/custom/drupalwoo_api. The module can provide a route, a controller, and a service responsible for transforming entities into the chosen response shape.
# drupalwoo_api.info.yml
name: Drupalwoo API
type: module
description: Provides versioned API responses.
package: Custom
core_version_requirement: ^10.3 || ^11
dependencies:
- node
Define a route for version one. Requiring the access content permission is a starting point; a production API will usually use OAuth, a signed token, or another authentication provider instead.
# drupalwoo_api.routing.yml
drupalwoo_api.products_v1:
path: '/api/v1/products'
defaults:
_controller: '\Drupal\drupalwoo_api\Controller\ProductApiController::collection'
_format: 'json'
requirements:
_permission: 'access content'
methods: [GET]
The controller should retrieve only the fields it needs and pass each entity to a dedicated mapper. Avoid returning $node->toArray() because that exposes Drupal field storage details, language structures, revision metadata, and values that clients should not rely on.
<?php
namespace Drupal\drupalwoo_api\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\node\NodeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
final class ProductApiController extends ControllerBase {
public function collection(Request $request): JsonResponse {
$storage = $this->entityTypeManager()->getStorage('node');
$query = $storage->getQuery()
->accessCheck(TRUE)
->condition('type', 'product')
->condition('status', 1)
->sort('created', 'DESC')
->range(0, 50);
$nids = $query->execute();
$nodes = $storage->loadMultiple($nids);
$items = array_map(
fn(NodeInterface $node): array => $this->toV1($node),
$nodes
);
$response = new JsonResponse([
'data' => $items,
'meta' => [
'api_version' => '1',
'count' => count($items),
'generated_at' => gmdate(DATE_ATOM),
],
]);
$response->headers->set(
'Content-Type',
'application/vnd.drupalwoo.product.v1+json'
);
$response->headers->set('Vary', 'Accept');
return $response;
}
private function toV1(NodeInterface $node): array {
return [
'id' => (string) $node->id(),
'type' => 'product',
'attributes' => [
'title' => $node->label(),
'summary' => $node->get('body')->summary ?: '',
'price' => (float) $node->get('field_price')->value,
'currency' => 'AUD',
'available' => (bool) $node->get('field_available')->value,
],
];
}
}
Enable the module with Drush, clear caches, and test the route:
drush en drupalwoo_api -y
drush cr
curl -i https://example.com/api/v1/products
The mapper is deliberately explicit. If the site later adds a field_supplier_sku field or changes the editorial workflow, the VRT remains unaffected. This separation is particularly valuable for agencies managing several Australian client sites with different Drupal field models but the same public API contract.
Select And Negotiate Response Versions
Version two should be implemented as a separate mapper or resource class rather than by adding conditionals throughout the version-one controller. A second route makes the contract visible and keeps cache keys, documentation, and access rules straightforward.
drupalwoo_api.products_v2:
path: '/api/v2/products'
defaults:
_controller: '\Drupal\drupalwoo_api\Controller\ProductApiController::collectionV2'
_format: 'json'
requirements:
_permission: 'access content'
methods: [GET]
A version-two response might rename summary to description, return a decimal price as a string to avoid floating-point ambiguity, and add a stock object. Those are contract changes, so they belong in a new response version rather than being silently introduced into version one.
| Concern | Version one | Version two | Reason |
|---|---|---|---|
| Endpoint | /api/v1/products |
/api/v2/products |
Clear client selection |
| Price | 249.95 as number |
"249.95" as string |
Prevents precision problems |
| Availability | Boolean field | Stock object | Allows quantity and status |
| Currency | AUD attribute |
AUD attribute |
Remains stable |
| Deprecation | Current | Current | Version one can later be retired |
Header negotiation can be added when clients need a stable URL. Inspect the Accept header and reject unsupported representations with HTTP 406. Do not silently return version one when a client requests version two, because the client may believe it has received fields that are unavailable.
$accept = $request->headers->get('Accept', '');
if (
$accept !== '' &&
!str_contains($accept, 'application/vnd.drupalwoo.product.v1+json') &&
!str_contains($accept, 'application/json')
) {
return new JsonResponse(
['errors' => [['code' => 'unsupported_media_type']]],
406
);
}
If both URL and header versioning are supported, document which one wins. A sensible rule is that /api/v2 always returns version two, while an unversioned route negotiates from the Accept header. Add Vary: Accept whenever the response changes according to that header, otherwise reverse proxies may serve one client's representation to another client.
Handle Errors, Caching, And Access Control
A useful VRT defines errors as carefully as successful responses. Return a consistent structure with a machine-readable code, a safe message, and optional details. Avoid exposing SQL errors, PHP warnings, stack traces, or internal entity information.
{
"errors": [
{
"status": "404",
"code": "product_not_found",
"message": "The requested product could not be found."
}
]
}
Use Drupal's access checking in entity queries and verify access again when loading a specific entity. A published node is not automatically public in every business workflow. Field-level restrictions, country-specific pricing, unpublished revisions, and customer permissions may require a dedicated access service. Never use an administrative account merely to simplify API development.
Set cache metadata deliberately. For public product data, a cacheable response may use a short max-age and a tag such as node:42, allowing Drupal to invalidate the representation when the node changes. If the response contains user-specific prices or permissions, mark it private and disable shared caching.
$response->setMaxAge(300);
$response->headers->set('Cache-Control', 'public, max-age=300');
For Australian commerce sites, caching rules should account for Australian Eastern Daylight Time during daylight saving and for rapid stock changes during Boxing Day or end-of-financial-year promotions. A five-minute product cache may be acceptable, while inventory or delivery estimates may need a much shorter lifetime. Include ETag or Last-Modified support when clients make frequent polling requests.
Test And Document The Public Contract
Test the VRT at several levels. Unit-test the mapper with a mocked node and confirm that absent optional fields produce safe values. Kernel-test the entity query and access checks. Functional tests should request each route, verify the status code, inspect Content-Type, and compare the JSON structure with the published contract.
A basic PHPUnit-style assertion might check that version one never exposes internal field names:
$this->assertSame('product', $payload['data'][0]['type']);
$this->assertArrayHasKey('attributes', $payload['data'][0]);
$this->assertArrayNotHasKey('field_price', $payload['data'][0]);
$this->assertSame(
'application/vnd.drupalwoo.product.v1+json',
$response->headers->get('Content-Type')
);
Use OpenAPI to document paths, parameters, media types, success responses, and error responses. Describe whether an amount is a number or a decimal string, whether dates are UTC, and whether omitted fields differ from fields containing null. These details prevent integration disagreements between Drupal developers and frontend teams in Melbourne or Canberra.
Record a deprecation policy in the API documentation. For example, version one may receive security fixes for twelve months after version two launches, with a Deprecation or Sunset header added during the retirement period. Communicate the date through release notes and monitoring rather than removing an old representation without warning.
Log the selected version, route, response status, and request identifier, but avoid logging access tokens or personal data. Metrics should reveal which clients still use an older VRT. This makes retirement evidence-based and helps a support team distinguish a Drupal regression from a client that has ignored the published migration window.
A custom Drupal VRT works best when it is treated as a public product contract. Explicit serializers protect consumers from internal field changes, versioned routes make upgrades predictable, and consistent headers, errors, caching, and tests turn a basic JSON endpoint into a maintainable API. The Drupal site can continue evolving while each application receives the response shape it was designed to consume.