Implement Phase 10: REST API Endpoints (v0.10.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m10s

- Add complete REST API infrastructure under src/Api/
- ResponseFormatter for standardized responses
- RateLimiter with tiered limits (public 60/min, availability 30/min, booking 10/min, admin 120/min)
- AbstractController base class with common functionality
- BuildingsController: list, get, rooms endpoints
- RoomsController: list, get, availability, calendar, search endpoints
- BookingsController: CRUD + confirm/check-in/check-out status transitions
- GuestsController: list, get, search, booking history (admin only)
- ServicesController: list, get, calculate endpoints
- PricingController: calculate, seasons endpoints
- API settings tab with enable/disable toggles
- Comprehensive API documentation in README

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 21:24:40 +01:00
parent 87aa89b1a6
commit 81c97c31d7
15 changed files with 4447 additions and 7 deletions

View File

@@ -0,0 +1,382 @@
<?php
/**
* Abstract REST Controller
*
* Base class for all REST API controllers with common functionality.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\Api\RestApi;
use Magdev\WpBnb\Api\RateLimiter;
use Magdev\WpBnb\Api\ResponseFormatter;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
/**
* Abstract Controller class.
*/
abstract class AbstractController extends WP_REST_Controller {
/**
* API namespace.
*
* @var string
*/
protected $namespace = RestApi::NAMESPACE;
/**
* Rate limiter instance.
*
* @var RateLimiter
*/
protected RateLimiter $rate_limiter;
/**
* Response formatter instance.
*
* @var ResponseFormatter
*/
protected ResponseFormatter $formatter;
/**
* Constructor.
*/
public function __construct() {
$this->rate_limiter = new RateLimiter();
$this->formatter = new ResponseFormatter();
}
/**
* Check rate limit before processing request.
*
* @param WP_REST_Request $request Current request.
* @return WP_Error|null Error if rate limited, null otherwise.
*/
protected function check_rate_limit( WP_REST_Request $request ): ?WP_Error {
// Skip rate limiting if disabled.
if ( 'yes' !== get_option( 'wp_bnb_api_rate_limiting', 'yes' ) ) {
return null;
}
$identifier = $this->get_client_identifier( $request );
$endpoint = $request->get_route();
if ( ! $this->rate_limiter->check( $identifier, $endpoint ) ) {
return $this->formatter->rate_limit_error(
$this->rate_limiter->get_retry_after( $identifier, $endpoint )
);
}
return null;
}
/**
* Add rate limit headers to response.
*
* @param WP_REST_Response $response Current response.
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response Response with headers.
*/
protected function add_rate_limit_headers( WP_REST_Response $response, WP_REST_Request $request ): WP_REST_Response {
if ( 'yes' !== get_option( 'wp_bnb_api_rate_limiting', 'yes' ) ) {
return $response;
}
$identifier = $this->get_client_identifier( $request );
$endpoint = $request->get_route();
$info = $this->rate_limiter->get_rate_limit_info( $identifier, $endpoint );
$response->header( 'X-RateLimit-Limit', (string) $info['limit'] );
$response->header( 'X-RateLimit-Remaining', (string) $info['remaining'] );
$response->header( 'X-RateLimit-Reset', (string) $info['reset'] );
return $response;
}
/**
* Get client identifier for rate limiting.
*
* @param WP_REST_Request $request Current request.
* @return string Client identifier.
*/
protected function get_client_identifier( WP_REST_Request $request ): string {
// Use user ID if authenticated.
$user_id = get_current_user_id();
if ( $user_id > 0 ) {
return 'user_' . $user_id;
}
return 'ip_' . $this->get_client_ip();
}
/**
* Get client IP address.
*
* Supports proxies and Cloudflare.
*
* @return string Client IP address.
*/
protected function get_client_ip(): string {
$headers = array(
'HTTP_CF_CONNECTING_IP', // Cloudflare.
'HTTP_X_FORWARDED_FOR', // Proxy.
'HTTP_X_REAL_IP', // Nginx.
'REMOTE_ADDR',
);
foreach ( $headers as $header ) {
if ( ! empty( $_SERVER[ $header ] ) ) {
$ip = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
// Handle comma-separated list (X-Forwarded-For).
if ( str_contains( $ip, ',' ) ) {
$ip = trim( explode( ',', $ip )[0] );
}
return $ip;
}
}
return '127.0.0.1';
}
/**
* Validate date format (Y-m-d).
*
* @param string $date Date string.
* @return bool True if valid.
*/
protected function validate_date( string $date ): bool {
$d = \DateTimeImmutable::createFromFormat( 'Y-m-d', $date );
return $d && $d->format( 'Y-m-d' ) === $date;
}
/**
* Validate date is not in the past.
*
* @param string $date Date string (Y-m-d).
* @return bool True if date is today or future.
*/
protected function validate_future_date( string $date ): bool {
if ( ! $this->validate_date( $date ) ) {
return false;
}
$date_obj = \DateTimeImmutable::createFromFormat( 'Y-m-d', $date );
$today = new \DateTimeImmutable( 'today' );
return $date_obj >= $today;
}
/**
* Permission callback for public endpoints.
*
* @return bool Always true.
*/
public function public_permission(): bool {
return true;
}
/**
* Permission callback for authenticated endpoints.
*
* @return bool True if logged in.
*/
public function authenticated_permission(): bool {
return is_user_logged_in();
}
/**
* Permission callback for admin endpoints.
*
* @return bool True if user can edit posts.
*/
public function admin_permission(): bool {
return current_user_can( 'edit_posts' );
}
/**
* Permission callback for managing bookings.
*
* @return bool True if user can edit posts.
*/
public function manage_bookings_permission(): bool {
return current_user_can( 'edit_posts' );
}
/**
* Get pagination parameters from request.
*
* @param WP_REST_Request $request Current request.
* @return array{page: int, per_page: int, offset: int}
*/
protected function get_pagination_params( WP_REST_Request $request ): array {
$page = max( 1, (int) $request->get_param( 'page' ) ?: 1 );
$per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ?: 10 ) );
$offset = ( $page - 1 ) * $per_page;
return array(
'page' => $page,
'per_page' => $per_page,
'offset' => $offset,
);
}
/**
* Get sorting parameters from request.
*
* @param WP_REST_Request $request Current request.
* @param array $allowed_orderby Allowed orderby values.
* @param string $default_orderby Default orderby value.
* @return array{orderby: string, order: string}
*/
protected function get_sorting_params( WP_REST_Request $request, array $allowed_orderby = array( 'title', 'date' ), string $default_orderby = 'title' ): array {
$orderby = $request->get_param( 'orderby' ) ?: $default_orderby;
$order = strtoupper( $request->get_param( 'order' ) ?: 'ASC' );
// Validate orderby.
if ( ! in_array( $orderby, $allowed_orderby, true ) ) {
$orderby = $default_orderby;
}
// Validate order.
if ( ! in_array( $order, array( 'ASC', 'DESC' ), true ) ) {
$order = 'ASC';
}
return array(
'orderby' => $orderby,
'order' => $order,
);
}
/**
* Format post for API response.
*
* @param \WP_Post $post Post object.
* @return array Basic post data.
*/
protected function format_post_base( \WP_Post $post ): array {
return array(
'id' => $post->ID,
'title' => get_the_title( $post ),
'slug' => $post->post_name,
'excerpt' => get_the_excerpt( $post ),
'content' => apply_filters( 'the_content', $post->post_content ),
);
}
/**
* Format featured image for API response.
*
* @param int $post_id Post ID.
* @return array|null Image data or null.
*/
protected function format_featured_image( int $post_id ): ?array {
$thumbnail_id = get_post_thumbnail_id( $post_id );
if ( ! $thumbnail_id ) {
return null;
}
return $this->format_image( $thumbnail_id );
}
/**
* Format image attachment for API response.
*
* @param int $attachment_id Attachment ID.
* @return array|null Image data or null.
*/
protected function format_image( int $attachment_id ): ?array {
$full = wp_get_attachment_image_src( $attachment_id, 'full' );
if ( ! $full ) {
return null;
}
$sizes = array();
foreach ( array( 'thumbnail', 'medium', 'large' ) as $size ) {
$src = wp_get_attachment_image_src( $attachment_id, $size );
if ( $src ) {
$sizes[ $size ] = array(
'url' => $src[0],
'width' => $src[1],
'height' => $src[2],
);
}
}
return array(
'id' => $attachment_id,
'url' => $full[0],
'width' => $full[1],
'height' => $full[2],
'alt' => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
'sizes' => $sizes,
);
}
/**
* Add HATEOAS links to response item.
*
* @param array $item Response item.
* @param string $route Base route for self link.
* @param int $id Item ID.
* @return array Item with _links.
*/
protected function add_links( array $item, string $route, int $id ): array {
$item['_links'] = array(
'self' => array(
array(
'href' => rest_url( $this->namespace . '/' . $route . '/' . $id ),
),
),
);
return $item;
}
/**
* Get common collection parameters for schema.
*
* @return array Collection parameters.
*/
public function get_collection_params(): array {
return array(
'page' => array(
'description' => __( 'Current page of the collection.', 'wp-bnb' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
),
'per_page' => array(
'description' => __( 'Maximum number of items to be returned per page.', 'wp-bnb' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
),
'search' => array(
'description' => __( 'Limit results to those matching a string.', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'orderby' => array(
'description' => __( 'Sort collection by attribute.', 'wp-bnb' ),
'type' => 'string',
'default' => 'title',
'sanitize_callback' => 'sanitize_text_field',
),
'order' => array(
'description' => __( 'Order sort attribute ascending or descending.', 'wp-bnb' ),
'type' => 'string',
'default' => 'asc',
'enum' => array( 'asc', 'desc' ),
'sanitize_callback' => 'sanitize_text_field',
),
);
}
}