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 / RestEnvelope.php

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

268 lines 8.8 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 WP_HTTP_Response;
6 use WP_REST_Request;
7
8 /**
9 * Applies the envelope to every Templately REST response (spec 043 / FR-001, FR-008a).
10 *
11 * It runs on `rest_post_dispatch`, which is the one place EVERY route in the
12 * namespace passes through — including the ones that still return a bare array
13 * or a legacy `WP_Error`. Doing it here rather than per-endpoint is what makes
14 * "one envelope" true by construction instead of by convention.
15 *
16 * The frontend counterpart is `unwrapEnvelope()` in `react-src/utils/api.ts`:
17 * it strips the success wrapper in the single transport every call goes through,
18 * so consumers still receive exactly the payload they received before the
19 * envelope existed. That pairing is what makes wrapping everything here safe.
20 *
21 * ## Padding (FR-008a)
22 *
23 * Some proxies/browsers mishandle very small JSON bodies, which is why the
24 * plugin used to staple 512 bytes of spaces into EVERY error's data bag. That
25 * padding is now applied here, once, only when the serialized body is under
26 * `PADDING_THRESHOLD` — and as a HEADER, so the body stays schema-valid and no
27 * `browser_padding` key leaks into the contract.
28 */
29 class RestEnvelope {
30
31 /**
32 * Bodies smaller than this get padded; larger ones never do.
33 */
34 const PADDING_THRESHOLD = 1024;
35
36 /**
37 * @return void
38 */
39 public static function init() {
40 add_filter( 'rest_post_dispatch', [ __CLASS__, 'filter_response' ], 10, 3 );
41 }
42
43 /**
44 * @param WP_HTTP_Response $result
45 * @param mixed $server
46 * @param WP_REST_Request $request
47 * @return WP_HTTP_Response
48 */
49 public static function filter_response( $result, $server, $request ) {
50 if ( ! $result instanceof WP_HTTP_Response || ! $request instanceof WP_REST_Request ) {
51 return $result;
52 }
53
54 if ( ! self::owns_route( $request ) ) {
55 return $result;
56 }
57
58 return self::apply( $result );
59 }
60
61 /**
62 * Wrap one response. Extracted from the filter so tests can exercise it
63 * without standing up a dispatcher.
64 *
65 * @param WP_HTTP_Response $result
66 * @return WP_HTTP_Response
67 */
68 public static function apply( WP_HTTP_Response $result ) {
69 $data = $result->get_data();
70 $status = (int) $result->get_status();
71 $envelope = self::to_envelope( $data, $status );
72
73 if ( Envelope::is_error( $envelope ) ) {
74 // `data.status` is only guaranteed on envelopes this class builds from
75 // a WP_Error shape. An error envelope constructed anywhere else — or a
76 // foreign `success:false` payload that reached us — has no such key,
77 // and reading it blind emitted a pair of PHP warnings on every request
78 // that took this path ("Undefined array key \"data\"", then "Trying to
79 // access array offset on null"). Fall back to the response's own
80 // status rather than assuming a shape we did not build.
81 $error_status = isset( $envelope['data']['status'] ) ? (int) $envelope['data']['status'] : $status;
82 $result->set_status( $error_status >= 400 ? $error_status : 500 );
83 }
84
85 $result->set_data( $envelope );
86 self::maybe_pad( $result, $envelope );
87
88 return $result;
89 }
90
91 /**
92 * @param mixed $data
93 * @param int $status
94 * @return array
95 */
96 public static function to_envelope( $data, $status ) {
97 // Already an envelope (an endpoint built it directly) — leave it alone.
98 if ( self::is_envelope( $data ) ) {
99 return $data;
100 }
101
102 // The REST server has already flattened any WP_Error into
103 // `{ code, message, data: { status } }` by the time we see it.
104 if ( self::is_wp_error_shape( $data ) ) {
105 return Envelope::error( self::error_from_wp_error_shape( $data, $status ) );
106 }
107
108 if ( $status >= 400 ) {
109 $message = is_array( $data ) && isset( $data['message'] ) && is_string( $data['message'] )
110 ? $data['message']
111 : '';
112
113 return Envelope::error( self::error_for_status( $status, $message ) );
114 }
115
116 return Envelope::success( $data );
117 }
118
119 /**
120 * @param mixed $data
121 * @return bool
122 */
123 public static function is_envelope( $data ) {
124 return is_array( $data )
125 && array_key_exists( 'success', $data )
126 && is_bool( $data['success'] )
127 && ( $data['success'] ? array_key_exists( 'data', $data ) : isset( $data['code'], $data['message'] ) );
128 }
129
130 /**
131 * @param mixed $data
132 * @return bool
133 */
134 private static function is_wp_error_shape( $data ) {
135 return is_array( $data )
136 && isset( $data['code'], $data['message'] )
137 && is_string( $data['code'] )
138 && ! array_key_exists( 'success', $data );
139 }
140
141 /**
142 * @param array $data
143 * @param int $status
144 * @return TemplatelyError
145 */
146 private static function error_from_wp_error_shape( $data, $status ) {
147 $legacy = $data['code'];
148 $error_data = isset( $data['data'] ) && is_array( $data['data'] ) ? $data['data'] : [];
149 $http_status = isset( $error_data['status'] ) ? (int) $error_data['status'] : $status;
150 $code = ErrorCode::exists( $legacy ) ? $legacy : self::legacy_code( $legacy, $http_status );
151
152 $context = [];
153 if ( $code !== $legacy ) {
154 $context['legacy_code'] = $legacy;
155 }
156 if ( ! empty( $error_data['endpoint'] ) ) {
157 $context['endpoint'] = $error_data['endpoint'];
158 }
159
160 // The historical unconditional padding never belonged in the contract.
161 unset( $error_data['browser_padding'], $error_data['status'], $error_data['endpoint'] );
162
163 return new TemplatelyError( $code, $data['message'], [
164 'status' => $http_status ?: 500,
165 'fields' => isset( $error_data['fields'] ) ? $error_data['fields'] : [],
166 'context' => array_merge( $context, isset( $error_data['context'] ) && is_array( $error_data['context'] ) ? $error_data['context'] : [] ),
167 ] );
168 }
169
170 /**
171 * Map the pre-043 `WP_Error` vocabulary onto the registry. Anything
172 * unrecognised degrades by HTTP status rather than collapsing to a generic
173 * server error.
174 *
175 * @param string $legacy
176 * @param int $status
177 * @return string
178 */
179 private static function legacy_code( $legacy, $status ) {
180 $map = [
181 'invalid_api_key' => ErrorCode::AUTH_EXPIRED,
182 'rest_forbidden' => ErrorCode::AUTH_EXPIRED,
183 'rest_cookie_invalid_nonce' => ErrorCode::AUTH_EXPIRED,
184 'rest_no_route' => ErrorCode::NOT_FOUND,
185 'templately_graphql_error' => ErrorCode::SERVER_ERROR,
186 'templately_api_error' => ErrorCode::SERVER_ERROR,
187 'templately_http_error' => ErrorCode::SERVER_ERROR,
188 ];
189
190 if ( isset( $map[ $legacy ] ) ) {
191 return $map[ $legacy ];
192 }
193
194 return self::error_for_status( $status ?: 500, '' )->code();
195 }
196
197 /**
198 * @param int $status
199 * @param string $message
200 * @return TemplatelyError
201 */
202 private static function error_for_status( $status, $message ) {
203 $response = ResponseNormalizer::normalize(
204 [
205 'response' => [ 'code' => $status, 'message' => '' ],
206 'body' => wp_json_encode( [ 'status' => 'error', 'message' => $message ] ),
207 'headers' => [ 'content-type' => 'application/json' ],
208 ],
209 [ 'side_effects' => false ]
210 );
211
212 return $response->error();
213 }
214
215 /**
216 * FR-008a — pad only what is small enough to trip the browser bug, and pad
217 * in a header so the body stays exactly the contract shape.
218 *
219 * @param WP_HTTP_Response $result
220 * @param array $envelope
221 * @return void
222 */
223 private static function maybe_pad( WP_HTTP_Response $result, $envelope ) {
224 $serialized = wp_json_encode( $envelope );
225 $length = is_string( $serialized ) ? strlen( $serialized ) : self::PADDING_THRESHOLD;
226
227 if ( $length >= self::PADDING_THRESHOLD ) {
228 return;
229 }
230
231 $result->header( 'X-Templately-Padding', str_repeat( '.', self::PADDING_THRESHOLD - $length ) );
232 }
233
234 /**
235 * @param WP_REST_Request $request
236 * @return bool
237 */
238 private static function owns_route( WP_REST_Request $request ) {
239 if ( ! defined( 'TEMPLATELY_API_NAMESPACE' ) ) {
240 return false;
241 }
242
243 $route = (string) $request->get_route();
244 $owned = 0 === strpos( ltrim( $route, '/' ), TEMPLATELY_API_NAMESPACE );
245
246 /**
247 * Whether the envelope applies to this route.
248 *
249 * The envelope is the wire contract with OUR OWN frontend, which strips it
250 * again in `unwrapEnvelope()`. A route in this namespace that answers to a
251 * FOREIGN protocol — JSON-RPC, OAuth 2.1 — has its own mandated body shape
252 * and must opt out: wrapping it produces `{"success":true,"data":{…}}`,
253 * which no MCP or OAuth client can parse, and replaces RFC-required fields
254 * (`error`, `access_token`) with this vocabulary. It also drops error-data
255 * keys that later filters promote into headers, so `WWW-Authenticate` never
256 * reaches the wire.
257 *
258 * Opting out is the module's own call — core names no module. See
259 * `McpServer\Server\HttpTransport::exempt_protocol_routes()`.
260 *
261 * @param bool $owned Whether the envelope applies.
262 * @param string $route Route path, e.g. `/templately/v1/mcp`.
263 * @param WP_REST_Request $request The request being answered.
264 */
265 return (bool) apply_filters( 'templately_rest_envelope_owns_route', $owned, $route, $request );
266 }
267 }
268