get_param()`; * in array mode it consults `$request['params'][$name]`. * In every mode the per-key default is returned when the * source value is `null` or absent. * * @param mixed $request Request object or array. * @param string $name Parameter name to look up. * @param mixed $default Default to return when the parameter * is absent or null. */ public function param( mixed $request, string $name, // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.defaultFound -- mirrors WordPress's get_param( $name, $default ) idiom; renaming would obscure the WP-aligned semantics. mixed $default = null ): mixed { if ( is_object( $request ) && method_exists( $request, 'get_param' ) ) { $value = $request->get_param( $name ); return $value ?? $default; } if ( is_array( $request ) ) { $params = $request['params'] ?? array(); if ( is_array( $params ) && array_key_exists( $name, $params ) ) { return $params[ $name ] ?? $default; } return $default; } return $default; } /** * Look up a parameter and coerce it to a non-empty trimmed * string, falling back to the supplied default otherwise. * * Returns the trimmed value when the underlying parameter is * a string that is non-empty after trimming; returns * `$default` when the parameter is null, not a string, or * empty after trimming. The return type is therefore the * caller-controlled `?string` rather than a forced empty * string. * * @param mixed $request Request object or array. * @param string $name Parameter name to look up. * @param string|null $default Default when the parameter is * absent / non-string / blank. */ public function paramString( mixed $request, string $name, // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.defaultFound -- mirrors WordPress's get_param( $name, $default ) idiom; renaming would obscure the WP-aligned semantics. ?string $default = null ): ?string { $value = $this->param( $request, $name ); if ( $value === null ) { return $default; } if ( ! is_string( $value ) ) { return $default; } $trimmed = trim( $value ); if ( $trimmed === '' ) { return $default; } return $trimmed; } /** * Look up a parameter and coerce it to an integer, falling * back to the supplied default otherwise. * * Accepts native ints verbatim and decimal-integer strings * (matching `^-?\d+$`); every other shape (floats, non-numeric * strings, arrays, booleans) returns `$default`. * * @param mixed $request Request object or array. * @param string $name Parameter name to look up. * @param int|null $default Default when the parameter is * absent / not int-coercible. */ public function paramInt( mixed $request, string $name, // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.defaultFound -- mirrors WordPress's get_param( $name, $default ) idiom; renaming would obscure the WP-aligned semantics. ?int $default = null ): ?int { $value = $this->param( $request, $name ); if ( $value === null ) { return $default; } if ( is_int( $value ) ) { return $value; } if ( is_string( $value ) && preg_match( '/\A-?\d+\z/', $value ) === 1 ) { return (int) $value; } return $default; } /** * Return the decoded JSON body of the request as an array. * * Always returns an array, never `null`: in object mode an * absent body (`get_json_params()` returning `null`) is * coerced to `array()`, and in array mode an absent * `'json'` key is coerced to `array()` as well. Callers can * therefore treat the return value uniformly with * `array_key_exists()` / `??` lookups. * * @param mixed $request Request object or array. * * @return array */ public function jsonBody( mixed $request ): array { if ( is_object( $request ) && method_exists( $request, 'get_json_params' ) ) { $json = $request->get_json_params(); return is_array( $json ) ? $json : array(); } if ( is_array( $request ) ) { $json = $request['json'] ?? array(); return is_array( $json ) ? $json : array(); } return array(); } /** * Look up a request header by name. * * Header name lookup is case-insensitive in both modes: in * object mode the duck-typed `get_header()` already performs * the case-folded lookup that `WP_REST_Request` exposes; in * array mode the adapter iterates `$request['headers']` and * compares `strtolower()` of each key against * `strtolower($name)` so test fixtures can use whichever * casing reads naturally. * * @param mixed $request Request object or array. * @param string $name Header name to look up. */ public function header( mixed $request, string $name ): ?string { if ( is_object( $request ) && method_exists( $request, 'get_header' ) ) { $value = $request->get_header( $name ); return is_string( $value ) ? $value : null; } if ( is_array( $request ) ) { $headers = $request['headers'] ?? array(); if ( ! is_array( $headers ) ) { return null; } $needle = strtolower( $name ); foreach ( $headers as $key => $value ) { if ( ! is_string( $key ) ) { continue; } if ( strtolower( $key ) === $needle ) { if ( $value === null ) { return null; } return is_string( $value ) ? $value : (string) $value; } } return null; } return null; } /** * Build the §3 / §4 {@see RequestContext} value object that * the controllers thread through their use cases and * response envelopes. * * Header projection rules (per `rest-api-spec.md` §3): * * - `X-Request-Id` is consulted; when present and matching * the §3 grammar `^[a-zA-Z0-9_-]{8,64}$` it is preserved * verbatim by constructing the {@see RequestContext} * directly so the inbound id round-trips into the * response envelope. When absent or invalid the adapter * falls through to {@see RequestContext::generate()}, * which synthesizes a fresh `req_` + 16 hex chars id. * * - `X-Correlation-Id` is consulted with the same grammar; * a valid value is forwarded as `$externalCorrelationId` * so it is stamped onto the resulting context, an absent * or invalid value is forwarded as `null` so the * correlation-id field is omitted from the response * envelope per §4.1. * * The `$userCapabilities` parameter is accepted in two * shapes for caller convenience and normalized to the single * shape {@see RequestContext} expects: * * - List shape (`array('edit_posts', 'manage_options')`): * each entry is treated as a capability token and mapped * to `array('edit_posts' => 'true', 'manage_options' => 'true')`. * * - Map shape (`array('edit_posts' => 'true')` already in * the {@see RequestContext} format): the value is * re-validated as the literal string `'true'` and * forwarded verbatim. * * Mixed-shape arrays are accepted: numeric keys are treated * as list entries, string keys are treated as map entries. * Every entry must ultimately resolve to a non-empty trimmed * capability token; an entry that does not raises * {@see InvalidArgumentException} so a typo'd capability * list fails loudly here rather than silently masking a * deny-by-default capability check downstream. * * @param mixed $request Request object or array. * @param int|null $userId WordPress user id, or null * when the request is anonymous. * @param array $userCapabilities Capability list or map. * * @throws InvalidArgumentException When `$userCapabilities` * carries an entry that is not * a non-empty trimmed string. */ public function buildRequestContext( mixed $request, ?int $userId, array $userCapabilities ): RequestContext { $externalRequestId = $this->validatedIdHeader( $request, 'X-Request-Id' ); $externalCorrelationId = $this->validatedIdHeader( $request, 'X-Correlation-Id' ); $capabilitiesMap = $this->normalizeCapabilities( $userCapabilities ); if ( $externalRequestId !== null ) { return new RequestContext( $externalRequestId, $externalCorrelationId, $userId, $capabilitiesMap ); } return RequestContext::generate( $userId, $capabilitiesMap, $externalCorrelationId ); } /** * Look up a request header and validate it against the §3 * id grammar; return null when absent or invalid. * * @param mixed $request Request object or array. * @param string $name Header name to look up. */ private function validatedIdHeader( mixed $request, string $name ): ?string { $value = $this->header( $request, $name ); if ( $value === null ) { return null; } if ( preg_match( self::ID_GRAMMAR, $value ) !== 1 ) { return null; } return $value; } /** * Normalize a list / map / mixed capability input to the * `array` shape {@see RequestContext} * expects. * * @param array $userCapabilities Capability list or map. * * @return array * * @throws InvalidArgumentException When an entry is not a * non-empty trimmed string. */ private function normalizeCapabilities( array $userCapabilities ): array { $normalized = array(); foreach ( $userCapabilities as $key => $value ) { if ( is_int( $key ) ) { $cap = $value; } else { $cap = $key; } if ( ! is_string( $cap ) ) { $message = sprintf( 'RestRequestAdapter capability tokens must be strings; got %s.', gettype( $cap ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $trimmed = trim( $cap ); if ( $trimmed === '' ) { $message = 'RestRequestAdapter capability tokens must be non-empty trimmed strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[ $trimmed ] = 'true'; } return $normalized; } }