PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / trunk
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution vtrunk
2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 1.7.2 All 33 releases
fluent-booking / app / Modules / MCP / Support / RestBridge.php

RestBridge.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution trunk, at app/Modules/MCP/Support/RestBridge.php

168 lines 5.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Modules\MCP\Support;
4
5 use FluentBooking\Framework\Support\Arr;
6
7 defined('ABSPATH') || exit;
8
9 /**
10 * Dispatches an MCP write through the plugin's own REST API.
11 *
12 * Configuration writes — event types, availability schedules — carry a lot of
13 * validation: slug uniqueness, timezone conversion of weekly hours, duration
14 * lists, host assignment rules, refusal to delete a schedule still in use.
15 * Reimplementing any of it in the MCP layer would create a second copy that
16 * drifts, and the first drift is a schedule an agent saved that the admin UI
17 * then refuses to load.
18 *
19 * So the writes go through `rest_do_request()` against the same
20 * `fluent-booking/v2` routes the admin SPA calls. The policy layer runs
21 * unchanged — verified: a host without permission gets 403 from an internal
22 * dispatch exactly as they would over HTTP — and so does every validation rule.
23 *
24 * Reads deliberately do NOT go through here. Admin responses are shaped for a
25 * UI and carry scaffolding an agent has no use for; paying for that in context
26 * on every call is the thing this whole design exists to avoid. Reads get
27 * hand-built projections instead.
28 *
29 * @since 2.2.6
30 */
31 class RestBridge
32 {
33 const NAMESPACE_PATH = '/fluent-booking/v2';
34
35 /**
36 * @param string $method GET|POST|PUT|DELETE
37 * @param string $route Path after the namespace, e.g. '/availability/3'.
38 * @param array $body Request parameters.
39 *
40 * @return array|\WP_Error The response data, or the failure translated into
41 * an MCP error the agent can act on.
42 */
43 public static function call($method, $route, $body = [])
44 {
45 // The framework's Controller::validate() only re-throws its
46 // ValidationException when REST_REQUEST is set; without it the
47 // exception is swallowed and the controller runs on with invalid data.
48 // MCP always serves over /wp-json/ so the constant is always there —
49 // but a silent validation bypass is not a failure to discover in
50 // production, so refuse loudly instead of writing unvalidated data.
51 if (!defined('REST_REQUEST') || !REST_REQUEST) {
52 return MCPHelper::error(
53 'not_a_rest_request',
54 __('Configuration writes are only available over the REST transport, because that is where validation runs.', 'fluent-booking')
55 );
56 }
57
58 $request = new \WP_REST_Request($method, self::NAMESPACE_PATH . $route);
59
60 foreach ($body as $key => $value) {
61 $request->set_param($key, $value);
62 }
63
64 // The plugin's controllers read the body for non-GET verbs; setting it
65 // as JSON keeps nested arrays (weekly schedules, booking fields) intact
66 // rather than flattening them the way a form-encoded body would.
67 if ($method !== 'GET') {
68 $request->set_header('content-type', 'application/json');
69 $request->set_body(wp_json_encode($body));
70 }
71
72 $response = rest_do_request($request);
73
74 if ($response->is_error()) {
75 return self::translateError($response);
76 }
77
78 return (array) $response->get_data();
79 }
80
81 /**
82 * Turn a REST failure into an MCP error whose message is the one the admin
83 * UI would have shown, so the agent gets the real reason rather than
84 * "request failed".
85 *
86 * @return \WP_Error
87 */
88 private static function translateError($response)
89 {
90 $status = $response->get_status();
91 $data = (array) $response->get_data();
92
93 // Three payload shapes reach here, and none of them is the other two:
94 //
95 // {message, errors} Controller::sendError()
96 // {code, message, data} a WP_Error, e.g. from a policy
97 // {field: {rule: message}} a 422 from the framework's validator
98 //
99 // WP_Error::as_error() only understands the second and warns on the
100 // others, so the payload is read directly.
101 $message = (string) Arr::get($data, 'message', '');
102 $fieldErrors = self::flattenFieldErrors($data);
103
104 if (!$message && $fieldErrors) {
105 // Lead with the real reasons rather than "the request failed" —
106 // an agent that is told "Event title field is required" fixes its
107 // call in one step.
108 $message = implode(' ', array_values($fieldErrors));
109 }
110
111 if (!$message) {
112 $message = __('The request could not be completed.', 'fluent-booking');
113 }
114
115 if ($status === 401 || $status === 403) {
116 return MCPHelper::error('permission_denied', $message);
117 }
118
119 if ($status === 404) {
120 return MCPHelper::error('not_found', $message);
121 }
122
123 return MCPHelper::error(
124 $status === 422 ? 'validation_failed' : 'request_failed',
125 $message,
126 $fieldErrors ? ['field_errors' => $fieldErrors] : []
127 );
128 }
129
130 /**
131 * Reduce whichever error shape arrived to `field => message`.
132 *
133 * @param array $data
134 *
135 * @return array
136 */
137 private static function flattenFieldErrors($data)
138 {
139 $errors = Arr::get($data, 'errors');
140
141 if (!is_array($errors)) {
142 // A bare validator payload: every key is a field, and `message` is
143 // the only key that is not.
144 $errors = $data;
145 unset($errors['message'], $errors['code'], $errors['data']);
146 }
147
148 $flat = [];
149
150 foreach ((array) $errors as $field => $messages) {
151 if (is_string($messages)) {
152 $flat[$field] = $messages;
153 continue;
154 }
155
156 if (is_array($messages)) {
157 $first = reset($messages);
158
159 if (is_string($first)) {
160 $flat[$field] = $first;
161 }
162 }
163 }
164
165 return $flat;
166 }
167 }
168