| 1 |
<?php |
| 2 |
|
| 3 |
namespace Templately\Utils\Response; |
| 4 |
|
| 5 |
use Templately\Utils\Helper; |
| 6 |
use WP_Error; |
| 7 |
|
| 8 |
/** |
| 9 |
* The single mapper from "whatever the cloud sent" to one typed result (spec 043 / FR-003). |
| 10 |
* |
| 11 |
* The BRANCH ORDER below is itself the contract — see |
| 12 |
* `specs/043-core-api-response-contract/contracts/normalizer-resolution.md`. |
| 13 |
* The TypeScript half (`react-src/utils/errors/normalizeError.ts`) implements the |
| 14 |
* same order and MUST produce the same code for the same input. |
| 15 |
* |
| 16 |
* Invariants enforced here: |
| 17 |
* - INV-1 the version-gated dual contract (`statusText` vs legacy `extensions`) |
| 18 |
* resolves to the SAME code for the same logical failure. |
| 19 |
* - INV-2 an error is never represented as an empty array. |
| 20 |
* - INV-3 `file` / `line` / `trace` never reach the outward result. |
| 21 |
* - INV-4 message text is never read to choose a branch (the one exception is the |
| 22 |
* literal GraphQL sentinel `"validation"`, which is a token, not prose). |
| 23 |
* - INV-5 `Unauthorised` and `Unauthorized` reach the same code. |
| 24 |
* - INV-6 an aborted/expected cancellation is `info` and never a network error. |
| 25 |
*/ |
| 26 |
class ResponseNormalizer { |
| 27 |
|
| 28 |
/** |
| 29 |
* Upstream `statusText` vocabulary → registry code (FR-004 / FR-006). |
| 30 |
* |
| 31 |
* ONLY values observed live are listed. An unmapped value degrades to a |
| 32 |
* generic code with the raw text preserved in `context.status_text` — we |
| 33 |
* never guess a meaning we have not seen. |
| 34 |
*/ |
| 35 |
private static $status_text_map = [ |
| 36 |
'Unauthorised' => ErrorCode::AUTH_EXPIRED, |
| 37 |
'Unauthorized' => ErrorCode::AUTH_EXPIRED, |
| 38 |
'AgentKeyNotAllowed' => ErrorCode::AGENT_KEY_NOT_ALLOWED, |
| 39 |
'Unverified' => ErrorCode::NOT_VERIFIED, |
| 40 |
'Disabled' => ErrorCode::ACCOUNT_DISABLED, |
| 41 |
'SiteNotConnected' => ErrorCode::SITE_DISCONNECTED, |
| 42 |
'SiteUrlRequired' => ErrorCode::SITE_URL_REQUIRED, |
| 43 |
'LimitReached' => ErrorCode::LIMIT_REACHED, |
| 44 |
'SiteLimitExceeded' => ErrorCode::SITE_LIMIT_REACHED, |
| 45 |
]; |
| 46 |
|
| 47 |
/** |
| 48 |
* GraphQL field name → the code its nested `status:'error'` means. |
| 49 |
* |
| 50 |
* Keying off the FIELD (not the message) keeps INV-4 intact for the nested |
| 51 |
* body-error shape, which carries no `statusText` of its own. |
| 52 |
*/ |
| 53 |
private static $endpoint_error_map = [ |
| 54 |
'connectWithApiKey' => ErrorCode::INVALID_API_KEY, |
| 55 |
]; |
| 56 |
|
| 57 |
/** |
| 58 |
* Structured body FLAGS that identify an outcome on their own. |
| 59 |
* |
| 60 |
* These exist because some endpoints signal a specific condition with a |
| 61 |
* boolean rather than a `statusText` — `v2/feedback/store` answers a repeat |
| 62 |
* submission with `{hasFeedback:true, status:'error'}` at HTTP 400. Reading |
| 63 |
* the flag keeps INV-4 intact (we never parse the prose "Feedback already |
| 64 |
* submitted"), and it is the difference between telling the user their |
| 65 |
* feedback failed and telling them it was already recorded. |
| 66 |
*/ |
| 67 |
private static $body_flag_map = [ |
| 68 |
'hasFeedback' => ErrorCode::ALREADY_SUBMITTED, |
| 69 |
]; |
| 70 |
|
| 71 |
/** |
| 72 |
* Content types that are passed through untouched (FR-012 / branch 3). |
| 73 |
*/ |
| 74 |
private static $raw_content_types = [ |
| 75 |
'application/zip', |
| 76 |
'application/octet-stream', |
| 77 |
'application/x-zip-compressed', |
| 78 |
'application/xml', |
| 79 |
'text/xml', |
| 80 |
'text/plain', |
| 81 |
'text/csv', |
| 82 |
'image/', |
| 83 |
'video/', |
| 84 |
'audio/', |
| 85 |
]; |
| 86 |
|
| 87 |
/** |
| 88 |
* Normalize one `wp_remote_*` result. |
| 89 |
* |
| 90 |
* @param array|WP_Error $response Raw WP HTTP response, or a transport WP_Error. |
| 91 |
* @param array $options { |
| 92 |
* @type bool $raw Treat the body as opaque — never JSON-parse it. |
| 93 |
* @type string $endpoint GraphQL field name, used to unwrap `data.<field>`. |
| 94 |
* @type bool $side_effects Apply the verification/disconnection side-effects (default true). |
| 95 |
* @type bool $cancelled The caller aborted this request on purpose (branch 0). |
| 96 |
* @type bool $unwrap Extract the payload from its envelope (default true). |
| 97 |
* Pass false when the caller needs the WHOLE decoded |
| 98 |
* body — e.g. it reads sibling fields like `status` |
| 99 |
* or `credit_cost` that sit next to `data`. |
| 100 |
* } |
| 101 |
* @return RemoteResponse |
| 102 |
*/ |
| 103 |
public static function normalize( $response, $options = [] ) { |
| 104 |
$options = array_merge( [ |
| 105 |
'raw' => false, |
| 106 |
'endpoint' => '', |
| 107 |
'side_effects' => true, |
| 108 |
'cancelled' => false, |
| 109 |
'unwrap' => true, |
| 110 |
], (array) $options ); |
| 111 |
|
| 112 |
// ── Branch 0 — expected cancellation. MUST precede the transport branch |
| 113 |
// so a deliberate abort is never miscoded as a network failure (INV-6). |
| 114 |
if ( ! empty( $options['cancelled'] ) ) { |
| 115 |
return RemoteResponse::failure( |
| 116 |
new TemplatelyError( ErrorCode::CANCELLED, __( 'Request cancelled.', 'templately' ) ) |
| 117 |
); |
| 118 |
} |
| 119 |
|
| 120 |
// ── Branch 1 — transport failure. |
| 121 |
if ( is_wp_error( $response ) ) { |
| 122 |
return RemoteResponse::failure( self::from_transport_error( $response ) ); |
| 123 |
} |
| 124 |
|
| 125 |
$status = (int) wp_remote_retrieve_response_code( $response ); |
| 126 |
$body = (string) wp_remote_retrieve_body( $response ); |
| 127 |
$content_type = (string) wp_remote_retrieve_header( $response, 'content-type' ); |
| 128 |
|
| 129 |
// ── Branch 2 — side-effects. Applied to EVERY response (FR-011) and |
| 130 |
// never terminating: a verified-header or disconnection signal can ride |
| 131 |
// on a perfectly successful response. |
| 132 |
if ( ! empty( $options['side_effects'] ) ) { |
| 133 |
self::apply_side_effects( $response, $body, $options ); |
| 134 |
} |
| 135 |
|
| 136 |
// ── Branch 3 — raw / binary passthrough. No JSON parsing (FR-012). |
| 137 |
if ( ! empty( $options['raw'] ) || self::is_raw_content_type( $content_type ) ) { |
| 138 |
if ( $status >= 400 ) { |
| 139 |
return RemoteResponse::failure( self::from_http_status( $status, '' ), $status ); |
| 140 |
} |
| 141 |
return RemoteResponse::success( $body, $status ); |
| 142 |
} |
| 143 |
|
| 144 |
// ── Branch 4 — empty body where JSON was expected. |
| 145 |
if ( '' === trim( $body ) ) { |
| 146 |
return RemoteResponse::failure( |
| 147 |
new TemplatelyError( |
| 148 |
ErrorCode::EMPTY_RESPONSE, |
| 149 |
__( 'The server returned an empty response.', 'templately' ), |
| 150 |
[ 'status' => $status ?: 502 ] |
| 151 |
), |
| 152 |
$status |
| 153 |
); |
| 154 |
} |
| 155 |
|
| 156 |
// ── Branch 5 — HTML where JSON was expected (proxy / error page). |
| 157 |
if ( '<' === substr( ltrim( $body ), 0, 1 ) ) { |
| 158 |
return RemoteResponse::failure( |
| 159 |
new TemplatelyError( |
| 160 |
ErrorCode::SERVER_HTML_RESPONSE, |
| 161 |
__( 'The server returned an unexpected page instead of data.', 'templately' ), |
| 162 |
[ 'status' => $status ?: 502 ] |
| 163 |
), |
| 164 |
$status |
| 165 |
); |
| 166 |
} |
| 167 |
|
| 168 |
// ── Branch 6 — undecodable body. NEVER an empty array (INV-2). |
| 169 |
$decoded = json_decode( $body, true ); |
| 170 |
if ( JSON_ERROR_NONE !== json_last_error() ) { |
| 171 |
return RemoteResponse::failure( |
| 172 |
new TemplatelyError( |
| 173 |
ErrorCode::MALFORMED_JSON, |
| 174 |
__( 'The server response could not be read.', 'templately' ), |
| 175 |
[ 'status' => $status ?: 502 ] |
| 176 |
), |
| 177 |
$status |
| 178 |
); |
| 179 |
} |
| 180 |
|
| 181 |
if ( ! is_array( $decoded ) ) { |
| 182 |
// A scalar JSON body is a valid payload; there is nothing to dispatch on. |
| 183 |
return $status >= 400 |
| 184 |
? RemoteResponse::failure( self::from_http_status( $status, '' ), $status ) |
| 185 |
: RemoteResponse::success( $decoded, $status ); |
| 186 |
} |
| 187 |
|
| 188 |
// ── Branch 7 — framework debug-500. Stripped of file/line/trace (INV-3). |
| 189 |
if ( self::is_debug_exception( $decoded ) ) { |
| 190 |
return RemoteResponse::failure( self::from_debug_exception( $decoded, $status ), $status ); |
| 191 |
} |
| 192 |
|
| 193 |
// ── Branch 8 — shape dispatch. |
| 194 |
$error = self::dispatch_body_error( $decoded, $status, $options ); |
| 195 |
if ( $error instanceof TemplatelyError ) { |
| 196 |
return RemoteResponse::failure( self::with_retry_after( $error, $response ), $status ); |
| 197 |
} |
| 198 |
|
| 199 |
// ── Branch 9 — a success-shaped body riding a 4xx/5xx status. |
| 200 |
if ( $status >= 400 ) { |
| 201 |
$message = isset( $decoded['message'] ) && is_string( $decoded['message'] ) ? $decoded['message'] : ''; |
| 202 |
return RemoteResponse::failure( |
| 203 |
self::with_retry_after( self::from_http_status( $status, $message ), $response ), |
| 204 |
$status |
| 205 |
); |
| 206 |
} |
| 207 |
|
| 208 |
$payload = empty( $options['unwrap'] ) |
| 209 |
? $decoded |
| 210 |
: self::extract_payload( $decoded, $options['endpoint'] ); |
| 211 |
|
| 212 |
return RemoteResponse::success( $payload, $status ); |
| 213 |
} |
| 214 |
|
| 215 |
/** |
| 216 |
* Carry `Retry-After` into the context when the server sent one. |
| 217 |
* |
| 218 |
* Without it a rate-limited client can only guess, and guessing wrong is how |
| 219 |
* a 429 turns into a retry storm. The value is structured data in `context`, |
| 220 |
* never text baked into the message. |
| 221 |
* |
| 222 |
* @param TemplatelyError $error |
| 223 |
* @param array $response |
| 224 |
* @return TemplatelyError |
| 225 |
*/ |
| 226 |
private static function with_retry_after( TemplatelyError $error, $response ) { |
| 227 |
if ( ErrorCode::RATE_LIMITED !== $error->code() ) { |
| 228 |
return $error; |
| 229 |
} |
| 230 |
|
| 231 |
$retry_after = wp_remote_retrieve_header( $response, 'retry-after' ); |
| 232 |
if ( '' === (string) $retry_after || ! is_numeric( $retry_after ) ) { |
| 233 |
return $error; |
| 234 |
} |
| 235 |
|
| 236 |
$data = $error->data(); |
| 237 |
$data['context'] = isset( $data['context'] ) && is_array( $data['context'] ) ? $data['context'] : []; |
| 238 |
$data['context']['retry_after'] = (int) $retry_after; |
| 239 |
|
| 240 |
return new TemplatelyError( $error->code(), $error->message(), $data ); |
| 241 |
} |
| 242 |
|
| 243 |
/** |
| 244 |
* Branch 1 — classify a WP transport error. |
| 245 |
* |
| 246 |
* @param WP_Error $error |
| 247 |
* @return TemplatelyError |
| 248 |
*/ |
| 249 |
private static function from_transport_error( WP_Error $error ) { |
| 250 |
if ( $error instanceof TemplatelyError ) { |
| 251 |
return $error; |
| 252 |
} |
| 253 |
|
| 254 |
$message = (string) $error->get_error_message(); |
| 255 |
$code = ErrorCode::NETWORK_ERROR; |
| 256 |
|
| 257 |
// `http_request_failed` is WP's single bucket for every cURL failure, so |
| 258 |
// the timeout has to be recognised from cURL's own wording. This is the |
| 259 |
// one place a message is inspected, and only to REFINE a code that is |
| 260 |
// already correct — never to pick a branch (INV-4). |
| 261 |
if ( false !== stripos( $message, 'timed out' ) || false !== stripos( $message, 'timeout' ) ) { |
| 262 |
$code = ErrorCode::TIMEOUT; |
| 263 |
} |
| 264 |
|
| 265 |
return new TemplatelyError( $code, self::transport_message( $code ), [ |
| 266 |
'context' => [ 'legacy_code' => $error->get_error_code() ], |
| 267 |
] ); |
| 268 |
} |
| 269 |
|
| 270 |
/** |
| 271 |
* @param string $code |
| 272 |
* @return string |
| 273 |
*/ |
| 274 |
private static function transport_message( $code ) { |
| 275 |
if ( ErrorCode::TIMEOUT === $code ) { |
| 276 |
return __( 'The request timed out. Please try again.', 'templately' ); |
| 277 |
} |
| 278 |
|
| 279 |
return __( 'Could not reach the Templately server. Please check your connection and try again.', 'templately' ); |
| 280 |
} |
| 281 |
|
| 282 |
/** |
| 283 |
* Branch 2 — verification + disconnection, on every response (FR-011). |
| 284 |
* |
| 285 |
* Tolerant per D6: the header may be `true`, `1`, `yes`, `on`; empty or |
| 286 |
* absent means "no change" (NOT "unverified"). On the connect mutation the |
| 287 |
* flag arrives in the BODY instead of a header, so it is read there too. |
| 288 |
* |
| 289 |
* @param array $response |
| 290 |
* @param string $body |
| 291 |
* @param array $options |
| 292 |
* @return void |
| 293 |
*/ |
| 294 |
private static function apply_side_effects( $response, $body, $options ) { |
| 295 |
Helper::check_verification_header( $response ); |
| 296 |
|
| 297 |
$decoded = json_decode( $body, true ); |
| 298 |
if ( ! is_array( $decoded ) ) { |
| 299 |
return; |
| 300 |
} |
| 301 |
|
| 302 |
Helper::check_site_disconnection( $decoded ); |
| 303 |
|
| 304 |
// GraphQL carries the disconnection signal inside `errors[].extensions` |
| 305 |
// rather than as a top-level `statusText`. |
| 306 |
if ( self::graphql_signals_disconnection( $decoded ) ) { |
| 307 |
Helper::check_site_disconnection( [ |
| 308 |
'status' => 'error', |
| 309 |
'statusText' => 'SiteNotConnected', |
| 310 |
] ); |
| 311 |
} |
| 312 |
|
| 313 |
// Connect mutation: `data.<field>.user.is_verified`. |
| 314 |
$endpoint = isset( $options['endpoint'] ) ? $options['endpoint'] : ''; |
| 315 |
if ( $endpoint && ! empty( $decoded['data'][ $endpoint ]['user']['is_verified'] ) ) { |
| 316 |
Helper::mark_user_verified(); |
| 317 |
} |
| 318 |
} |
| 319 |
|
| 320 |
/** |
| 321 |
* @param array $decoded |
| 322 |
* @return bool |
| 323 |
*/ |
| 324 |
private static function graphql_signals_disconnection( $decoded ) { |
| 325 |
if ( empty( $decoded['errors'] ) || ! is_array( $decoded['errors'] ) ) { |
| 326 |
return false; |
| 327 |
} |
| 328 |
|
| 329 |
foreach ( $decoded['errors'] as $error ) { |
| 330 |
if ( ! is_array( $error ) ) { |
| 331 |
continue; |
| 332 |
} |
| 333 |
$status_text = isset( $error['extensions']['statusText'] ) ? $error['extensions']['statusText'] : ''; |
| 334 |
if ( 'SiteNotConnected' === $status_text ) { |
| 335 |
return true; |
| 336 |
} |
| 337 |
} |
| 338 |
|
| 339 |
return false; |
| 340 |
} |
| 341 |
|
| 342 |
/** |
| 343 |
* Branch 3 — is this body opaque to us? |
| 344 |
* |
| 345 |
* @param string $content_type |
| 346 |
* @return bool |
| 347 |
*/ |
| 348 |
private static function is_raw_content_type( $content_type ) { |
| 349 |
if ( '' === $content_type ) { |
| 350 |
return false; |
| 351 |
} |
| 352 |
|
| 353 |
$content_type = strtolower( $content_type ); |
| 354 |
foreach ( self::$raw_content_types as $raw ) { |
| 355 |
if ( false !== strpos( $content_type, $raw ) ) { |
| 356 |
return true; |
| 357 |
} |
| 358 |
} |
| 359 |
|
| 360 |
return false; |
| 361 |
} |
| 362 |
|
| 363 |
/** |
| 364 |
* Branch 7 — the Laravel/webonyx debug-500 shape. |
| 365 |
* |
| 366 |
* `{ message, exception, file, line, trace }` with NO `status` key. The |
| 367 |
* `status` exclusion is what separates it from a legitimate error body that |
| 368 |
* happens to carry a message. |
| 369 |
* |
| 370 |
* @param array $decoded |
| 371 |
* @return bool |
| 372 |
*/ |
| 373 |
private static function is_debug_exception( $decoded ) { |
| 374 |
return isset( $decoded['message'], $decoded['exception'] ) |
| 375 |
&& ! isset( $decoded['status'] ) |
| 376 |
&& ( isset( $decoded['file'] ) || isset( $decoded['line'] ) || isset( $decoded['trace'] ) ); |
| 377 |
} |
| 378 |
|
| 379 |
/** |
| 380 |
* Branch 7 — build the outward error, discarding every internal detail (INV-3 / FR-008). |
| 381 |
* |
| 382 |
* The upstream exception message, file, line and trace are logged |
| 383 |
* server-side and NEVER travel to the client. |
| 384 |
* |
| 385 |
* @param array $decoded |
| 386 |
* @param int $status |
| 387 |
* @return TemplatelyError |
| 388 |
*/ |
| 389 |
private static function from_debug_exception( $decoded, $status ) { |
| 390 |
Helper::log( |
| 391 |
'Upstream returned a debug exception: ' . ( isset( $decoded['exception'] ) ? $decoded['exception'] : 'unknown' ), |
| 392 |
'ResponseNormalizer', |
| 393 |
'error' |
| 394 |
); |
| 395 |
|
| 396 |
return new TemplatelyError( |
| 397 |
ErrorCode::SERVER_ERROR, |
| 398 |
__( 'Something went wrong on the Templately server. Please try again in a moment.', 'templately' ), |
| 399 |
[ 'status' => $status ?: 500 ] |
| 400 |
); |
| 401 |
} |
| 402 |
|
| 403 |
/** |
| 404 |
* Branch 8 — dispatch on the decoded body's shape. |
| 405 |
* |
| 406 |
* @param array $decoded |
| 407 |
* @param int $status |
| 408 |
* @param array $options |
| 409 |
* @return TemplatelyError|null null when the body carries no error signal. |
| 410 |
*/ |
| 411 |
private static function dispatch_body_error( $decoded, $status, $options ) { |
| 412 |
// 8a — the modern `statusText` contract. |
| 413 |
if ( ! empty( $decoded['statusText'] ) && is_string( $decoded['statusText'] ) ) { |
| 414 |
return self::from_status_text( |
| 415 |
$decoded['statusText'], |
| 416 |
isset( $decoded['message'] ) ? $decoded['message'] : '', |
| 417 |
$status |
| 418 |
); |
| 419 |
} |
| 420 |
|
| 421 |
// 8b — nested `data.<field>.status === 'error'` (GraphQL body-level error). |
| 422 |
$nested = self::find_nested_body_error( $decoded ); |
| 423 |
if ( null !== $nested ) { |
| 424 |
list( $field, $node ) = $nested; |
| 425 |
|
| 426 |
if ( ! empty( $node['statusText'] ) && is_string( $node['statusText'] ) ) { |
| 427 |
return self::from_status_text( $node['statusText'], $node['message'] ?? '', $status ); |
| 428 |
} |
| 429 |
|
| 430 |
$code = isset( self::$endpoint_error_map[ $field ] ) |
| 431 |
? self::$endpoint_error_map[ $field ] |
| 432 |
: ErrorCode::INVALID_REQUEST; |
| 433 |
|
| 434 |
return new TemplatelyError( $code, isset( $node['message'] ) ? $node['message'] : '', [ |
| 435 |
'context' => [ 'field' => $field ], |
| 436 |
] ); |
| 437 |
} |
| 438 |
|
| 439 |
// 8b-bis — a structured flag that names the outcome by itself. |
| 440 |
foreach ( self::$body_flag_map as $flag => $flag_code ) { |
| 441 |
if ( ! empty( $decoded[ $flag ] ) ) { |
| 442 |
return new TemplatelyError( |
| 443 |
$flag_code, |
| 444 |
isset( $decoded['message'] ) ? $decoded['message'] : '', |
| 445 |
[ 'context' => [ 'flag' => $flag ] ] |
| 446 |
); |
| 447 |
} |
| 448 |
} |
| 449 |
|
| 450 |
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) { |
| 451 |
// 8g — REST validation: `errors` is a FIELD MAP, not a GraphQL list. |
| 452 |
if ( ! self::is_list( $decoded['errors'] ) ) { |
| 453 |
return new TemplatelyError( |
| 454 |
ErrorCode::VALIDATION_FAILED, |
| 455 |
isset( $decoded['message'] ) ? $decoded['message'] : __( 'Validation failed.', 'templately' ), |
| 456 |
[ |
| 457 |
'status' => $status ?: 422, |
| 458 |
'fields' => self::normalize_fields( $decoded['errors'] ), |
| 459 |
] |
| 460 |
); |
| 461 |
} |
| 462 |
|
| 463 |
// 8d/8e/8f — GraphQL `errors[]`. |
| 464 |
return self::from_graphql_errors( $decoded['errors'], $status ); |
| 465 |
} |
| 466 |
|
| 467 |
// 8c — top-level `status:'error'` with no statusText. |
| 468 |
if ( isset( $decoded['status'] ) && 'error' === $decoded['status'] ) { |
| 469 |
$message = isset( $decoded['message'] ) ? $decoded['message'] : ''; |
| 470 |
return self::from_http_status( $status ?: 400, $message ); |
| 471 |
} |
| 472 |
|
| 473 |
return null; |
| 474 |
} |
| 475 |
|
| 476 |
/** |
| 477 |
* 8a — map upstream vocabulary onto the registry (FR-004 / FR-005 / FR-006). |
| 478 |
* |
| 479 |
* @param string $status_text |
| 480 |
* @param string $message |
| 481 |
* @param int $status |
| 482 |
* @return TemplatelyError |
| 483 |
*/ |
| 484 |
/** |
| 485 |
* The registry code an upstream `statusText` means, or `null` when the word |
| 486 |
* is not one we have observed. |
| 487 |
* |
| 488 |
* Exposed because a handler occasionally has to classify a `statusText` that |
| 489 |
* did NOT arrive as an error envelope — `Login::login()` gets HTTP 200 with a |
| 490 |
* `user` node that simply carries no `api_key`, so `normalize()` never sees a |
| 491 |
* failure at all. Reading the map here keeps the vocabulary in ONE place |
| 492 |
* rather than letting each such handler grow its own copy. |
| 493 |
* |
| 494 |
* @param string $status_text |
| 495 |
* @return string|null |
| 496 |
*/ |
| 497 |
public static function code_for_status_text( $status_text ) { |
| 498 |
if ( ! is_string( $status_text ) || '' === $status_text ) { |
| 499 |
return null; |
| 500 |
} |
| 501 |
|
| 502 |
return isset( self::$status_text_map[ $status_text ] ) ? self::$status_text_map[ $status_text ] : null; |
| 503 |
} |
| 504 |
|
| 505 |
private static function from_status_text( $status_text, $message, $status ) { |
| 506 |
$context = [ 'status_text' => $status_text ]; |
| 507 |
|
| 508 |
if ( ! isset( self::$status_text_map[ $status_text ] ) ) { |
| 509 |
// Degrade, don't misclassify — the raw value is preserved so a new |
| 510 |
// upstream vocabulary word is diagnosable without a plugin release. |
| 511 |
return new TemplatelyError( self::code_for_http_status( $status ?: 400 ), $message, [ |
| 512 |
'status' => $status ?: 400, |
| 513 |
'context' => $context, |
| 514 |
] ); |
| 515 |
} |
| 516 |
|
| 517 |
$code = self::$status_text_map[ $status_text ]; |
| 518 |
|
| 519 |
if ( ErrorCode::AUTH_EXPIRED === $code || ErrorCode::INVALID_API_KEY === $code ) { |
| 520 |
$context['redirect'] = 'sign-in'; |
| 521 |
} |
| 522 |
|
| 523 |
return new TemplatelyError( $code, $message, [ 'context' => $context ] ); |
| 524 |
} |
| 525 |
|
| 526 |
/** |
| 527 |
* 8b — locate a `data.<field>` node whose own `status` is `'error'`. |
| 528 |
* |
| 529 |
* @param array $decoded |
| 530 |
* @return array|null [ field, node ] |
| 531 |
*/ |
| 532 |
private static function find_nested_body_error( $decoded ) { |
| 533 |
if ( empty( $decoded['data'] ) || ! is_array( $decoded['data'] ) ) { |
| 534 |
return null; |
| 535 |
} |
| 536 |
|
| 537 |
foreach ( $decoded['data'] as $field => $node ) { |
| 538 |
if ( is_array( $node ) && isset( $node['status'] ) && 'error' === $node['status'] ) { |
| 539 |
return [ $field, $node ]; |
| 540 |
} |
| 541 |
} |
| 542 |
|
| 543 |
return null; |
| 544 |
} |
| 545 |
|
| 546 |
/** |
| 547 |
* 8d/8e/8f — the GraphQL `errors[]` list. |
| 548 |
* |
| 549 |
* @param array $errors |
| 550 |
* @param int $status |
| 551 |
* @return TemplatelyError |
| 552 |
*/ |
| 553 |
private static function from_graphql_errors( $errors, $status ) { |
| 554 |
$first = null; |
| 555 |
|
| 556 |
foreach ( $errors as $error ) { |
| 557 |
if ( ! is_array( $error ) ) { |
| 558 |
continue; |
| 559 |
} |
| 560 |
if ( null === $first ) { |
| 561 |
$first = $error; |
| 562 |
} |
| 563 |
|
| 564 |
// 8d — the `validation` sentinel carries per-field messages. |
| 565 |
if ( isset( $error['message'] ) && 'validation' === $error['message'] && ! empty( $error['extensions']['validation'] ) ) { |
| 566 |
return new TemplatelyError( |
| 567 |
ErrorCode::VALIDATION_FAILED, |
| 568 |
__( 'Validation failed.', 'templately' ), |
| 569 |
[ |
| 570 |
'status' => $status ?: 422, |
| 571 |
'fields' => self::normalize_fields( $error['extensions']['validation'] ), |
| 572 |
] |
| 573 |
); |
| 574 |
} |
| 575 |
|
| 576 |
// 8f — the legacy auth contract. Same code as the modern 8a path (INV-1). |
| 577 |
if ( ! empty( $error['extensions']['statusText'] ) ) { |
| 578 |
return self::from_status_text( |
| 579 |
$error['extensions']['statusText'], |
| 580 |
isset( $error['message'] ) ? $error['message'] : '', |
| 581 |
$status |
| 582 |
); |
| 583 |
} |
| 584 |
} |
| 585 |
|
| 586 |
if ( null === $first ) { |
| 587 |
return new TemplatelyError( ErrorCode::SERVER_ERROR, '', [ 'status' => $status ?: 500 ] ); |
| 588 |
} |
| 589 |
|
| 590 |
// 8e — webonyx leaks its own `file`/`line` in `extensions` when the |
| 591 |
// PLUGIN sent an invalid query. That is our bug, not the user's, so it |
| 592 |
// is fatal — and the internal path is dropped (INV-3). |
| 593 |
if ( isset( $first['extensions']['file'] ) || isset( $first['extensions']['line'] ) ) { |
| 594 |
Helper::log( 'Invalid GraphQL query sent by the plugin: ' . ( $first['message'] ?? '' ), 'ResponseNormalizer', 'error' ); |
| 595 |
|
| 596 |
return new TemplatelyError( |
| 597 |
ErrorCode::MALFORMED_QUERY, |
| 598 |
__( 'Templately could not complete this request due to an internal error. Please update the plugin or contact support.', 'templately' ), |
| 599 |
[ 'status' => $status ?: 500 ] |
| 600 |
); |
| 601 |
} |
| 602 |
|
| 603 |
// 8f — the legacy `extensions.code` + `extensions.status` shape. The |
| 604 |
// message is NOT read (INV-4), so every authorization-category failure |
| 605 |
// resolves to AUTH_EXPIRED — the same code the modern `statusText` |
| 606 |
// contract produces for the identical failure (INV-1). |
| 607 |
if ( isset( $first['extensions']['code'] ) ) { |
| 608 |
$code = self::code_for_http_status( (int) $first['extensions']['code'] ); |
| 609 |
|
| 610 |
return new TemplatelyError( $code, isset( $first['message'] ) ? $first['message'] : '', [ |
| 611 |
'context' => [ |
| 612 |
'legacy_code' => (int) $first['extensions']['code'], |
| 613 |
'category' => isset( $first['extensions']['category'] ) ? $first['extensions']['category'] : '', |
| 614 |
], |
| 615 |
] ); |
| 616 |
} |
| 617 |
|
| 618 |
return new TemplatelyError( |
| 619 |
self::code_for_http_status( $status ?: 500 ), |
| 620 |
isset( $first['message'] ) ? $first['message'] : '', |
| 621 |
[ 'status' => $status ?: 500 ] |
| 622 |
); |
| 623 |
} |
| 624 |
|
| 625 |
/** |
| 626 |
* Branch 9 / 8c — derive a code from the HTTP status alone. |
| 627 |
* |
| 628 |
* @param int $status |
| 629 |
* @param string $message |
| 630 |
* @return TemplatelyError |
| 631 |
*/ |
| 632 |
private static function from_http_status( $status, $message = '' ) { |
| 633 |
$code = self::code_for_http_status( $status ); |
| 634 |
|
| 635 |
if ( '' === trim( (string) $message ) ) { |
| 636 |
$message = self::default_message_for( $code ); |
| 637 |
} |
| 638 |
|
| 639 |
$data = [ 'status' => $status ]; |
| 640 |
|
| 641 |
if ( ErrorCode::AUTH_EXPIRED === $code ) { |
| 642 |
$data['context'] = [ 'redirect' => 'sign-in' ]; |
| 643 |
} |
| 644 |
|
| 645 |
return new TemplatelyError( $code, $message, $data ); |
| 646 |
} |
| 647 |
|
| 648 |
/** |
| 649 |
* The status → code table. Message-independent by construction (INV-4). |
| 650 |
* |
| 651 |
* @param int $status |
| 652 |
* @return string |
| 653 |
*/ |
| 654 |
private static function code_for_http_status( $status ) { |
| 655 |
$status = (int) $status; |
| 656 |
|
| 657 |
switch ( true ) { |
| 658 |
case 401 === $status: |
| 659 |
case 403 === $status: |
| 660 |
return ErrorCode::AUTH_EXPIRED; |
| 661 |
case 404 === $status: |
| 662 |
return ErrorCode::NOT_FOUND; |
| 663 |
case 409 === $status: |
| 664 |
return ErrorCode::LIMIT_REACHED; |
| 665 |
case 422 === $status: |
| 666 |
return ErrorCode::VALIDATION_FAILED; |
| 667 |
case 426 === $status: |
| 668 |
return ErrorCode::UPDATE_REQUIRED; |
| 669 |
case 429 === $status: |
| 670 |
return ErrorCode::RATE_LIMITED; |
| 671 |
case $status >= 500: |
| 672 |
return ErrorCode::SERVER_ERROR; |
| 673 |
case $status >= 400: |
| 674 |
default: |
| 675 |
// Below 400 the HTTP layer said "fine" while the body said |
| 676 |
// "error" — that is an application-level rejection, not a server |
| 677 |
// fault, so it must not be reported as one. |
| 678 |
return ErrorCode::INVALID_REQUEST; |
| 679 |
} |
| 680 |
} |
| 681 |
|
| 682 |
/** |
| 683 |
* @param string $code |
| 684 |
* @return string |
| 685 |
*/ |
| 686 |
private static function default_message_for( $code ) { |
| 687 |
// One source of wording — see ErrorCode::default_message(). |
| 688 |
return ErrorCode::default_message( $code ); |
| 689 |
} |
| 690 |
|
| 691 |
/** |
| 692 |
* Coerce any field-error shape into `{ field: [ string, … ] }` (FR-007). |
| 693 |
* |
| 694 |
* @param array $fields |
| 695 |
* @return array |
| 696 |
*/ |
| 697 |
private static function normalize_fields( $fields ) { |
| 698 |
if ( ! is_array( $fields ) ) { |
| 699 |
return []; |
| 700 |
} |
| 701 |
|
| 702 |
$normalized = []; |
| 703 |
foreach ( $fields as $field => $messages ) { |
| 704 |
$messages = is_array( $messages ) ? $messages : [ $messages ]; |
| 705 |
$clean = []; |
| 706 |
foreach ( $messages as $message ) { |
| 707 |
if ( is_scalar( $message ) ) { |
| 708 |
$clean[] = TemplatelyError::plain_text( (string) $message ); |
| 709 |
} |
| 710 |
} |
| 711 |
$normalized[ (string) $field ] = $clean; |
| 712 |
} |
| 713 |
|
| 714 |
return $normalized; |
| 715 |
} |
| 716 |
|
| 717 |
/** |
| 718 |
* Unwrap the successful payload. |
| 719 |
* |
| 720 |
* GraphQL nests it under `data.<field>`; the REST API under `data`. |
| 721 |
* |
| 722 |
* @param array $decoded |
| 723 |
* @param string $endpoint |
| 724 |
* @return mixed |
| 725 |
*/ |
| 726 |
private static function extract_payload( $decoded, $endpoint ) { |
| 727 |
if ( $endpoint ) { |
| 728 |
// GraphQL semantics: the payload is `data.<field>` and NOTHING else. |
| 729 |
// |
| 730 |
// When the queried field is absent, this returns `[]` — never the |
| 731 |
// sibling `data` object. Spec 006 settled that on 2026-07-02 and named |
| 732 |
// the alternative a defect: handing back `data` wholesale leaks the |
| 733 |
// other queried fields into a code path whose caller expects an empty |
| 734 |
// result, and the caller has no way to tell the two apart. |
| 735 |
if ( isset( $decoded['data'] ) && is_array( $decoded['data'] ) && array_key_exists( $endpoint, $decoded['data'] ) ) { |
| 736 |
return $decoded['data'][ $endpoint ]; |
| 737 |
} |
| 738 |
|
| 739 |
return []; |
| 740 |
} |
| 741 |
|
| 742 |
if ( isset( $decoded['status'] ) && 'success' === $decoded['status'] && array_key_exists( 'data', $decoded ) ) { |
| 743 |
return $decoded['data']; |
| 744 |
} |
| 745 |
|
| 746 |
return $decoded; |
| 747 |
} |
| 748 |
|
| 749 |
/** |
| 750 |
* PHP 7.4-safe `array_is_list()`. |
| 751 |
* |
| 752 |
* @param array $array |
| 753 |
* @return bool |
| 754 |
*/ |
| 755 |
private static function is_list( $array ) { |
| 756 |
if ( ! is_array( $array ) ) { |
| 757 |
return false; |
| 758 |
} |
| 759 |
|
| 760 |
return array_keys( $array ) === range( 0, count( $array ) - 1 ); |
| 761 |
} |
| 762 |
} |
| 763 |
|