Skip to content

feat: request header/raw body access and StaticRouter fixes - #19

Merged
abdul-kaioum merged 12 commits into
mainfrom
feat/request-headers
Oct 27, 2026
Merged

abdul-kaioum merged 12 commits into
mainfrom
feat/request-headers

Conversation

@abdul-kaioum

@abdul-kaioum abdul-kaioum commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Why

Outside REST, Request has no way to read headers or the raw body: __call only proxies to WP_REST_Request, and body() is the decoded payload. Static and AJAX routes that authenticate by a header token or an HMAC over the exact bytes (webhooks, setup proofs) have to fall back to $_SERVER and php://input themselves.

What

  • InteractsWithHeaders trait, used by Request:
    • header($name, $default = null) accepts any name style (X-Setup-Token or x_setup_token) and returns a sanitized value (sanitize_text_field); headers() returns the sanitized map keyed the way WP_REST_Request canonicalizes names (lowercase, underscores); hasHeader($name).
    • rawHeader($name, $default = null) returns the exact bytes, for HMAC/token verification only.
    • Values come from the hydrated WP_REST_Request when present (multi-value headers joined with , ), else from $_SERVER HTTP_* plus CONTENT_TYPE/CONTENT_LENGTH/CONTENT_MD5. Empty CONTENT_* (FastCGI sends '' on bodyless requests) counts as absent.
  • Request::content() returns the raw body: the REST request's when hydrated, else php://input, read once and cached. setBody() now decodes from content(), so the stream is not read twice.
  • method() unslashes, sanitizes and upper-cases REQUEST_METHOD, returning '' when absent (it read the raw global with no isset).
  • contentType() reads the sanitized Content-Type and strips a case-insensitive leading application/. Bug fix: it used ltrim($type, 'application/'), a character mask, so application/pdf became df and text/plain became ext/plain.
  • New public methods stay untyped so consumer Request subclasses can override them (same contract RequestTest pins for the other accessors).

StaticRouter

  • Subdirectory installs: routes matched the raw REQUEST_URI against /page/..., so on example.com/blog (and with /index.php/ permalinks) nothing matched and WordPress served a 404. Routes now match WP::$request (home path and index.php already stripped), falling back to the request URI minus the home path under plain permalinks.
  • dispatchOnParseRequest() (opt-in): dispatch before the main query runs, so self-responding routes (OAuth callbacks, webhooks) are never flagged 404 or redirected by redirect_canonical. The default stays template_redirect, which page-content routes (bit-connect's portal) need.
  • Flush storm: maybeFlushRewriteRules() flushed (and rewrote .htaccess in admin) on every request whenever our rules never persisted. It now skips plain permalinks and retries at most every 5 minutes via a transient.
  • Deactivation: flushOnDeactivate() flushed while the router's rules were still registered on the deactivating request, re-saving them so /page/* kept rewriting after the plugin was off. It now empties rewrite_rules (row kept) so WordPress rebuilds it on the next request without them.
  • Root rule: RewriteRuleSet added ^page/?$ for any route set; it is now added only for an empty-path route (Route::get('/')), so a non-page page slug is no longer hijacked.

Tests

Tests landed before each implementation commit. RequestTest covers header lookup by name style, CONTENT_* exposure, defaults, the canonical map, sanitized header()/headers() vs exact rawHeader(), REST precedence, method() sanitizing/missing, and contentType() prefix handling (JSON with charset, pdf, mixed case, non-application types, tags); raw body byte-for-byte (JSON and form payloads), empty body, single stream read and REST precedence. A FakePhpInputStream fixture replaces the php:// wrapper so the tests drive the real file_get_contents('php://input') path. The sanitize_text_field stub now mirrors core so sanitization is actually asserted. StaticRoutingTest/RewriteRuleSetTest cover subdirectory and /index.php/ matching, home-path stripping (segment boundary, no WP object, template_redirect's '' argument), default vs parse_request dispatch (unmatched paths, method enforcement), plain-permalink skip, deactivation clearing (also after init), per-page retry windows, activation inside a window and the root rule; the new edge cases were each checked against a mutation of StaticRouter. composer test (298), php-cs-fixer and composer compat pass.

Needed by the PaymentForm plugin to move its /paymentform/* endpoints onto StaticRouter; it will use dispatchOnParseRequest() and read rawHeader()/content() for its token and HMAC checks.

🤖 Generated with Claude Code

Pins header()/headers()/hasHeader(): any name style resolves, CONTENT_*
headers are exposed without the HTTP_ prefix, values stay unsanitized,
and a hydrated WP_REST_Request takes precedence over $_SERVER.

Assisted-By: AI
Request had no header access outside REST (where __call proxies to
WP_REST_Request::get_header), so static and AJAX routes had to read
$_SERVER directly. The InteractsWithHeaders trait adds header(),
headers() and hasHeader(): names are canonicalized like WP_REST_Request
(lowercase, underscores), values come from the hydrated REST request
when present, else from $_SERVER (unslashed, not sanitized, so tokens
and signatures survive intact).

Public methods stay untyped so consumer subclasses can override them.

Assisted-By: AI
A FakePhpInputStream fixture stands in for the php:// wrapper so the
tests drive the real file_get_contents('php://input') path: the body is
returned byte for byte (JSON and form payloads), is empty without a
body, is read from the stream once, and a hydrated WP_REST_Request's
body takes precedence.

Assisted-By: AI
body() only offers the decoded payload, which cannot be used to verify
an HMAC computed over the exact bytes sent. content() returns the raw
body: the hydrated REST request's when present, else php://input read
once and cached. setBody() now decodes from content(), so the stream is
not read twice.

Assisted-By: AI
header()/headers() must return sanitized values with rawHeader() for
signature checks; method() must sanitize, upper-case and tolerate a
missing REQUEST_METHOD; contentType() must strip only a leading
"application/" (ltrim's character mask turned application/pdf into
"df" and text/plain into "ext/plain"). The sanitize_text_field stub now
mirrors core so these assertions are meaningful.

Assisted-By: AI
header() and headers() now return sanitize_text_field() output;
rawHeader() keeps the exact bytes for HMAC/token verification and says
so. method() unslashes, sanitizes and upper-cases REQUEST_METHOD and
returns '' when it is absent. contentType() reads the sanitized
Content-Type header and removes a case-insensitive "application/"
prefix instead of ltrim()'s character mask.

Assisted-By: AI
Pins StaticRouter behaviour the PaymentForm endpoints need:
- match WordPress's parsed path, so subdirectory installs and
  /index.php/ permalinks route; strip the home path from the request
  URI when WP has not parsed one (plain permalinks)
- dispatch on template_redirect by default, or on parse_request when
  opted in via dispatchOnParseRequest()
- skip the init flush under plain permalinks and retry missing rules at
  most once per window instead of on every request
- register the page root rule only for an empty-path route

Adds WP and home_url() stubs and defaults the test site to pretty
permalinks at the domain root.

Assisted-By: AI
- Match routes against WP::$request (home path and index.php already
  stripped) and fall back to the request URI minus the home path, instead
  of the raw REQUEST_URI, which never matched on subdirectory installs.
- dispatchOnParseRequest() opts a router into dispatching before the
  main query runs, so self-responding routes (callbacks, webhooks) are
  never flagged 404 or caught by redirect_canonical. Page-content routes
  keep the template_redirect default.
- maybeFlushRewriteRules() no longer flushes under plain permalinks and
  retries missing rules at most every 5 minutes via a transient, instead
  of flushing (and rewriting .htaccess) on every request.
- RewriteRuleSet adds the ^page/?$ rule only for an empty-path route, so
  routes no longer claim the bare page URL by accident.

Assisted-By: AI
@abdul-kaioum abdul-kaioum changed the title feat(request): header and raw body access feat: request header/raw body access and StaticRouter fixes Oct 26, 2026
Adds cases for template_redirect's '' argument falling back to the
global WP, slashes around WP::$request, the request-URI fallback with no
WP object, home-path stripping only on a segment boundary, early
dispatch ignoring unmatched paths and enforcing the route method,
per-page retry windows, activation flushing inside a retry window,
persisted rules skipping later flushes, and the root rule alongside
other routes. Each new case fails against a matching mutation of
StaticRouter.

Assisted-By: AI
Deactivation flushed while the router's rules were still registered on
that request, so the flush re-saved them and they outlived the plugin.
The tests now require no flush and an emptied rewrite_rules option,
including after init has registered the rules.

Assisted-By: AI
flushOnDeactivate() now empties the rewrite_rules option (row kept) so
WordPress rebuilds it on the next request, when the deactivated
plugin's rules are no longer registered. Consumers no longer need their
own post-deactivation cleanup to stop stale /page/* rewrites.

Assisted-By: AI
Comment thread src/Http/Request/Request.php Resolved
@abdul-kaioum
abdul-kaioum merged commit 7452c7c into main Oct 27, 2026
0 of 6 checks passed
@abdul-kaioum
abdul-kaioum deleted the feat/request-headers branch October 27, 2026 08:42
abdul-kaioum added a commit that referenced this pull request Oct 28, 2026
InteractsWithHeaders landed (#19) after the rector 2.7 bump (#18), so
the CI rector dry-run has failed on main since. The types are safe to
add: the trait is unreleased, so no consumer overrides exist yet.

Assisted-By: AI
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants