Skip to content

Commit 3cd5034

Browse files
Merge pull request #246 from components-web-app/fix/245-route-generator-parent-route
Refuse to generate a route for a page whose parent has no route (#245)
2 parents 6f86229 + 1a9b5d7 commit 3cd5034

8 files changed

Lines changed: 230 additions & 15 deletions

File tree

‎CLAUDE.md‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,15 @@ Consumers:
171171
3. Resolves name/path conflicts with a numeric suffix
172172
4. Creates or updates the `Route` entity and calls `setRoute()` on the `PageData`
173173

174+
**Generation refuses when the page has a parent but the parent has no route (#245).** If `parentPage` or `parentPageData` is set and `getParentPageRoute()` returns null, `create()` throws `UnroutedParentException` before touching anything. It used to skip the prefix silently and hand the child a bare top-level path — frequently the parent's own natural path (a child at `/2027` under an unrouted conference whose natural path is `/2027`). The parent could then never take its route: `UniqueEntity('path')` rejected it with a 422 on a later, unrelated write, naming a path the user never created. The reason is **path squatting, not rendering** — rendering depth comes from the manifest either way (see **Route path concatenation — recommended, not required**). The generator must not claim a path that is not the page's to claim, so it fails where the cause is.
175+
176+
- `POST /_/routes/generate` returns **422** (`application/problem+json`, the message in `detail`), via `exception_to_status` in `prependApiPlatformConfig()`. 422 matches the endpoint's existing refusals (page/pageData both or neither), which are 422 validation violations. No Route is created.
177+
- **Pages with no parent are unaffected** and still get a top-level path.
178+
- **Explicit creation is unaffected.** `POST /_/routes` with a path, `DoctrineContext` and `CwaFixtureBuilder`'s `route:` argument never call the generator, so a routed child under an unrouted parent remains possible and remains live (the #224 guard). Refusing to *generate* a path is not refusing the page a route.
179+
- `CwaFixtureBuilder` propagates the exception out of `flush()`: a page nested under a parent that gets no route (an `isTemplate: true` page with no `route:`) must be given an explicit `route:`.
180+
181+
Tests: `tests/Helper/Route/RouteGeneratorTest.php`; `features/main/route.feature` (refused with 422 and zero Routes, prefixed under a routed parent, explicit creation still 201).
182+
174183
### Caching architecture
175184

176185
Resources are designed as **individual, piecemeal, independently cacheable entities**. The API does not bundle data into large grouped responses. Each resource (Route, Page, Layout, ComponentGroup, Component, etc.) is fetched and cached separately. When a resource changes, only that resource's cache entry is invalidated — not anything that merely references it.
@@ -253,7 +262,7 @@ Pages support sub-pages. A conference page at `/best-conference-ever` renders a
253262

254263
A validation constraint (`Assert\Expression`) ensures both cannot be set simultaneously.
255264

256-
`getParentPageRoute(): ?Route` is a computed helper (no DB column) returning `$parentPage?->getRoute() ?? $parentPageData?->getRoute()`. Used by `RouteGenerator` to prefix paths. Returns null gracefully when the parent is still in draft (no public Route yet).
265+
`getParentPageRoute(): ?Route` is a computed helper (no DB column) returning `$parentPage?->getRoute() ?? $parentPageData?->getRoute()`. Used by `RouteGenerator` to prefix paths. Returns null when the parent is still in draft (no public Route yet), in which case `RouteGenerator` refuses to generate a route for the child (#245).
257266

258267
### How the manifest carries parent resources
259268

@@ -302,7 +311,7 @@ This means:
302311

303312
- **No `$nested` boolean** — parent = nested, full stop. The presence of `$parentPage`/`$parentPageData` is the complete signal.
304313
- **Two FK properties, not one** — `AbstractPage` is a mapped superclass with no discriminator map; `?AbstractPage` cannot be a Doctrine FK target. `?Page` + `?AbstractPageData` mirrors `Route.$page`/`Route.$pageData`.
305-
- **`getParentPageRoute()` is computed** — no DB column; used by `RouteGenerator` only; returns null safely when the parent has no route yet.
314+
- **`getParentPageRoute()` is computed** — no DB column; used by `RouteGenerator` only; returns null when the parent has no route yet, and `RouteGenerator` then refuses to generate (#245).
306315
- **Route concatenation is recommended, not required** — `RouteGenerator` prefixes child paths for clean URLs and SEO, but the module's `<CwaPage />` renders depth from manifest data, not URL structure.
307316
- **`resource_iris` is `string[][]`, not `string[]`** — depth-grouped, root first. The module reads the array index as the rendering depth without any client-side traversal.
308317
- **Single rendering mechanism** — `<CwaPage />` uses a manifest in both public and admin/draft contexts. Both contexts use the same `/_/resource_manifest/{id}` endpoint — route path for public, UUID for admin/draft. The chain walk (`parentPage`/`parentPageData`) is a fallback only. No URL-depth dependency.
@@ -492,6 +501,7 @@ GroupBuilder
492501
| no `route:` on `->page()` + `isTemplate: true` | no Route created |
493502
| no `route:` on `->page()` without template flag | RouteGenerator called from title (slug) |
494503
| `->pageData(...)` inside `->nested()`, no route | RouteGenerator called → `/parent-path/slug-from-title` |
504+
| `->page(...)`/`->pageData(...)` inside `->nested()` of a parent that gets no route (e.g. `isTemplate: true`), no route | `UnroutedParentException` from `flush()` — pass an explicit `route:` (#245) |
495505
| `->pageData(...)` or `->page(...)` at top level, no route, no title | no Route created (draft) |
496506

497507
### Allowed components on groups
@@ -564,7 +574,7 @@ $topicBuilder->onRoutesCreated(function (array $childBuilders) use ($intro) {
564574

565575
- **No `$nested` boolean** — parent = nested, full stop. The presence of `$parentPage`/`$parentPageData` is the complete signal.
566576
- **Two FK properties, not one** — `AbstractPage` is a mapped superclass with no discriminator map; `?AbstractPage` cannot be a Doctrine FK target. `?Page` + `?AbstractPageData` mirrors `Route.$page`/`Route.$pageData`.
567-
- **`getParentPageRoute()` is computed** — no DB column; used by `RouteGenerator` only; returns null safely when the parent has no route yet.
577+
- **`getParentPageRoute()` is computed** — no DB column; used by `RouteGenerator` only; returns null when the parent has no route yet, and `RouteGenerator` then refuses to generate (#245).
568578
- **Route concatenation is recommended, not required** — `RouteGenerator` prefixes child paths for clean URLs and SEO, but the module's `<CwaPage />` renders depth from manifest data, not URL structure.
569579
- **`resource_iris` is `string[][]`, not `string[]`** — depth-grouped, root first. The module reads the array index as the rendering depth without any client-side traversal.
570580
- **Single rendering mechanism** — `<CwaPage />` uses a manifest in both public and admin/draft contexts. Both contexts use the same `/_/resource_manifest/{id}` endpoint — route path for public, UUID for admin/draft. The chain walk (`parentPage`/`parentPageData`) is a fallback only. No URL-depth dependency.

‎features/bootstrap/DoctrineContext.php‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,6 @@ final class DoctrineContext implements Context
7676
private JsonContext $jsonContext;
7777
private RouteLiveResolver $routeLiveResolver;
7878

79-
/**
80-
* Initializes context.
81-
*
82-
* Every scenario gets its own context instance.
83-
* You can also pass arbitrary arguments to the
84-
* context constructor through behat.yml.
85-
*/
8679
public function __construct(ManagerRegistry $doctrine, JWTTokenManagerInterface $jwtManager, IriConverterInterface $iriConverter, TimestampedDataPersister $timestampedHelper, UserPasswordHasherInterface $passwordHasher, JWTEncoderInterface $jwtEncoder, RouteLiveResolver $routeLiveResolver)
8780
{
8881
$this->routeLiveResolver = $routeLiveResolver;
@@ -2102,6 +2095,18 @@ public function theResponseResourceShouldBeSavedAs($name): void
21022095
$this->restContext->resources[$name] = $response['@id'];
21032096
}
21042097

2098+
/**
2099+
* @Then there should be :count Route resources
2100+
*/
2101+
public function thereShouldBeRouteResources(int $count): void
2102+
{
2103+
$this->manager->clear();
2104+
$actual = \count($this->manager->getRepository(Route::class)->findAll());
2105+
if ($actual !== $count) {
2106+
throw new ExpectationException(\sprintf('Expected %d Route resources, found %d.', $count, $actual), $this->minkContext->getSession()->getDriver());
2107+
}
2108+
}
2109+
21052110
/**
21062111
* @Then there should be :count ComponentPosition resources
21072112
*/

‎features/main/route.feature‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,34 @@ Feature: Route resources
107107
And the JSON should be valid according to the schema file "route.schema.json"
108108
And the Route "/original" should redirect to "/unnamed-page"
109109

110+
@loginUser
111+
Scenario: A route cannot be generated for a nested page whose parent page has no route
112+
Given there is a routeless parent PageData with a component and an unrouted child Page
113+
When I send a "POST" request to "/_/routes/generate" with data:
114+
| page |
115+
| resource[child_page] |
116+
Then the response status code should be 422
117+
And the JSON node "detail" should contain "parent page has no route"
118+
And there should be 0 Route resources
119+
120+
@loginUser
121+
Scenario: A route generated for a nested page whose parent page has a route is prefixed with the parent path
122+
Given there is a PageData resource with the route path "/conference/programme" nested within the route "/conference"
123+
When I send a "POST" request to "/_/routes/generate" with data:
124+
| pageData |
125+
| resource[page_data] |
126+
Then the response status code should be 201
127+
And the JSON node "path" should be equal to the string "/conference/unnamed-page"
128+
129+
@loginUser
130+
Scenario: A route can still be created explicitly for a nested page whose parent page has no route
131+
Given there is a routeless parent PageData with a component and an unrouted child Page
132+
When I send a "POST" request to "/_/routes" with data:
133+
| path | name | page |
134+
| /2027 | conference-2027 | resource[child_page] |
135+
Then the response status code should be 201
136+
And the JSON node "path" should be equal to the string "/2027"
137+
110138
@loginUser
111139
Scenario: I update a route path. A new redirect will be created.
112140
Given there is a PageData resource with the route path "/original"

‎src/DependencyInjection/SilverbackApiComponentsExtension.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Silverback\ApiComponentsBundle\EventListener\Form\FormSuccessEventListenerInterface;
2222
use Silverback\ApiComponentsBundle\Exception\ApiPlatformAuthenticationException;
2323
use Silverback\ApiComponentsBundle\Exception\UnparseableRequestHeaderException;
24+
use Silverback\ApiComponentsBundle\Exception\UnroutedParentException;
2425
use Silverback\ApiComponentsBundle\Exception\UserDisabledException;
2526
use Silverback\ApiComponentsBundle\Factory\Uploadable\MediaObjectFactory;
2627
use Silverback\ApiComponentsBundle\Factory\User\Mailer\ChangeEmailConfirmationEmailFactory;
@@ -365,6 +366,7 @@ private function prependApiPlatformConfig(ContainerBuilder $container, array $co
365366
UnparseableRequestHeaderException::class => 400,
366367
ApiPlatformAuthenticationException::class => 401,
367368
UserDisabledException::class => 401,
369+
UnroutedParentException::class => 422,
368370
],
369371
]
370372
);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\Exception;
13+
14+
/**
15+
* @author Daniel West <daniel@silverback.is>
16+
*/
17+
class UnroutedParentException extends \RuntimeException
18+
{
19+
}

‎src/Helper/Route/RouteGenerator.php‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Silverback\ApiComponentsBundle\Entity\Core\RoutableInterface;
1717
use Silverback\ApiComponentsBundle\Entity\Core\Route;
1818
use Silverback\ApiComponentsBundle\Exception\InvalidArgumentException;
19+
use Silverback\ApiComponentsBundle\Exception\UnroutedParentException;
1920
use Silverback\ApiComponentsBundle\Helper\Timestamped\TimestampedDataPersister;
2021
use Silverback\ApiComponentsBundle\Repository\Core\RouteRepository;
2122

@@ -57,6 +58,11 @@ public function createRedirect(string $fromPath, Route $targetRoute): Route
5758

5859
public function create(RoutableInterface $object, ?Route $route = null): Route
5960
{
61+
$parentPageRoute = $object->getParentPageRoute();
62+
if (null === $parentPageRoute && (null !== $object->getParentPage() || null !== $object->getParentPageData())) {
63+
throw new UnroutedParentException('Cannot generate a route for this page because its parent page has no route. Give the parent page a route first, or create this page\'s route explicitly.');
64+
}
65+
6066
$entityManager = $this->registry->getManagerForClass($className = $object::class);
6167
if (!$entityManager) {
6268
throw new InvalidArgumentException(\sprintf('Could not find entity manager for %s', $className));
@@ -75,7 +81,7 @@ public function create(RoutableInterface $object, ?Route $route = null): Route
7581

7682
$path = '/' . ltrim($titleSlug, '/');
7783

78-
if ($parentPageRoute = $object->getParentPageRoute()) {
84+
if ($parentPageRoute) {
7985
$path = '/' . ltrim($parentPageRoute->getPath(), '/') . $path;
8086
}
8187

@@ -88,10 +94,6 @@ public function create(RoutableInterface $object, ?Route $route = null): Route
8894

8995
if ($existingRoute) {
9096
$existingRoute->setRedirect($route);
91-
// When we enabled patch endpoint for route, this was required.
92-
// The existing route is found in uow, perhaps this is why..
93-
// Future investigation would be nice to know reasoning for this breaking tests and pageData becoming null
94-
// on the $route and staying on the existingRoute only when patch enabled.
9597
$route->setPage($existingRoute->getPage());
9698
$route->setpageData($existingRoute->getPageData());
9799
}

‎tests/DependencyInjection/SilverbackApiComponentsExtensionTest.php‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
use Silverback\ApiComponentsBundle\Entity\Core\RoutableInterface;
2525
use Silverback\ApiComponentsBundle\Entity\Core\Route;
2626
use Silverback\ApiComponentsBundle\Entity\Core\SiteConfigParameter;
27+
use Silverback\ApiComponentsBundle\Exception\ApiPlatformAuthenticationException;
28+
use Silverback\ApiComponentsBundle\Exception\UnparseableRequestHeaderException;
29+
use Silverback\ApiComponentsBundle\Exception\UnroutedParentException;
30+
use Silverback\ApiComponentsBundle\Exception\UserDisabledException;
2731
use Silverback\ApiComponentsBundle\Factory\User\Mailer\ChangeEmailConfirmationEmailFactory;
2832
use Silverback\ApiComponentsBundle\Factory\User\Mailer\PasswordResetEmailFactory;
2933
use Silverback\ApiComponentsBundle\Factory\User\Mailer\VerifyEmailFactory;
@@ -87,6 +91,21 @@ private static function minimalConfig(): array
8791
* @return array{0: ContainerBuilder, 1: list<string>} the built container and any warnings or
8892
* notices raised while loading it
8993
*/
94+
public function test_bundle_exceptions_are_mapped_to_their_http_status(): void
95+
{
96+
$container = new ContainerBuilder();
97+
$container->prependExtensionConfig('silverback_api_components', self::minimalConfig());
98+
99+
(new SilverbackApiComponentsExtension())->prepend($container);
100+
101+
$mapped = array_merge(...array_column($container->getExtensionConfig('api_platform'), 'exception_to_status'));
102+
103+
self::assertSame(400, $mapped[UnparseableRequestHeaderException::class]);
104+
self::assertSame(401, $mapped[ApiPlatformAuthenticationException::class]);
105+
self::assertSame(401, $mapped[UserDisabledException::class]);
106+
self::assertSame(422, $mapped[UnroutedParentException::class]);
107+
}
108+
90109
private function load(array $config): array
91110
{
92111
$container = new ContainerBuilder();

0 commit comments

Comments
 (0)