Skip to content

Commit 6f86229

Browse files
Merge pull request #247 from components-web-app/feature/243-purge-rendered-html
Purge the rendered HTML on request, for deploys and admin purges (#243)
2 parents 54d749d + e421a67 commit 6f86229

11 files changed

Lines changed: 442 additions & 7 deletions

File tree

‎CLAUDE.md‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ This factory (`src/ApiPlatform/Metadata/Resource/RoutingPrefixResourceMetadataCo
204204
- **Symfony 8.2 null-for-typed-string**: Symfony 8.2 converts a null value for a non-nullable typed string property into a proper validation violation rather than a raw TypeError. Prefer `?string = null` (nullable PHP type + nullable ORM column) so null passes through deserialization to the `#[Assert\NotBlank]` validator consistently across all Symfony versions. AP4's `AbstractItemNormalizer` reads serializer metadata (including the ORM `nullable` flag), so `?string` with `nullable: false` on the ORM column still triggers the TypeError path.
205205
- **Behat / Symfony 8.x**: The `behat/behat` and `friends-of-behat/symfony-extension` packages do not yet support Symfony 8.x (their constraints cap at `^7.0`). This holds `symfony/console`, `symfony/event-dispatcher`, `symfony/property-access`, `symfony/http-kernel`, and the full security stack at 7.x in the test environment. The production bundle code is Symfony 8.x-compatible; only the test tooling is blocked. Watch for a `behat/behat` 4.x stable release or updated `friends-of-behat/symfony-extension` to unblock.
206206
- **Service ID convention — two mandatory exceptions**: All bundle services use stable `silverback.api_components.*` string IDs with FQCN class-name aliases. Two categories **must** keep the FQCN as the primary service ID (with the string ID as alias) because they are looked up by class name at runtime:
207-
1. **AP4 state providers** tagged `api_platform.state_provider` and referenced as `provider: SomeClass::class` on an operation — AP4 builds its `CallableProvider` service locator keyed by tagged service ID. If the service ID is a string (not the FQCN), AP4 throws `ProviderNotFoundException`.
207+
1. **AP4 state providers and state processors** tagged `api_platform.state_provider` / `api_platform.state_processor` and referenced as `provider:` / `processor: SomeClass::class` on an operation — AP4 builds its `CallableProvider` / `CallableProcessor` service locator keyed by tagged service ID. If the service ID is a string (not the FQCN), AP4 throws `ProviderNotFoundException` / `ProcessorNotFoundException`.
208208
2. **Controller action services** tagged `controller.service_arguments` — Symfony's `RegisterControllerArgumentLocatorsPass` keys argument locators by service ID. Routes resolve controllers by FQCN; if the ID is a string the locator can't be matched and `__invoke` method argument injection fails.
209209
Pattern: `->set(SomeClass::class)->...->tag(...)` then `->alias('silverback.api_components.*', SomeClass::class)->public()`. All other services use the reverse.
210210

@@ -227,6 +227,7 @@ Key current group assignments:
227227
| `GET /routes/{id}/redirects` | Follow the redirect chain for a Route |
228228
| `PATCH /_/routes/{id}` | Accepts optional `cascadeChildPaths: true` — when `path` changes, walks direct children and updates their route paths (prefixing with the new parent path), creating redirects from old to new paths. |
229229
| `GET /_/routes/{id}/children` | Returns the recursive child tree for a route (admin-only). Each node: `{ "route": IRI, "path": string, "children": [] }`. Not filtered by publication — an admin needs to see scheduled children. |
230+
| `POST /_/rendered_html/purge` | Purges the `cwa-html` rendered-HTML cache tag and nothing else (`ROLE_ADMIN`, no body, 204). The deploy path is the console command `silverback:api-components:purge-rendered-html`; this endpoint is for the admin button. See #243. |
230231

231232
---
232233

@@ -1032,7 +1033,7 @@ References: `src/EventListener/Api/CacheHeadersEventListener.php`, `src/Dependen
10321033

10331034
> **Listed classes should be association-free leaf resources.** `HttpCachePurger::collectResource()` is reached for resources collected as **associations** of another write (`PropagateUpdatesListener::gatherAllAssociatedEntities`), not only for the resource actually written. `$type` cannot discriminate — associated resources are collected as `'updated'` too — so filtering on it is not an option and was deliberately not attempted. `SiteConfigParameter` has no associations, so this is inert today; listing a class that *is* reachable as an association would let unrelated writes drop the whole HTML cache.
10341035
1035-
**Deploys need no tag.** A new build changes the `/_nuxt` hashes with no resource IRI changing, which is not expressible as a resource purge — but Souin's in-memory `otter` store dies with the pod on redeploy, so it is already handled.
1036+
**Deploys were thought to need no tag, and #243 overturned that.** The reasoning here was that Souin's in-memory `otter` store dies with the pod on redeploy. It does not hold: the API and PWA deploy **separately**, so a front-end-only deploy restarts no API pod, and the cached HTML keeps pointing at `/_nuxt` assets the new build has removed — a blank page, not merely stale content. A CDN or a persistent Souin store would outlive the pod anyway. A front-end deploy now purges `cwa-html` explicitly; see #243.
10361037

10371038
**Behat:** `features/main/purge_rendered_html.feature` — a listed class purging the tag on PUT / POST / DELETE; an unlisted class (`Layout` via `ComponentGroup`) purging its own IRI but **not** the tag; and a single write emitting the tag exactly once. The "several listed resources in one flush yield exactly one tag" case is **not expressible through the API** (no batch operation ⇒ never more than one written resource per request), so it lives in `tests/HttpCache/HttpCachePurgerTest.php` alongside subclass matching, no-carry-over between purges, and `reset()`.
10381039

@@ -1042,6 +1043,51 @@ References: `src/HttpCache/HttpCachePurger.php`, `src/DependencyInjection/Config
10421043

10431044
---
10441045

1046+
### #243 — Purge the rendered HTML on request, for deploys and manual admin purges (front-end: cwa-nuxt-module #291, deploy hook: components-web-app #70) ✓ **DONE**
1047+
1048+
**Implemented.** `HttpCachePurger::purgeRenderedHtml()` sends `[RENDERED_HTML_TAG]` straight to the purger and records it on the collector. Two callers:
1049+
1050+
- **Console command `silverback:api-components:purge-rendered-html`** (`src/Command/PurgeRenderedHtmlCommand.php`) — **the deploy path**.
1051+
- **`POST /_/rendered_html/purge`** — DTO `src/ApiResource/RenderedHtmlPurge.php`, state processor `src/DataProcessor/StateProcessor/RenderedHtmlPurgeStateProcessor.php`. `input: false`, `output: false`, `status: 204`, `security: "is_granted('ROLE_ADMIN')"`. **The only HTTP caller is the admin button** in the front end.
1052+
1053+
**`purgeRenderedHtml()` never touches the collected state.** It does not read `$tags` or the `$purgeRenderedHtml` flag and does not call `reset()`; the send step is a private `send()` shared with `propagate()`. Calling `propagate()` out of band would have carried whatever the request had already collected and cleared request-scoped state early. Pinned in `HttpCachePurgerTest`: it sends exactly `['cwa-html']`, ignores tags already collected, and a later `propagate()` still sends them.
1054+
1055+
**"Only the tag" is structural, not filtered.** With no input and a parameterless method, an arbitrary key is unrepresentable rather than rejected. Behat pins that neither a body nor a query string naming other tags widens the purge.
1056+
1057+
**Why a console command is the deploy path — settled with Daniel, do not re-derive.**
1058+
1059+
- In the CWA template **all traffic enters through the API pod's Caddy**, which reverse-proxies to Nuxt and caches its HTML in Souin (`components-web-app/api/frankenphp/Caddyfile`). Souin's purge endpoint is bound to `localhost:2019` **inside the API pod**, so nothing outside that container can send the purge itself.
1060+
- The template already has the precedent: `components-web-app/bin/devops/k8s.sh` `load_fixtures()` waits with `kubectl rollout status`, then runs a console command inside the API pod with `kubectl exec`. A front-end deploy does the same with this command.
1061+
- **No new credential.** The pipeline already holds `kubectl exec` on the API pod, which is strictly more powerful than "purge the HTML cache". No token, no scoped credential, no HTTP.
1062+
- **Plain `ROLE_ADMIN`, no named voter attribute.** A dedicated attribute was only justified by a non-admin HTTP deployer, and that caller does not exist. A plain role check also has no subject, which matters: `SiteConfigParameterVoter::supports()` requires a `SiteConfigParameter` instance, so on a bodyless POST every voter would abstain and deny admins too (see "Abstention is a decision").
1063+
- **Rejected:** a helm `post-upgrade` Job is Kubernetes-specific, and a future Vercel front end has no helm. A PWA `postStart` hook runs per pod, so it also fires on every HPA scale-up and drops the whole HTML cache exactly when under load. An API-pod startup hook either never fires on a front-end-only deploy or fires when the in-memory cache has already died with the pod.
1064+
- **On Vercel** the HTML does not pass through the API pod, so Souin never holds it and this purge is a harmless no-op.
1065+
1066+
**No coalescing, deliberately.** The issue suggested collapsing repeated purges within N seconds. A leading-edge coalesce drops the *later* call, and the later call reflects the newest state: an admin purge mid-rollout would swallow the one that runs after the new assets exist, which is exactly the blank-page case this exists to fix. A repeated purge is a cheap no-op at the edge. **Do not build a debounce** (same conclusion as #232, for a different reason).
1067+
1068+
**No purger configured.** `ApiPlatformCompilerPass` removes `silverback.api_components.http_cache.purger` when API Platform has no `http_cache.invalidation` purger, so the command and processor take it as `?HttpCachePurger` via `NULL_ON_INVALID_REFERENCE`. The command then prints that nothing was purged and still succeeds, so a deploy step on an application with no cache does not fail. The endpoint returns 204.
1069+
1070+
**`read: true` is required on the operation.** Under `use_symfony_listeners: true` (the test app), AP's `PlaceholderAction::__invoke($data)` needs a `data` request attribute. A POST neither reads nor deserializes by default, and with `input: false` nothing sets it, so the request 500s with "Could not resolve argument $data". `read: true` makes `ReadProvider` run. There is no provider, so it logs `ProviderNotFoundException` at debug, sets `data` to null, and does not 404 on a POST. The body is never read.
1071+
1072+
**CSRF position.** `input: false` accepts any `Content-Type`. Verified: a `text/plain` POST reaches the processor. That makes this the one bundle POST a cross-site `text/plain` form could reach. It relies on lexik's default JWT cookie `samesite: lax`, under which the cookie is not sent on a cross-site POST. **An application that sets `SameSite=None` on the JWT cookie exposes this endpoint to CSRF.**
1073+
1074+
**Wiring**, in `src/Resources/config/services_doctrine_orm_http_cache_purger.php` beside the purger they depend on, all registered explicitly with no reliance on autoconfiguration. The command is `silverback.api_components.command.purge_rendered_html` + FQCN alias, tagged `console.command`, like the bundle's other commands. The processor keeps its **FQCN as the primary id** with `silverback.api_components.api_platform.state_processor.rendered_html_purge` as the alias, tagged `api_platform.state_processor`, `->autoconfigure(false)`. That is the state-provider exception, for the same reason: `CallableProcessor`.
1075+
1076+
**Tests.** Behat: `features/main/purge_rendered_html_operation.feature` covers these cases:
1077+
1078+
- an admin purges exactly once, and `cwa-html` is the only tag
1079+
- a body or a query string cannot widen the purge
1080+
- GET → 405
1081+
- `@loginUser` → 403, which is **the scenario that proves the operation's own `security`**
1082+
- anonymous → 401, which proves only **the test app's firewall** (`access_control` rejects unauthenticated POSTs before the operation runs), not the operation
1083+
- repeats are safe
1084+
1085+
The new `ProfilerContext` step `:tag should be the only cache tag purged` is built on `collectPurgedTags()`. Unit tests: `tests/Command/PurgeRenderedHtmlCommandTest.php` and `tests/DataProcessor/StateProcessor/RenderedHtmlPurgeStateProcessorTest.php`. Both build the service **from its definition** in a bare `ContainerBuilder`, with the purger replaced by a mock, rather than constructing the class directly, so a definition that cannot construct its class fails the test. No kernel is booted (see the Risky/Infection warning under `kernel.reset`).
1086+
1087+
References: `src/HttpCache/HttpCachePurger.php`, `src/Command/PurgeRenderedHtmlCommand.php`, `src/ApiResource/RenderedHtmlPurge.php`, `src/DataProcessor/StateProcessor/RenderedHtmlPurgeStateProcessor.php`, `src/Resources/config/services_doctrine_orm_http_cache_purger.php`, `features/bootstrap/ProfilerContext.php`.
1088+
1089+
---
1090+
10451091
### #211 / #212 / #215 — Maker DX: `make:page-data` prompts + correct nuxt.config snippet, `make:api-component` help text ✓ **DONE**
10461092

10471093
Three papercuts found by the docs accuracy audit, all in `src/Maker/`.

‎features/bootstrap/ProfilerContext.php‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,18 @@ public function theCacheTagShouldBePurgedTimes(string $tag, int $count)
239239
throw new ExpectationException(\sprintf('The cache tag %s was purged %d time(s) but %d was expected. Tags that were purged were `%s`', $tag, $actual, $count, implode('`, `', $purged)), $this->minkContext->getSession()->getDriver());
240240
}
241241

242+
/**
243+
* @Then :tag should be the only cache tag purged
244+
*/
245+
public function theCacheTagShouldBeTheOnlyCacheTagPurged(string $tag)
246+
{
247+
$purged = $this->collectPurgedTags();
248+
if ([] !== $purged && [$tag] === array_values(array_unique($purged))) {
249+
return;
250+
}
251+
throw new ExpectationException(\sprintf('The cache tag %s should have been the only tag purged. Tags that were purged were `%s`', $tag, implode('`, `', $purged)), $this->minkContext->getSession()->getDriver());
252+
}
253+
242254
/**
243255
* @Then the manifest cache tag for the resource :resource_name should be purged
244256
*/
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
Feature: Purging the front end's rendered HTML on request
2+
In order for a front-end deploy or a manual purge to drop every cached page
3+
As an administrator
4+
I need an operation that purges the rendered HTML cache tag and nothing else
5+
6+
Background:
7+
Given I add "Accept" header equal to "application/ld+json"
8+
And I add "Content-Type" header equal to "application/ld+json"
9+
10+
@loginAdmin
11+
Scenario: An administrator purges the rendered HTML tag and only that tag
12+
When I send a "POST" request to "/_/rendered_html/purge"
13+
Then the response status code should be 204
14+
And the cache tag "cwa-html" should be purged 1 time
15+
And "cwa-html" should be the only cache tag purged
16+
17+
@loginAdmin
18+
Scenario: A request body naming other tags cannot widen the purge
19+
When I send a "POST" request to "/_/rendered_html/purge" with body:
20+
"""
21+
{"tags": ["/_/layouts", "manifest:/_/pages/1"], "tag": "/_/routes"}
22+
"""
23+
Then the response status code should be 204
24+
And "cwa-html" should be the only cache tag purged
25+
26+
@loginAdmin
27+
Scenario: A query string naming other tags cannot widen the purge
28+
When I send a "POST" request to "/_/rendered_html/purge?tags[]=/_/layouts&tags[]=manifest:/_/pages/1&tag=/_/routes"
29+
Then the response status code should be 204
30+
And "cwa-html" should be the only cache tag purged
31+
32+
@loginAdmin
33+
Scenario: The operation cannot be reached with GET
34+
When I send a "GET" request to "/_/rendered_html/purge"
35+
Then the response status code should be 405
36+
And the cache tag "cwa-html" should not be purged
37+
38+
@loginUser
39+
Scenario: A user without the administrator role is refused by the operation's own security
40+
When I send a "POST" request to "/_/rendered_html/purge"
41+
Then the response status code should be 403
42+
And the cache tag "cwa-html" should not be purged
43+
44+
Scenario: An anonymous request is refused, by the test application's firewall before the operation is reached
45+
When I send a "POST" request to "/_/rendered_html/purge"
46+
Then the response status code should be 401
47+
And the cache tag "cwa-html" should not be purged
48+
49+
@loginAdmin
50+
Scenario: Repeating the purge is safe and each call purges once
51+
When I send a "POST" request to "/_/rendered_html/purge"
52+
Then the response status code should be 204
53+
And the cache tag "cwa-html" should be purged 1 time
54+
When I send a "POST" request to "/_/rendered_html/purge"
55+
Then the response status code should be 204
56+
And the cache tag "cwa-html" should be purged 1 time
57+
And "cwa-html" should be the only cache tag purged
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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\ApiResource;
13+
14+
use ApiPlatform\Metadata\ApiResource;
15+
use ApiPlatform\Metadata\Post;
16+
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\RenderedHtmlPurgeStateProcessor;
17+
18+
#[ApiResource]
19+
#[Post(
20+
uriTemplate: '/rendered_html/purge',
21+
status: 204,
22+
read: true,
23+
input: false,
24+
output: false,
25+
security: "is_granted('ROLE_ADMIN')",
26+
processor: RenderedHtmlPurgeStateProcessor::class,
27+
)]
28+
class RenderedHtmlPurge
29+
{
30+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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\Command;
13+
14+
use Silverback\ApiComponentsBundle\HttpCache\HttpCachePurger;
15+
use Symfony\Component\Console\Attribute\AsCommand;
16+
use Symfony\Component\Console\Command\Command;
17+
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\Console\Output\OutputInterface;
19+
20+
#[AsCommand(name: 'silverback:api-components:purge-rendered-html', description: 'Purges every page of rendered front-end HTML from the HTTP cache by its `cwa-html` tag')]
21+
class PurgeRenderedHtmlCommand extends Command
22+
{
23+
public function __construct(private readonly ?HttpCachePurger $httpCachePurger)
24+
{
25+
parent::__construct();
26+
}
27+
28+
protected function execute(InputInterface $input, OutputInterface $output): int
29+
{
30+
if (null === $this->httpCachePurger) {
31+
$output->writeln('No HTTP cache purger is configured, so nothing was purged');
32+
33+
return Command::SUCCESS;
34+
}
35+
36+
$this->httpCachePurger->purgeRenderedHtml();
37+
$output->writeln(\sprintf('Purged the `%s` cache tag', HttpCachePurger::RENDERED_HTML_TAG));
38+
39+
return Command::SUCCESS;
40+
}
41+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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\DataProcessor\StateProcessor;
13+
14+
use ApiPlatform\Metadata\Operation;
15+
use ApiPlatform\State\ProcessorInterface;
16+
use Silverback\ApiComponentsBundle\HttpCache\HttpCachePurger;
17+
18+
/**
19+
* @implements ProcessorInterface<mixed, null>
20+
*/
21+
class RenderedHtmlPurgeStateProcessor implements ProcessorInterface
22+
{
23+
public function __construct(private readonly ?HttpCachePurger $httpCachePurger)
24+
{
25+
}
26+
27+
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
28+
{
29+
$this->httpCachePurger?->purgeRenderedHtml();
30+
31+
return null;
32+
}
33+
}

0 commit comments

Comments
 (0)