Repository navigation
feat: request header/raw body access and StaticRouter fixes - #19
Merged
Merged
Conversation
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
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
csemazharul
requested changes
Oct 27, 2026
csemazharul
approved these changes
Oct 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Outside REST,
Requesthas no way to read headers or the raw body:__callonly proxies toWP_REST_Request, andbody()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$_SERVERandphp://inputthemselves.What
InteractsWithHeaderstrait, used byRequest:header($name, $default = null)accepts any name style (X-Setup-Tokenorx_setup_token) and returns a sanitized value (sanitize_text_field);headers()returns the sanitized map keyed the wayWP_REST_Requestcanonicalizes names (lowercase, underscores);hasHeader($name).rawHeader($name, $default = null)returns the exact bytes, for HMAC/token verification only.WP_REST_Requestwhen present (multi-value headers joined with,), else from$_SERVERHTTP_*plusCONTENT_TYPE/CONTENT_LENGTH/CONTENT_MD5. EmptyCONTENT_*(FastCGI sends''on bodyless requests) counts as absent.Request::content()returns the raw body: the REST request's when hydrated, elsephp://input, read once and cached.setBody()now decodes fromcontent(), so the stream is not read twice.method()unslashes, sanitizes and upper-casesREQUEST_METHOD, returning''when absent (it read the raw global with noisset).contentType()reads the sanitizedContent-Typeand strips a case-insensitive leadingapplication/. Bug fix: it usedltrim($type, 'application/'), a character mask, soapplication/pdfbecamedfandtext/plainbecameext/plain.Requestsubclasses can override them (same contractRequestTestpins for the other accessors).StaticRouter
REQUEST_URIagainst/page/..., so onexample.com/blog(and with/index.php/permalinks) nothing matched and WordPress served a 404. Routes now matchWP::$request(home path andindex.phpalready 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 byredirect_canonical. The default staystemplate_redirect, which page-content routes (bit-connect's portal) need.maybeFlushRewriteRules()flushed (and rewrote.htaccessin admin) on every request whenever our rules never persisted. It now skips plain permalinks and retries at most every 5 minutes via a transient.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 emptiesrewrite_rules(row kept) so WordPress rebuilds it on the next request without them.RewriteRuleSetadded^page/?$for any route set; it is now added only for an empty-path route (Route::get('/')), so a non-pagepageslug is no longer hijacked.Tests
Tests landed before each implementation commit.
RequestTestcovers header lookup by name style, CONTENT_* exposure, defaults, the canonical map, sanitizedheader()/headers()vs exactrawHeader(), REST precedence,method()sanitizing/missing, andcontentType()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. AFakePhpInputStreamfixture replaces thephp://wrapper so the tests drive the realfile_get_contents('php://input')path. Thesanitize_text_fieldstub now mirrors core so sanitization is actually asserted.StaticRoutingTest/RewriteRuleSetTestcover subdirectory and/index.php/matching, home-path stripping (segment boundary, no WP object,template_redirect's''argument), default vsparse_requestdispatch (unmatched paths, method enforcement), plain-permalink skip, deactivation clearing (also afterinit), per-page retry windows, activation inside a window and the root rule; the new edge cases were each checked against a mutation ofStaticRouter.composer test(298), php-cs-fixer andcomposer compatpass.Needed by the PaymentForm plugin to move its
/paymentform/*endpoints ontoStaticRouter; it will usedispatchOnParseRequest()and readrawHeader()/content()for its token and HMAC checks.🤖 Generated with Claude Code