| 1 |
<?php |
| 2 |
/** |
| 3 |
* Shared response normalization for template-driven catalogs. |
| 4 |
* |
| 5 |
* @package Booking Calendar |
| 6 |
* @since 11.6.0 |
| 7 |
*/ |
| 8 |
|
| 9 |
if ( ! defined( 'ABSPATH' ) ) { |
| 10 |
exit; |
| 11 |
} |
| 12 |
|
| 13 |
/** |
| 14 |
* Produce stable JSON-safe success, empty, and error response contracts. |
| 15 |
*/ |
| 16 |
final class WPBC_UI_Catalog_Response { |
| 17 |
|
| 18 |
/** |
| 19 |
* Supported browser response schema version. |
| 20 |
* |
| 21 |
* @var int |
| 22 |
*/ |
| 23 |
const SCHEMA_VERSION = 1; |
| 24 |
|
| 25 |
/** |
| 26 |
* Normalized response payload. |
| 27 |
* |
| 28 |
* @var array |
| 29 |
*/ |
| 30 |
private $response = array(); |
| 31 |
|
| 32 |
/** |
| 33 |
* Prevent direct construction; callers must use create() or create_empty(). |
| 34 |
* |
| 35 |
* @param array $response Normalized response payload. |
| 36 |
*/ |
| 37 |
private function __construct( $response ) { |
| 38 |
$this->response = $response; |
| 39 |
} |
| 40 |
|
| 41 |
/** |
| 42 |
* Create a normalized successful response. |
| 43 |
* |
| 44 |
* Domain providers may supply JSON-safe item arrays and optional response |
| 45 |
* sections, but cannot replace shared request, pagination, sorting, display, |
| 46 |
* schema, catalog, or request identifiers. |
| 47 |
* |
| 48 |
* @param string $catalog_id Registered catalog identifier. |
| 49 |
* @param WPBC_UI_Catalog_Request $request Validated shared request. |
| 50 |
* @param mixed $items Normalized provider item arrays. |
| 51 |
* @param mixed $response_values Optional normalized sections. |
| 52 |
* |
| 53 |
* @return WPBC_UI_Catalog_Response|WP_Error Normalized response or safe error. |
| 54 |
*/ |
| 55 |
public static function create( $catalog_id, $request, $items, $response_values = array() ) { |
| 56 |
$catalog_id = is_scalar( $catalog_id ) ? sanitize_key( (string) $catalog_id ) : ''; |
| 57 |
if ( '' === $catalog_id || ! $request instanceof WPBC_UI_Catalog_Request || $catalog_id !== $request->get_catalog_id() ) { |
| 58 |
return self::get_error( 'invalid_response_context', __( 'The catalog response context is invalid.', 'booking' ) ); |
| 59 |
} |
| 60 |
|
| 61 |
if ( ! is_array( $items ) || ! is_array( $response_values ) ) { |
| 62 |
return self::get_error( 'malformed_response', __( 'The catalog response is malformed.', 'booking' ) ); |
| 63 |
} |
| 64 |
|
| 65 |
$normalized_items = array(); |
| 66 |
foreach ( $items as $item ) { |
| 67 |
if ( ! is_array( $item ) ) { |
| 68 |
return self::get_error( 'malformed_item', __( 'A catalog response item is malformed.', 'booking' ) ); |
| 69 |
} |
| 70 |
|
| 71 |
$normalized_item = self::normalize_json_value( $item ); |
| 72 |
if ( is_wp_error( $normalized_item ) ) { |
| 73 |
return $normalized_item; |
| 74 |
} |
| 75 |
$normalized_items[] = $normalized_item; |
| 76 |
} |
| 77 |
|
| 78 |
$request_values = $request->to_array(); |
| 79 |
$page_number = $request_values['page_number']; |
| 80 |
$items_per_page = $request_values['items_per_page']; |
| 81 |
$pagination_values = isset( $response_values['pagination'] ) && is_array( $response_values['pagination'] ) |
| 82 |
? $response_values['pagination'] |
| 83 |
: array(); |
| 84 |
foreach ( array( 'total_items', 'page_item_count' ) as $pagination_key ) { |
| 85 |
if ( |
| 86 |
isset( $pagination_values[ $pagination_key ] ) |
| 87 |
&& ( |
| 88 |
! is_scalar( $pagination_values[ $pagination_key ] ) |
| 89 |
|| ! preg_match( '/^-?\d+$/', (string) $pagination_values[ $pagination_key ] ) |
| 90 |
) |
| 91 |
) { |
| 92 |
return self::get_error( 'malformed_pagination', __( 'The catalog pagination response is malformed.', 'booking' ) ); |
| 93 |
} |
| 94 |
} |
| 95 |
$total_items = isset( $pagination_values['total_items'] ) |
| 96 |
? max( 0, (int) $pagination_values['total_items'] ) |
| 97 |
: count( $normalized_items ); |
| 98 |
$page_item_count = isset( $pagination_values['page_item_count'] ) |
| 99 |
? max( 0, (int) $pagination_values['page_item_count'] ) |
| 100 |
: count( $normalized_items ); |
| 101 |
$total_pages = 0 === $total_items ? 0 : (int) ceil( $total_items / $items_per_page ); |
| 102 |
$items_from = 0 === $page_item_count ? 0 : ( ( $page_number - 1 ) * $items_per_page ) + 1; |
| 103 |
$items_to = 0 === $page_item_count ? 0 : min( $total_items, $items_from + $page_item_count - 1 ); |
| 104 |
$filters = array( 'search' => $request_values['search'] ); |
| 105 |
if ( |
| 106 |
$page_item_count > $items_per_page |
| 107 |
|| $page_item_count > $total_items |
| 108 |
|| ( ! empty( $normalized_items ) && 0 === $page_item_count ) |
| 109 |
|| ( empty( $normalized_items ) && 0 < $page_item_count ) |
| 110 |
|| ( 0 < $page_item_count && $items_from > $total_items ) |
| 111 |
) { |
| 112 |
return self::get_error( 'inconsistent_pagination', __( 'The catalog pagination response is inconsistent.', 'booking' ) ); |
| 113 |
} |
| 114 |
|
| 115 |
if ( isset( $response_values['filters'] ) ) { |
| 116 |
$normalized_filters = self::normalize_json_value( $response_values['filters'] ); |
| 117 |
if ( is_wp_error( $normalized_filters ) || ! is_array( $normalized_filters ) ) { |
| 118 |
return self::get_error( 'malformed_filters', __( 'The catalog filter response is malformed.', 'booking' ) ); |
| 119 |
} |
| 120 |
$filters = array_merge( $filters, $normalized_filters ); |
| 121 |
} |
| 122 |
|
| 123 |
$configuration = $request->get_configuration(); |
| 124 |
$features = isset( $configuration['features'] ) && is_array( $configuration['features'] ) ? $configuration['features'] : array(); |
| 125 |
$hierarchy_values = isset( $response_values['hierarchy'] ) ? self::normalize_json_value( $response_values['hierarchy'] ) : array(); |
| 126 |
if ( is_wp_error( $hierarchy_values ) ) { |
| 127 |
return self::get_error( 'malformed_hierarchy', __( 'The catalog hierarchy response is malformed.', 'booking' ) ); |
| 128 |
} |
| 129 |
$hierarchy = WPBC_UI_Catalog_Hierarchy::normalize_response( $hierarchy_values, ! empty( $features['hierarchy'] ) ); |
| 130 |
if ( is_wp_error( $hierarchy ) ) { |
| 131 |
return $hierarchy; |
| 132 |
} |
| 133 |
|
| 134 |
$normalized_items = WPBC_UI_Catalog_Hierarchy::normalize_items( $normalized_items, $hierarchy['enabled'] ); |
| 135 |
if ( is_wp_error( $normalized_items ) ) { |
| 136 |
return $normalized_items; |
| 137 |
} |
| 138 |
|
| 139 |
$capabilities = array(); |
| 140 |
if ( isset( $response_values['capabilities'] ) ) { |
| 141 |
if ( ! is_array( $response_values['capabilities'] ) ) { |
| 142 |
return self::get_error( 'malformed_capabilities', __( 'The catalog capability response is malformed.', 'booking' ) ); |
| 143 |
} |
| 144 |
foreach ( $response_values['capabilities'] as $capability_key => $is_allowed ) { |
| 145 |
$capability_key = is_scalar( $capability_key ) ? sanitize_key( (string) $capability_key ) : ''; |
| 146 |
if ( '' === $capability_key || ! is_bool( $is_allowed ) ) { |
| 147 |
return self::get_error( 'malformed_capabilities', __( 'The catalog capability response is malformed.', 'booking' ) ); |
| 148 |
} |
| 149 |
$capabilities[ $capability_key ] = $is_allowed; |
| 150 |
} |
| 151 |
} |
| 152 |
|
| 153 |
$messages = array(); |
| 154 |
if ( isset( $response_values['messages'] ) ) { |
| 155 |
if ( ! is_array( $response_values['messages'] ) ) { |
| 156 |
return self::get_error( 'malformed_messages', __( 'The catalog message response is malformed.', 'booking' ) ); |
| 157 |
} |
| 158 |
foreach ( $response_values['messages'] as $message ) { |
| 159 |
if ( ! is_scalar( $message ) ) { |
| 160 |
return self::get_error( 'malformed_messages', __( 'The catalog message response is malformed.', 'booking' ) ); |
| 161 |
} |
| 162 |
$messages[] = sanitize_text_field( (string) $message ); |
| 163 |
} |
| 164 |
} |
| 165 |
|
| 166 |
return new self( |
| 167 |
array( |
| 168 |
'schema_version' => self::SCHEMA_VERSION, |
| 169 |
'success' => true, |
| 170 |
'catalog_id' => $catalog_id, |
| 171 |
'request_id' => $request_values['request_id'], |
| 172 |
'items' => $normalized_items, |
| 173 |
'pagination' => array( |
| 174 |
'page_number' => $page_number, |
| 175 |
'items_per_page' => $items_per_page, |
| 176 |
'total_items' => $total_items, |
| 177 |
'page_item_count' => $page_item_count, |
| 178 |
'total_pages' => $total_pages, |
| 179 |
'items_from' => $items_from, |
| 180 |
'items_to' => $items_to, |
| 181 |
), |
| 182 |
'sorting' => array( |
| 183 |
'sort_by' => $request_values['sort_by'], |
| 184 |
'sort_order' => $request_values['sort_order'], |
| 185 |
), |
| 186 |
'filters' => $filters, |
| 187 |
'display' => array( |
| 188 |
'visible_columns' => $request_values['visible_columns'], |
| 189 |
'column_order' => $request_values['column_order'], |
| 190 |
'template_pack' => $request_values['template_pack'], |
| 191 |
), |
| 192 |
'hierarchy' => $hierarchy, |
| 193 |
'capabilities' => $capabilities, |
| 194 |
'messages' => $messages, |
| 195 |
'error' => null, |
| 196 |
) |
| 197 |
); |
| 198 |
} |
| 199 |
|
| 200 |
/** |
| 201 |
* Create the canonical empty response contract. |
| 202 |
* |
| 203 |
* @param string $catalog_id Registered catalog identifier. |
| 204 |
* @param WPBC_UI_Catalog_Request $request Validated shared request. |
| 205 |
* @param mixed $response_values Optional normalized sections. |
| 206 |
* |
| 207 |
* @return WPBC_UI_Catalog_Response|WP_Error Empty response or safe error. |
| 208 |
*/ |
| 209 |
public static function create_empty( $catalog_id, $request, $response_values = array() ) { |
| 210 |
if ( ! is_array( $response_values ) ) { |
| 211 |
return self::get_error( 'malformed_response', __( 'The catalog response is malformed.', 'booking' ) ); |
| 212 |
} |
| 213 |
|
| 214 |
$response_values['pagination'] = array( 'total_items' => 0 ); |
| 215 |
|
| 216 |
return self::create( $catalog_id, $request, array(), $response_values ); |
| 217 |
} |
| 218 |
|
| 219 |
/** |
| 220 |
* Create a stable non-sensitive browser error contract. |
| 221 |
* |
| 222 |
* @param string $catalog_id Catalog identifier. |
| 223 |
* @param mixed $request_id Client request sequence. |
| 224 |
* @param string $error_code Stable error code. |
| 225 |
* @param string $error_message Localized safe message. |
| 226 |
* @param bool $retryable Whether the browser may offer a retry. |
| 227 |
* |
| 228 |
* @return array<string,mixed> Normalized error payload. |
| 229 |
*/ |
| 230 |
public static function get_error_response( $catalog_id, $request_id, $error_code, $error_message, $retryable = false ) { |
| 231 |
$catalog_id = is_scalar( $catalog_id ) ? sanitize_key( (string) $catalog_id ) : ''; |
| 232 |
$request_id = is_scalar( $request_id ) && preg_match( '/^\d+$/', (string) $request_id ) ? (int) $request_id : 0; |
| 233 |
$error_code = is_scalar( $error_code ) ? sanitize_key( (string) $error_code ) : 'catalog_error'; |
| 234 |
|
| 235 |
return array( |
| 236 |
'schema_version' => self::SCHEMA_VERSION, |
| 237 |
'success' => false, |
| 238 |
'catalog_id' => $catalog_id, |
| 239 |
'request_id' => $request_id, |
| 240 |
'items' => array(), |
| 241 |
'error' => array( |
| 242 |
'code' => '' === $error_code ? 'catalog_error' : $error_code, |
| 243 |
'message' => is_scalar( $error_message ) ? sanitize_text_field( (string) $error_message ) : '', |
| 244 |
'retryable' => (bool) $retryable, |
| 245 |
), |
| 246 |
); |
| 247 |
} |
| 248 |
|
| 249 |
/** |
| 250 |
* Convert a WordPress error into the shared browser error contract. |
| 251 |
* |
| 252 |
* @param string $catalog_id Catalog identifier. |
| 253 |
* @param mixed $request_id Client request sequence. |
| 254 |
* @param WP_Error $error Safe WordPress error. |
| 255 |
* @param bool $retryable Whether the browser may offer a retry. |
| 256 |
* |
| 257 |
* @return array<string,mixed> Normalized error payload. |
| 258 |
*/ |
| 259 |
public static function from_wp_error( $catalog_id, $request_id, $error, $retryable = false ) { |
| 260 |
if ( ! is_wp_error( $error ) ) { |
| 261 |
$error = self::get_error( 'catalog_error', __( 'The catalog could not be loaded.', 'booking' ) ); |
| 262 |
} |
| 263 |
|
| 264 |
return self::get_error_response( $catalog_id, $request_id, $error->get_error_code(), $error->get_error_message(), $retryable ); |
| 265 |
} |
| 266 |
|
| 267 |
/** |
| 268 |
* Determine whether the normalized response contains no items. |
| 269 |
* |
| 270 |
* @return bool True for an empty successful response. |
| 271 |
*/ |
| 272 |
public function is_empty() { |
| 273 |
return empty( $this->response['items'] ); |
| 274 |
} |
| 275 |
|
| 276 |
/** |
| 277 |
* Export the normalized response payload. |
| 278 |
* |
| 279 |
* @return array<string,mixed> JSON-safe response payload. |
| 280 |
*/ |
| 281 |
public function to_array() { |
| 282 |
return $this->response; |
| 283 |
} |
| 284 |
|
| 285 |
/** |
| 286 |
* Recursively normalize JSON-safe scalar and array values. |
| 287 |
* |
| 288 |
* @param mixed $response_value Provider response value. |
| 289 |
* |
| 290 |
* @return mixed|WP_Error JSON-safe value or safe error. |
| 291 |
*/ |
| 292 |
private static function normalize_json_value( $response_value ) { |
| 293 |
if ( is_null( $response_value ) || is_string( $response_value ) || is_int( $response_value ) || is_float( $response_value ) || is_bool( $response_value ) ) { |
| 294 |
return $response_value; |
| 295 |
} |
| 296 |
|
| 297 |
if ( ! is_array( $response_value ) ) { |
| 298 |
return self::get_error( 'unsafe_response_value', __( 'The catalog response contains an unsupported value.', 'booking' ) ); |
| 299 |
} |
| 300 |
|
| 301 |
$normalized_value = array(); |
| 302 |
foreach ( $response_value as $response_key => $nested_value ) { |
| 303 |
$normalized_key = is_int( $response_key ) ? $response_key : sanitize_key( (string) $response_key ); |
| 304 |
if ( '' === $normalized_key ) { |
| 305 |
return self::get_error( 'unsafe_response_key', __( 'The catalog response contains an unsupported key.', 'booking' ) ); |
| 306 |
} |
| 307 |
|
| 308 |
$normalized_nested_value = self::normalize_json_value( $nested_value ); |
| 309 |
if ( is_wp_error( $normalized_nested_value ) ) { |
| 310 |
return $normalized_nested_value; |
| 311 |
} |
| 312 |
$normalized_value[ $normalized_key ] = $normalized_nested_value; |
| 313 |
} |
| 314 |
|
| 315 |
return $normalized_value; |
| 316 |
} |
| 317 |
|
| 318 |
/** |
| 319 |
* Create a namespaced, non-sensitive response error. |
| 320 |
* |
| 321 |
* @param string $error_code Short error code. |
| 322 |
* @param string $error_message User-facing error message. |
| 323 |
* |
| 324 |
* @return WP_Error Response error. |
| 325 |
*/ |
| 326 |
private static function get_error( $error_code, $error_message ) { |
| 327 |
return new WP_Error( 'wpbc_ui_catalog_' . sanitize_key( $error_code ), $error_message ); |
| 328 |
} |
| 329 |
} |
| 330 |
|