Skip to content

Commit 7853e4c

Browse files
committed
Initial release
0 parents  commit 7853e4c

12 files changed

Lines changed: 1100 additions & 0 deletions
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: Drupal Module
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "**/*.php"
7+
- "**/*.yml"
8+
- "composer.json"
9+
- "tests/**"
10+
- ".github/workflows/drupal-module.yml"
11+
push:
12+
paths:
13+
- "**/*.php"
14+
- "**/*.yml"
15+
- "composer.json"
16+
- "tests/**"
17+
- ".github/workflows/drupal-module.yml"
18+
19+
jobs:
20+
phpunit:
21+
runs-on: ubuntu-latest
22+
strategy:
23+
fail-fast: false
24+
matrix:
25+
include:
26+
- drupal: "^10"
27+
php: "8.2"
28+
- drupal: "^11"
29+
php: "8.3"
30+
31+
env:
32+
SIMPLETEST_BASE_URL: "http://127.0.0.1"
33+
SIMPLETEST_DB: "sqlite://localhost/sites/default/files/db.sqlite"
34+
35+
steps:
36+
- uses: actions/checkout@v4
37+
38+
- uses: shivammathur/setup-php@v2
39+
with:
40+
php-version: "${{ matrix.php }}"
41+
tools: composer:v2
42+
extensions: gd
43+
44+
- name: Create Drupal project
45+
run: composer create-project drupal/recommended-project:${{ matrix.drupal }} drupal --no-interaction --prefer-dist
46+
47+
- name: Install dependency modules
48+
run: |
49+
cd drupal
50+
composer require drupal/jsonapi_frontend:^1 --no-interaction --prefer-dist -W
51+
52+
- name: Install module from this repo
53+
run: |
54+
mkdir -p drupal/web/modules/contrib/jsonapi_frontend_layout
55+
rsync -a --delete \
56+
--exclude ".git" \
57+
--exclude "drupal" \
58+
--exclude ".github" \
59+
./ drupal/web/modules/contrib/jsonapi_frontend_layout/
60+
61+
- name: Install Drupal test dependencies
62+
run: |
63+
cd drupal
64+
composer require --dev drupal/core-dev:${{ matrix.drupal }} --no-interaction --prefer-dist -W
65+
66+
- name: Prepare test directories
67+
run: |
68+
mkdir -p drupal/web/sites/default/files
69+
mkdir -p drupal/web/sites/simpletest/browser_output
70+
chmod -R 777 drupal/web/sites/default/files
71+
chmod -R 777 drupal/web/sites/simpletest/browser_output
72+
73+
- name: Run PHPUnit
74+
run: |
75+
cd drupal/web
76+
../vendor/bin/phpunit -c core modules/contrib/jsonapi_frontend_layout/tests

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.idea/
2+
.DS_Store
3+
vendor/
4+

LICENSE

Lines changed: 338 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# JSON:API Frontend Layout Builder
2+
3+
`jsonapi_frontend_layout` is an optional add-on for `jsonapi_frontend` that exposes a normalized Layout Builder tree for true headless rendering.
4+
5+
## What it does
6+
7+
- Adds `GET /jsonapi/layout/resolve?path=/about-us&_format=json`
8+
- Internally calls `jsonapi_frontend`’s resolver so aliases, redirects, language negotiation, and access checks behave the same
9+
- When the resolved path is an entity rendered with Layout Builder, the response includes a `layout` tree (sections + components)
10+
11+
## Install
12+
13+
```bash
14+
composer require drupal/jsonapi_frontend_layout
15+
drush en jsonapi_frontend_layout
16+
```
17+
18+
## Usage
19+
20+
```http
21+
GET /jsonapi/layout/resolve?path=/about-us&_format=json
22+
```
23+
24+
The response matches `/jsonapi/resolve` and adds a `layout` object when applicable:
25+
26+
- `layout.sections[]` includes `layout_id`, `layout_settings`, and normalized `components[]`
27+
- Supported component types (MVP): `field_block`, `extra_field_block`, `inline_block`
28+
29+
## Notes
30+
31+
- This module is intentionally read-only and mirrors `jsonapi_frontend` caching behavior (anonymous cacheable; authenticated `no-store`).
32+
- For rendering, you still fetch the resolved `jsonapi_url` (entity) and any referenced block content via JSON:API.
33+

SECURITY.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Security Policy
2+
3+
## Reporting a vulnerability
4+
5+
Please do **not** open public issues or pull requests for security vulnerabilities.
6+
7+
Preferred: report privately using GitHub Security Advisories for this repository.
8+
9+
If private reporting is not available for you, contact the maintainers via the Drupal.org project page and clearly indicate that the report is security-sensitive.
10+
11+
## What to include
12+
13+
- A clear description of the vulnerability and impact
14+
- Steps to reproduce (or a proof of concept)
15+
- Affected versions (Drupal core version, module version, and relevant config)
16+
- Any suggested mitigation or fix (if you have one)

composer.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "drupal/jsonapi_frontend_layout",
3+
"type": "drupal-module",
4+
"description": "Optional Layout Builder integration for jsonapi_frontend. Exposes a normalized Layout Builder tree for true headless rendering.",
5+
"keywords": [
6+
"Drupal",
7+
"JSON:API",
8+
"headless",
9+
"decoupled",
10+
"layout builder",
11+
"layout"
12+
],
13+
"license": "GPL-2.0-or-later",
14+
"homepage": "https://www.drupal.org/project/jsonapi_frontend_layout",
15+
"support": {
16+
"issues": "https://www.drupal.org/project/issues/jsonapi_frontend_layout",
17+
"source": "https://git.drupalcode.org/project/jsonapi_frontend_layout"
18+
},
19+
"repositories": [
20+
{
21+
"type": "composer",
22+
"url": "https://packages.drupal.org/8"
23+
}
24+
],
25+
"require": {
26+
"drupal/core": "^10.3 || ^11",
27+
"drupal/jsonapi_frontend": "^1.0.7"
28+
}
29+
}
30+

jsonapi_frontend_layout.info.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
name: 'JSON:API Frontend Layout Builder'
2+
type: module
3+
description: 'Optional Layout Builder integration for jsonapi_frontend: exposes a normalized layout tree for true headless rendering.'
4+
core_version_requirement: ^10 || ^11
5+
package: 'Web services'
6+
dependencies:
7+
- jsonapi_frontend:jsonapi_frontend
8+
- drupal:layout_builder
9+
- drupal:block_content
10+
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
jsonapi_frontend_layout.resolve:
2+
path: '/jsonapi/layout/resolve'
3+
defaults:
4+
_controller: '\Drupal\jsonapi_frontend_layout\Controller\LayoutResolverController::resolve'
5+
requirements:
6+
_access: 'TRUE'
7+
_format: 'json'
8+
methods: [GET]
9+
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
services:
2+
jsonapi_frontend_layout.layout_tree_builder:
3+
class: Drupal\jsonapi_frontend_layout\Service\LayoutTreeBuilder
4+
arguments:
5+
- '@plugin.manager.layout_builder.section_storage'
6+
- '@entity_type.manager'
7+
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Drupal\jsonapi_frontend_layout\Controller;
6+
7+
use Drupal\Core\Cache\CacheableJsonResponse;
8+
use Drupal\Core\Cache\CacheableMetadata;
9+
use Drupal\Core\Controller\ControllerBase;
10+
use Drupal\Core\Entity\ContentEntityInterface;
11+
use Drupal\jsonapi_frontend\Service\PathResolverInterface;
12+
use Drupal\jsonapi_frontend_layout\Service\LayoutTreeBuilder;
13+
use Symfony\Component\DependencyInjection\ContainerInterface;
14+
use Symfony\Component\HttpFoundation\Request;
15+
16+
/**
17+
* Layout-aware resolver endpoint.
18+
*/
19+
final class LayoutResolverController extends ControllerBase {
20+
21+
private const CONTENT_TYPE = 'application/vnd.api+json; charset=utf-8';
22+
23+
public function __construct(
24+
private readonly PathResolverInterface $resolver,
25+
private readonly LayoutTreeBuilder $layoutTreeBuilder,
26+
) {}
27+
28+
public static function create(ContainerInterface $container): self {
29+
return new self(
30+
$container->get('jsonapi_frontend.path_resolver'),
31+
$container->get('jsonapi_frontend_layout.layout_tree_builder'),
32+
);
33+
}
34+
35+
public function resolve(Request $request): CacheableJsonResponse {
36+
$path = (string) $request->query->get('path', '');
37+
38+
if (trim($path) === '') {
39+
return $this->errorResponse(
40+
status: 400,
41+
title: 'Bad Request',
42+
detail: 'Missing required query parameter: path',
43+
);
44+
}
45+
46+
$langcode = $request->query->get('langcode');
47+
$langcode = is_string($langcode) && $langcode !== '' ? $langcode : NULL;
48+
49+
$result = $this->resolver->resolve($path, $langcode);
50+
51+
$cacheable = new CacheableMetadata();
52+
$cacheable->setCacheMaxAge($this->getCacheMaxAge());
53+
$cacheable->addCacheTags(['config:jsonapi_frontend.settings']);
54+
$cacheable->addCacheContexts([
55+
'url.query_args:path',
56+
'url.query_args:langcode',
57+
'url.site',
58+
]);
59+
60+
// Mirror jsonapi_frontend's language fallback cache behavior.
61+
$config = $this->config('jsonapi_frontend.settings');
62+
$langcode_fallback = (string) ($config->get('resolver.langcode_fallback') ?? 'site_default');
63+
if ($langcode_fallback === 'current') {
64+
$cacheable->addCacheContexts(['languages:language_content']);
65+
}
66+
67+
// If this resolved to an entity, attempt to attach a layout tree.
68+
if (($result['resolved'] ?? FALSE) === TRUE && ($result['kind'] ?? NULL) === 'entity' && is_array($result['entity'] ?? NULL)) {
69+
$entity = $this->loadResolvedEntity($result['entity'], $langcode);
70+
if ($entity) {
71+
$cacheable->addCacheableDependency($entity);
72+
$layout = $this->layoutTreeBuilder->build($entity, $cacheable);
73+
if ($layout) {
74+
$result['layout'] = $layout;
75+
}
76+
}
77+
}
78+
79+
$response = new CacheableJsonResponse($result, 200, [
80+
'Content-Type' => self::CONTENT_TYPE,
81+
]);
82+
83+
$response->addCacheableDependency($cacheable);
84+
$this->applySecurityHeaders($response, $cacheable->getCacheMaxAge());
85+
86+
return $response;
87+
}
88+
89+
/**
90+
* Load the resolved entity (by UUID) from a resolver result.
91+
*
92+
* @param array{type?: mixed, id?: mixed, langcode?: mixed} $entity_info
93+
* The "entity" object from jsonapi_frontend.
94+
*/
95+
private function loadResolvedEntity(array $entity_info, ?string $langcode): ?ContentEntityInterface {
96+
$resource_type = $entity_info['type'] ?? NULL;
97+
$uuid = $entity_info['id'] ?? NULL;
98+
99+
if (!is_string($resource_type) || !is_string($uuid) || $resource_type === '' || $uuid === '') {
100+
return NULL;
101+
}
102+
103+
$parts = explode('--', $resource_type, 2);
104+
if (count($parts) !== 2) {
105+
return NULL;
106+
}
107+
108+
[$entity_type_id] = $parts;
109+
if ($entity_type_id === '') {
110+
return NULL;
111+
}
112+
113+
$definition = $this->entityTypeManager()->getDefinition($entity_type_id, FALSE);
114+
if (!$definition || !$definition->entityClassImplements(ContentEntityInterface::class)) {
115+
return NULL;
116+
}
117+
118+
$storage = $this->entityTypeManager()->getStorage($entity_type_id);
119+
$entities = $storage->loadByProperties(['uuid' => $uuid]);
120+
$entity = $entities ? reset($entities) : NULL;
121+
122+
if (!$entity instanceof ContentEntityInterface) {
123+
return NULL;
124+
}
125+
126+
// Apply the negotiated langcode from the resolver response if possible.
127+
$resolved_langcode = $entity_info['langcode'] ?? NULL;
128+
$resolved_langcode = is_string($resolved_langcode) && $resolved_langcode !== '' ? $resolved_langcode : $langcode;
129+
if ($resolved_langcode && method_exists($entity, 'hasTranslation') && $entity->hasTranslation($resolved_langcode)) {
130+
$entity = $entity->getTranslation($resolved_langcode);
131+
}
132+
133+
// Re-check view access (defense in depth).
134+
return $entity->access('view') ? $entity : NULL;
135+
}
136+
137+
private function errorResponse(int $status, string $title, string $detail): CacheableJsonResponse {
138+
$response = new CacheableJsonResponse([
139+
'errors' => [
140+
[
141+
'status' => (string) $status,
142+
'title' => $title,
143+
'detail' => $detail,
144+
],
145+
],
146+
], $status, [
147+
'Content-Type' => self::CONTENT_TYPE,
148+
]);
149+
150+
$cacheable = new CacheableMetadata();
151+
$cacheable->setCacheMaxAge(0);
152+
$response->addCacheableDependency($cacheable);
153+
$this->applySecurityHeaders($response, 0);
154+
155+
return $response;
156+
}
157+
158+
private function getCacheMaxAge(): int {
159+
if (!$this->currentUser()->isAnonymous()) {
160+
return 0;
161+
}
162+
163+
$config = $this->config('jsonapi_frontend.settings');
164+
$max_age = (int) ($config->get('resolver.cache_max_age') ?? 0);
165+
166+
return max(0, $max_age);
167+
}
168+
169+
private function applySecurityHeaders(CacheableJsonResponse $response, int $max_age): void {
170+
$response->headers->set('X-Content-Type-Options', 'nosniff');
171+
172+
if ($max_age > 0) {
173+
$response->headers->set('Cache-Control', 'public, max-age=' . $max_age);
174+
}
175+
else {
176+
$response->headers->set('Cache-Control', 'no-store');
177+
}
178+
}
179+
180+
}

0 commit comments

Comments
 (0)