PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / includes / Utils / Response / ResponseNormalizer.php

ResponseNormalizer.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at includes/Utils/Response/ResponseNormalizer.php

763 lines 24.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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