Skip to content

Commit 8db3df5

Browse files
committed
Manifest resource_iris: emit a nested resource tree per depth (#197)
resource_iris changes from string[][] to NestedJsonStructure[] — the outer array stays indexed by rendering depth (root first), but each depth is now a recursive { iri, children } tree preserving resource containment (route -> pageData -> page -> componentGroup -> position -> component), so the front-end can render placeholders that mitigate layout shift. - ManifestDepthGroupTrait: buildDepthNodes builds the per-depth tree instead of flattening; same IRI set, per-depth dedup, blank-node/back-reference/@-key exclusions. Skipped/blank/duplicate resources hoist their children so no noise nodes appear. - Hard swap (no parallel key) — coordinated BC break with cwa-nuxt-module #250, migrated in tandem. Node key is 'iri' (bespoke DTO field). No per-node metadata yet (deliberate follow-up). - Behat manifest assertions converted to new DoctrineContext tree steps; ManifestDepthGroupTraitTest asserts exact nested structures. Full suite green.
1 parent dde605a commit 8db3df5

6 files changed

Lines changed: 294 additions & 109 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ Leave this issue open until the front-end approach and component-state semantics
510510

511511
**Implemented.** Declaration is a **Silverback class attribute** `#[Silverback\ExplicitAllowOnly]` (`src/Annotation/ExplicitAllowOnly.php`) read by `ExplicitAllowOnlyAttributeReader` (`src/AttributeReader/`, extends `AttributeReader`, service `silverback.api_components.attribute_reader.explicit_allow_only` — mirrors `Publishable`/`Timestamped`/`Uploadable`). This is the bundle's own attribute system — a component type declares the attribute, no interface to implement. `AbstractComponent::isPositionRestricted()` and the `RestrictedComponent` override are **removed**; `RestrictedComponent` carries `#[Silverback\ExplicitAllowOnly]`. `ComponentPositionValidator` now checks `$this->explicitAllowOnlyReader->isConfigured(...)` on **both** placement paths (was `isPositionRestricted()`): `validateDirectComponent` (the placed component) **and** `validateDynamicPosition` (the pageDataProperty's resolved `componentClass`) — the dynamic path was restructured to mirror the direct path so a restricted type can't be bound to a dynamic position in an unrestricted group and bypass the rule server-side. Exposure to the front-end matches the module's already-locked contract: `VersionedDocumentationNormalizer` adds `explicitAllowOnly => true` to each flagged component's Hydra `supportedClass` entry (matched by `title` = short name; flagged short names found by walking `ResourceNameCollectionFactory` and testing each class with the reader). The module already reads `supportedClass['explicitAllowOnly'] === true` (absent ⇒ false) in `getComponentMetadata`, so **no module code change is required**. Behat: `features/main/component_position.feature` covers both the direct path (RestrictedComponent) and the dynamic path (via new test entity `PageDataWithRestrictedComponent`, whose `restrictedComponent` property resolves to a RestrictedComponent) — rejected in an unrestricted group (422), accepted when the group lists it (201); new `features/main/explicit_allow_only.feature` asserts the docs flag (RestrictedComponent → true, DummyComponent → false).
512512

513-
**⚠ Outstanding — dynamic page-data-property positions.** The reader check was added only to `ComponentPositionValidator::validateDirectComponent`. `validateDynamicPosition` still returns early when a group has no `allowedComponents`, so an `explicitAllowOnly` component surfaced as a **page-data property** can still be positioned into an unrestricted group — bypassing the restriction. Decision (cwa-nuxt-module #249): `explicitAllowOnly` applies to **both** placement paths. **TODO:** in `validateDynamicPosition`, after resolving `$componentClass = $propertyMetadata->getComponentClass()`, apply the same `ExplicitAllowOnlyAttributeReader` check (reject when the group doesn't explicitly allow that component class), mirroring `validateDirectComponent`. Add a Behat scenario for the dynamic-position case. The front-end (module) already blocks both paths (`AddComponentDialog` + `useDynamicPositionSelectOptions.getPropertyOptions`), so this closes the server-side bypass.
513+
**Both placement paths enforced.** `explicitAllowOnly` applies to **direct** components (`validateDirectComponent`) **and** **dynamic** page-data-property positions (`validateDynamicPosition`, checking the property's resolved `componentClass`), so a flagged type can't be bound to a dynamic position in an unrestricted group and bypass the rule server-side. The front-end (module) blocks both paths too (`AddComponentDialog` + `useDynamicPositionSelectOptions.getPropertyOptions`). Only remaining cross-repo item: component cloning (cwa-nuxt-module #157) must also respect the flag.
514514

515515
---
516516
<details><summary>Original issue context</summary>
@@ -649,7 +649,11 @@ Depends on #194. References: `src/Fixture/CwaFixtureBuilder.php` (flush phases;
649649

650650
---
651651

652-
### #197 — Manifest: each depth's payload becomes a nested resource tree (`NestedJsonStructure[]`) — front-end: cwa-nuxt-module #250
652+
### #197 — Manifest: each depth's payload is a nested resource tree (`NestedJsonStructure[]`) — front-end: cwa-nuxt-module #250**DONE (API side)**
653+
654+
**Implemented.** `GET /_/resource_manifest/{id}` now returns `resource_iris` as an array indexed by rendering depth (root first) where **each element is a nested tree node `{ "iri": string, "children": [...] }`** instead of a flat `string[]`. Only `ResourceManifestNormalizer` emits `resource_iris` (via `ManifestDepthGroupTrait`; `RouteNormalizer` does not). The trait was rewritten: `buildDepthGroups` splits depths on the `parentPage`/`parentPageData` boundary (unchanged) and, within each depth, `buildDepthNodes` builds the containment tree instead of flattening — same IRI set as before, same per-depth dedup, same blank-node/`resource_metadatas`/`@`-key/back-reference exclusions (skipped/blank/duplicate resources hoist their children so no noise nodes appear). **Decisions taken:** hard swap (no parallel key — pre-alpha BC break, ships in lockstep with module #250); node key is `iri` (not `@id` — bespoke DTO field); **no per-node metadata yet** (placeholder metadata is a deliberate follow-up). Tests: `tests/Serializer/Normalizer/ManifestDepthGroupTraitTest.php` asserts exact nested structures; `features/main/route.feature` + `features/main/page.feature` converted to new `DoctrineContext` steps (`the manifest depth :n root IRI should be …`, `… should have :n resource IRIs`, `… should contain the IRI …`, `… should contain/not contain an IRI matching …`) which flatten a depth's tree. Full suite green.
655+
656+
<details><summary>Original design notes</summary>
653657

654658
Change the manifest endpoints (`GET /_/resource_manifest/{id}` and the `Route:manifest:read` output) so each depth's payload is a **nested resource tree** instead of a flat list of IRIs. The **outer array stays indexed by rendering depth** (root first) — only the inner element type changes.
655659

@@ -680,4 +684,7 @@ Page nesting → outer array index (as today); component nesting → the per-dep
680684
- **Node key**`iri` + `children` (proposed) vs. `@id` to match JSON-LD conventions used elsewhere.
681685
- **Siblings** — sibling child pages (tab bars) still come from `GET /_/routes/{id}/children`, not the manifest. Recommend keeping out for v1.
682686

683-
References: `src/Serializer/Normalizer/Trait/ManifestDepthGroupTrait.php`, `src/Serializer/Normalizer/RouteNormalizer.php`, `src/Serializer/Normalizer/ResourceManifestNormalizer.php`. **Note:** the `resource_iris: string[][]` shape described elsewhere in this file (manifest sections + design decisions) is the *current* contract; #197 keeps the depth-indexed outer array but replaces each inner `string[]` with a `NestedJsonStructure` tree.
687+
References: `src/Serializer/Normalizer/Trait/ManifestDepthGroupTrait.php`, `src/Serializer/Normalizer/RouteNormalizer.php`, `src/Serializer/Normalizer/ResourceManifestNormalizer.php`.
688+
</details>
689+
690+
> **⚠ Note:** the older `resource_iris: string[][]` description in the manifest architecture sections and design-decisions list above (e.g. "`resource_iris` is `string[][]`") is now **superseded by #197** — the shape is `NestedJsonStructure[]` (depth-indexed array of `{ iri, children }` trees).

features/bootstrap/DoctrineContext.php

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,6 +1494,111 @@ public function theApiDocsSupportedClassShouldHaveExplicitAllowOnly(string $clas
14941494
}
14951495
}
14961496

1497+
// --- Manifest resource_iris (nested tree per depth, #197) ---
1498+
// resource_iris is an array indexed by rendering depth; each element is a nested node
1499+
// { iri, children: [...] }. These steps flatten a depth's tree to assert on its IRI set.
1500+
// Manual checks + plain exceptions: a failing Assert::* fatals under Behat (PHPUnit Exporter).
1501+
1502+
/**
1503+
* @Then the manifest depth :depth root IRI should be :iri
1504+
*/
1505+
public function theManifestDepthRootIriShouldBe(int $depth, string $iri): void
1506+
{
1507+
$actual = $this->manifestDepthNode($depth)['iri'] ?? null;
1508+
if ($actual !== $iri) {
1509+
throw new \RuntimeException(\sprintf('Manifest depth %d root IRI is "%s", expected "%s".', $depth, $actual ?? 'null', $iri));
1510+
}
1511+
}
1512+
1513+
/**
1514+
* @Then the manifest depth :depth root IRI should be the IRI of the resource :name
1515+
*/
1516+
public function theManifestDepthRootIriShouldBeTheIriOfTheResource(int $depth, string $name): void
1517+
{
1518+
$this->theManifestDepthRootIriShouldBe($depth, $this->restContext->resources[$name]);
1519+
}
1520+
1521+
/**
1522+
* @Then the manifest depth :depth should have :count resource IRIs
1523+
*/
1524+
public function theManifestDepthShouldHaveResourceIris(int $depth, int $count): void
1525+
{
1526+
$iris = $this->flattenManifestNode($this->manifestDepthNode($depth));
1527+
if (\count($iris) !== $count) {
1528+
throw new \RuntimeException(\sprintf('Manifest depth %d has %d IRIs, expected %d: %s', $depth, \count($iris), $count, implode(', ', $iris)));
1529+
}
1530+
}
1531+
1532+
/**
1533+
* @Then the manifest depth :depth should contain the IRI :iri
1534+
*/
1535+
public function theManifestDepthShouldContainTheIri(int $depth, string $iri): void
1536+
{
1537+
$iris = $this->flattenManifestNode($this->manifestDepthNode($depth));
1538+
if (!\in_array($iri, $iris, true)) {
1539+
throw new \RuntimeException(\sprintf('Manifest depth %d does not contain "%s". Has: %s', $depth, $iri, implode(', ', $iris)));
1540+
}
1541+
}
1542+
1543+
/**
1544+
* @Then the manifest depth :depth should contain an IRI matching :pattern
1545+
*/
1546+
public function theManifestDepthShouldContainAnIriMatching(int $depth, string $pattern): void
1547+
{
1548+
$iris = $this->flattenManifestNode($this->manifestDepthNode($depth));
1549+
foreach ($iris as $iri) {
1550+
if (1 === preg_match($pattern, $iri)) {
1551+
return;
1552+
}
1553+
}
1554+
throw new \RuntimeException(\sprintf('Manifest depth %d has no IRI matching %s. Has: %s', $depth, $pattern, implode(', ', $iris)));
1555+
}
1556+
1557+
/**
1558+
* @Then the manifest depth :depth should not contain an IRI matching :pattern
1559+
*/
1560+
public function theManifestDepthShouldNotContainAnIriMatching(int $depth, string $pattern): void
1561+
{
1562+
$iris = $this->flattenManifestNode($this->manifestDepthNode($depth));
1563+
foreach ($iris as $iri) {
1564+
if (1 === preg_match($pattern, $iri)) {
1565+
throw new \RuntimeException(\sprintf('Manifest depth %d unexpectedly contains "%s" matching %s.', $depth, $iri, $pattern));
1566+
}
1567+
}
1568+
}
1569+
1570+
/**
1571+
* @return array{iri?: string, children?: array}
1572+
*/
1573+
private function manifestDepthNode(int $depth): array
1574+
{
1575+
$json = $this->jsonContext->getJsonAsArray();
1576+
$node = $json['resource_iris'][$depth] ?? null;
1577+
if (!\is_array($node)) {
1578+
throw new \RuntimeException(\sprintf('Manifest has no depth %d.', $depth));
1579+
}
1580+
1581+
return $node;
1582+
}
1583+
1584+
/**
1585+
* @return string[] every iri in the node tree, depth-first
1586+
*/
1587+
private function flattenManifestNode(array $node): array
1588+
{
1589+
$iris = [];
1590+
if (isset($node['iri'])) {
1591+
$iris[] = $node['iri'];
1592+
}
1593+
foreach ($node['children'] ?? [] as $child) {
1594+
if (\is_array($child)) {
1595+
$iris = array_merge($iris, $this->flattenManifestNode($child));
1596+
}
1597+
}
1598+
1599+
return $iris;
1600+
}
1601+
14971602
/**
14981603
* @Then the response resource should be saved as :name
14991604
*/

features/main/page.feature

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,15 @@ Feature: Page resources
125125
When I send a "GET" request to the resource "page_data_manifest"
126126
Then the response status code should be 200
127127
And the JSON node "resource_iris" should have 1 element
128-
And the JSON node "resource_iris[0][0]" should be equal to the IRI of the resource "page_data"
128+
And the manifest depth 0 root IRI should be the IRI of the resource "page_data"
129129

130130
@loginAdmin
131131
Scenario: I can get a resource manifest for a nested PageData by UUID
132132
Given there is a PageData resource with the route path "/conference/programme" nested within the route "/conference"
133133
When I send a "GET" request to the resource "page_data_manifest"
134134
Then the response status code should be 200
135135
And the JSON node "resource_iris" should have 2 elements
136-
And the JSON node "resource_iris[1][0]" should be equal to the IRI of the resource "page_data"
136+
And the manifest depth 1 root IRI should be the IRI of the resource "page_data"
137137

138138
@loginAdmin
139139
Scenario: I can PATCH a page when componentGroups are included in the request body
@@ -227,4 +227,4 @@ Feature: Page resources
227227
When I send a "GET" request to the resource "page_manifest"
228228
Then the response status code should be 200
229229
And the JSON node "resource_iris" should have 2 elements
230-
And the JSON node "resource_iris[1][0]" should be equal to the IRI of the resource "page"
230+
And the manifest depth 1 root IRI should be the IRI of the resource "page"

features/main/route.feature

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -224,65 +224,65 @@ Feature: Route resources
224224
Given there is a PageData resource with the route path "/my-route"
225225
When I send a "GET" request to "/_/resource_manifest//my-route"
226226
Then the response status code should be 200
227-
And the JSON node "resource_iris[0][5]" should match the regex "/\/component\/dummy_components\/[a-z0-9\-]+/"
227+
And the manifest depth 0 should contain an IRI matching "/\/component\/dummy_components\/[a-z0-9\-]+/"
228228

229229
Scenario: The manifest includes component IRIs from allowedComponents-restricted pageDataProperty positions for anonymous users
230230
Given there is a pageDataProperty position with an allowed component type in a restricted group with route "/my-route"
231231
When I send a "GET" request to "/_/resource_manifest//my-route"
232232
Then the response status code should be 200
233-
And the JSON node "resource_iris[0][5]" should match the regex "/\/component\/dummy_components\/[a-z0-9\-]+/"
233+
And the manifest depth 0 should contain an IRI matching "/\/component\/dummy_components\/[a-z0-9\-]+/"
234234

235235
Scenario: The manifest for a nested PageData route includes parent resource IRIs grouped by depth
236236
Given there is a PageData resource with the route path "/conference/programme" nested within the route "/conference"
237237
When I send a "GET" request to "/_/resource_manifest//conference/programme"
238238
Then the response status code should be 200
239239
And the JSON node "resource_iris" should have 2 elements
240-
And the JSON node "resource_iris[0]" should have 3 elements
241-
And the JSON node "resource_iris[1]" should have 3 elements
242-
And the JSON node "resource_iris[0]" should contain the element "/_/routes//conference"
243-
And the JSON node "resource_iris[1]" should contain the element "/_/routes//conference/programme"
240+
And the manifest depth 0 should have 3 resource IRIs
241+
And the manifest depth 1 should have 3 resource IRIs
242+
And the manifest depth 0 should contain the IRI "/_/routes//conference"
243+
And the manifest depth 1 should contain the IRI "/_/routes//conference/programme"
244244

245245
Scenario: The manifest for a nested Page route includes parent resource IRIs grouped by depth
246246
Given there is a Page resource with the route path "/conference/programme" nested within the route "/conference"
247247
When I send a "GET" request to "/_/resource_manifest//conference/programme"
248248
Then the response status code should be 200
249249
And the JSON node "resource_iris" should have 2 elements
250-
And the JSON node "resource_iris[0]" should have 2 elements
251-
And the JSON node "resource_iris[1]" should have 2 elements
252-
And the JSON node "resource_iris[0]" should contain the element "/_/routes//conference"
253-
And the JSON node "resource_iris[1]" should contain the element "/_/routes//conference/programme"
250+
And the manifest depth 0 should have 2 resource IRIs
251+
And the manifest depth 1 should have 2 resource IRIs
252+
And the manifest depth 0 should contain the IRI "/_/routes//conference"
253+
And the manifest depth 1 should contain the IRI "/_/routes//conference/programme"
254254

255255
Scenario: I can get a manifest of all unauthenticated resources that should be loaded for a route
256256
Given there is a PageData resource with the route path "/my-route"
257257
When I send a "GET" request to "/_/resource_manifest//my-route"
258258
Then the response status code should be 200
259259
And the JSON node "resource_iris" should have 1 element
260-
And the JSON node "resource_iris[0][0]" should be equal to "/_/routes//my-route"
261-
And the JSON node "resource_iris[0][1]" should match the regex "/\/page_data\/page_data_with_components\/[a-z0-9\-]+/"
262-
And the JSON node "resource_iris[0][2]" should match the regex "/\/_\/pages\/[a-z0-9\-]+/"
260+
And the manifest depth 0 root IRI should be "/_/routes//my-route"
261+
And the manifest depth 0 should contain an IRI matching "/\/page_data\/page_data_with_components\/[a-z0-9\-]+/"
262+
And the manifest depth 0 should contain an IRI matching "/\/_\/pages\/[a-z0-9\-]+/"
263263

264264
@loginAdmin
265265
Scenario: I can get a manifest of all authenticated resources that should be loaded for a route
266266
Given there is a PageData resource with the route path "/my-route"
267267
When I send a "GET" request to "/_/resource_manifest//my-route"
268268
Then the response status code should be 200
269269
And the JSON node "resource_iris" should have 1 element
270-
And the JSON node "resource_iris[0][0]" should be equal to "/_/routes//my-route"
271-
And the JSON node "resource_iris[0][1]" should match the regex "/\/page_data\/page_data_with_components\/[a-z0-9\-]+/"
272-
And the JSON node "resource_iris[0][2]" should match the regex "/\/_\/pages\/[a-z0-9\-]+/"
270+
And the manifest depth 0 root IRI should be "/_/routes//my-route"
271+
And the manifest depth 0 should contain an IRI matching "/\/page_data\/page_data_with_components\/[a-z0-9\-]+/"
272+
And the manifest depth 0 should contain an IRI matching "/\/_\/pages\/[a-z0-9\-]+/"
273273

274274
Scenario: A draft component resolved via pageDataProperty is excluded from the anonymous manifest
275275
Given there is a PageData resource with a draft component in a pageDataProperty position and the route path "/my-route"
276276
When I send a "GET" request to "/_/resource_manifest//my-route"
277277
Then the response status code should be 200
278-
And the JSON node "resource_iris[0]" should not contain an element matching "/\/component\/dummy_publishable_components\/[a-z0-9\-]+/"
278+
And the manifest depth 0 should not contain an IRI matching "/\/component\/dummy_publishable_components\/[a-z0-9\-]+/"
279279

280280
@loginAdmin
281281
Scenario: A draft component resolved via pageDataProperty is included in the admin manifest
282282
Given there is a PageData resource with a draft component in a pageDataProperty position and the route path "/my-route"
283283
When I send a "GET" request to "/_/resource_manifest//my-route"
284284
Then the response status code should be 200
285-
And the JSON node "resource_iris[0]" should contain an element matching "/\/component\/dummy_publishable_components\/[a-z0-9\-]+/"
285+
And the manifest depth 0 should contain an IRI matching "/\/component\/dummy_publishable_components\/[a-z0-9\-]+/"
286286

287287
@loginUser
288288
Scenario: When I create a redirect route, the cache should be cleared for the route being redirected to

0 commit comments

Comments
 (0)