$npx -y skills add Lonsdale201/wp-agent-skills --skill br-etag-cacheAdd better-route 1.1 ETag and If-None-Match handling to GET or HEAD routes. Use for ETagMiddleware, strong or weak validators, custom etagResolver, WP_REST_Response preservation, comma-separated validators, wildcard matching, 304 responses, Cache-Control, proxy-stripped ETag trou
| 1 | # better-route: ETag conditional reads |
| 2 | |
| 3 | Use ETags on read routes to let clients revalidate a representation. They do not prevent duplicate writes; use idempotency for that. |
| 4 | |
| 5 | ```php |
| 6 | use BetterRoute\Middleware\Cache\ETagMiddleware; |
| 7 | |
| 8 | $router->get('/catalog', $handler) |
| 9 | ->publicRoute() |
| 10 | ->middleware([new ETagMiddleware()]); |
| 11 | ``` |
| 12 | |
| 13 | The default validator is a quoted SHA-1 of the JSON-encoded response body. It applies only to `GET`/`HEAD` results with status 200–299 except 204. |
| 14 | |
| 15 | ## 1.1 behavior |
| 16 | |
| 17 | The middleware preserves Better Route `Response` and `WP_REST_Response` status/data. It adds the ETag through the appropriate response API instead of flattening the WordPress response. |
| 18 | |
| 19 | It skips: |
| 20 | |
| 21 | - returned `WP_Error`; |
| 22 | - non-2xx responses; |
| 23 | - `204 No Content`; |
| 24 | - non-GET/HEAD methods. |
| 25 | |
| 26 | `If-None-Match` accepts: |
| 27 | |
| 28 | - a single validator; |
| 29 | - a comma-separated validator list; |
| 30 | - weak or strong forms of the same opaque tag; |
| 31 | - `*`. |
| 32 | |
| 33 | On a match, 1.1 returns `304` with no body and preserves cache-relevant source headers: `Cache-Control`, `Content-Location`, `Expires`, and `Vary`, plus the ETag. |
| 34 | |
| 35 | The middleware computes and controls the final `ETag` header; do not rely on an existing handler ETag remaining unchanged. |
| 36 | |
| 37 | ## Cheap custom validators |
| 38 | |
| 39 | For large responses, derive a validator from a stable version instead of hashing the full body: |
| 40 | |
| 41 | ```php |
| 42 | use BetterRoute\Http\RequestContext; |
| 43 | |
| 44 | $etag = new ETagMiddleware( |
| 45 | weak: false, |
| 46 | etagResolver: static function (mixed $response, RequestContext $context): string { |
| 47 | return (string) get_option('myapp_catalog_version', 0); |
| 48 | }, |
| 49 | ); |
| 50 | ``` |
| 51 | |
| 52 | Return the opaque value; the middleware quotes it. A returned already-quoted or `W/` value is normalized. Invalid quote/control bytes are replaced with a safe hash rather than reaching an HTTP header. |
| 53 | |
| 54 | Use `weak: true` when byte differences may represent the same semantic representation: |
| 55 | |
| 56 | ```php |
| 57 | new ETagMiddleware(weak: true); // W/"..." |
| 58 | ``` |
| 59 | |
| 60 | The default JSON hash follows array order. Deeply sort associative data before returning it, or use a stable version resolver, when construction order is nondeterministic. |
| 61 | |
| 62 | ## Cache-Control and privacy |
| 63 | |
| 64 | ETag enables revalidation; it does not define freshness or sharing. Set Cache-Control separately: |
| 65 | |
| 66 | ```php |
| 67 | return new Response($data, 200, [ |
| 68 | 'Cache-Control' => 'public, max-age=300', |
| 69 | ]); |
| 70 | ``` |
| 71 | |
| 72 | Use `private`/`no-store` as appropriate for user-specific data. Never let a shared CDN cache `/me` or another personalized URL merely because it has an ETag. |
| 73 | |
| 74 | ## Troubleshooting |
| 75 | |
| 76 | Test both the public endpoint and the PHP/upstream origin. A reverse proxy, nginx/RunCloud rule, CDN, compression layer, or caching plugin may remove or rewrite an outbound ETag even when application-level matching still produces a correct 304. |
| 77 | |
| 78 | Verify: |
| 79 | |
| 80 | ```bash |
| 81 | curl -i 'https://example.com/wp-json/myapp/v1/catalog' |
| 82 | curl -i 'https://example.com/wp-json/myapp/v1/catalog' \ |
| 83 | -H 'If-None-Match: "copied-tag"' |
| 84 | ``` |
| 85 | |
| 86 | Also test `If-None-Match: W/"copied-tag"`, a comma-separated list, and `*`. |
| 87 | |
| 88 | ## Review checklist |
| 89 | |
| 90 | - Attach only to GET/HEAD routes. |
| 91 | - Use a cheap stable resolver for large bodies. |
| 92 | - Set explicit Cache-Control and correct privacy semantics. |
| 93 | - Confirm WP REST response status/data/headers survive. |
| 94 | - Confirm 4xx/5xx and WP_Error do not gain an ETag. |
| 95 | - Confirm matched 304 has no body and retains cache headers. |
| 96 | - Inspect intermediary header behavior if ETag disappears externally. |
| 97 | |
| 98 | ## Related skills |
| 99 | |
| 100 | - Use `br-idempotency` or `br-atomic-idempotency` for write retries. |
| 101 | - Use `br-cors-public-client` to expose `ETag` to browser JavaScript. |
| 102 | |
| 103 | ## References |
| 104 | |
| 105 | - Verified source paths: |
| 106 | - `src/Middleware/Cache/ETagMiddleware.php` |
| 107 | - `src/Http/Response.php` |