<?php /** * Base REST Controller * * @package LifterLMS_REST/Abstracts * * @since 1.0.0-beta.1 * @version 1.0.0-beta.14 */ defined( 'ABSPATH' ) || exit; /** * LLMS_REST_Controller class * * @since 1.0.0-beta.1 * @since 1.0.0-beta.3 Fix an issue displaying a last page for lists with 0 possible results & handle error conditions early in responses. * @since 1.0.0-beta.7 Break `get_items()` method into `prepare_collection_query_args()`, `prepare_args_for_total_count_query()`, * `prepare_collection_items_for_response()` and `add_header_pagination()` methods so to improve abstraction. * `prepare_objects_query()` renamed to `prepare_collection_query_args()`. * @since 1.0.0-beta.12 Added logic to perform a collection search. * Added `object_inserted()` and `object_completely_inserted()` methods called after an object is * respectively inserted in the DB and all its additional fields have been updated as well (completely inserted). * @since 1.0.0-beta.14 Update `prepare_links()` to accept a second parameter, `WP_REST_Request`. */ abstract class LLMS_REST_Controller extends LLMS_REST_Controller_Stubs { /** * Endpoint namespace. * * @var string */ protected $namespace = 'llms/v1'; /** * Schema properties available for ordering the collection. * * @var string[] */ protected $orderby_properties = array( 'id', ); /** * Whether search is allowed * * @var boolean */ protected $is_searchable = false; /** * Create an item. * * @since 1.0.0-beta.1 * @since 1.0.0-beta.12 Call `object_inserted` and `object_completely_inserted` after an object is * respectively inserted in the DB and all its additional fields have been * updated as well (completely inserted). * * @param WP_REST_Request $request Request object. * @return WP_Error|WP_REST_Response */ public function create_item( $request ) { if ( ! empty( $request['id'] ) ) { return llms_rest_bad_request_error( __( 'Cannot create an existing resource.', 'lifterlms' ) ); } $item = $this->prepare_item_for_database( $request ); $object = $this->create_object( $item, $request ); $schema = $this->get_item_schema(); if ( is_wp_error( $object ) ) { return $object; } $this->object_inserted( $object, $request, $schema, true ); $fields_update = $this->update_additional_fields_for_object( $item, $request ); if ( is_wp_error( $fields_update ) ) { return $fields_update; } $this->object_completely_inserted( $object, $request, $schema, true ); $request->set_param( 'context', 'edit' ); $response = $this->prepare_item_for_response( $object, $request ); $response = rest_ensure_response( $response ); $response->set_status( 201 ); $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $this->get_object_id( $object ) ) ) ); return $response; } /** * Called right after a resource is inserted (created/updated). * * @since 1.0.0-beta.12 * * @param object $object Inserted or updated object. * @param WP_REST_Request $request Request object. * @param array $schema The item schema. * @param bool $creating True when creating a post, false when updating. */ protected function object_inserted( $object, $request, $schema, $creating ) { $type = $this->get_object_type(); /** * Fires after a single llms resource is created or updated via the REST API. * * The dynamic portion of the hook name, `$type`, refers to the object type this controller is responsible for managing. * * @since 1.0.0-beta.12 * * @param object $object Inserted or updated object. * @param WP_REST_Request $request Request object. * @param array $schema The item schema. * @param bool $creating True when creating a post, false when updating. */ do_action( "llms_rest_insert_{$type}", $object, $request, $schema, $creating ); } /** * Called right after a resource is completely inserted (created/updated). * * @since 1.0.0-beta.12 * * @param LLMS_Post $object Inserted or updated object. * @param WP_REST_Request $request Request object. * @param array $schema The item schema. * @param bool $creating True when creating a post, false when updating. */ protected function object_completely_inserted( $object, $request, $schema, $creating ) { $type = $this->get_object_type(); /** * Fires after a single llms resource is completely created or updated via the REST API. * * The dynamic portion of the hook name, `$type`, refers to the object type this controller is responsible for managing. * * @since 1.0.0-beta.12 * * @param object $object Inserted or updated object. * @param WP_REST_Request $request Request object. * @param array $schema The item schema. * @param bool $creating True when creating a post, false when updating. */ do_action( "llms_rest_after_insert_{$type}", $object, $request, $schema, $creating ); } /** * Delete the item. * * @since 1.0.0-beta.1 * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function delete_item( $request ) { $object = $this->get_object( $request['id'], false ); // We don't return 404s for items that are not found. if ( ! is_wp_error( $object ) ) { // If there was an error deleting the object return the error. If the error is that the object doesn't exist return 204 below! $del = $this->delete_object( $object, $request ); if ( is_wp_error( $del ) ) { return $del; } } $response = rest_ensure_response( null ); $response->set_status( 204 ); return $response; } /** * Retrieves the query params for the objects collection. * * @since 1.0.0-beta.1 * @since 1.0.0-beta.12 Added `search_columns` collection param for searchable resources. * * @return array Collection parameters. */ public function get_collection_params() { $query_params = parent::get_collection_params(); $query_params['context']['default'] = 'view'; // We're not currently implementing searching for all of our controllers. if ( empty( $this->is_searchable ) ) { unset( $query_params['search'] ); } elseif ( ! empty( $this->search_columns_mapping ) ) { $search_columns = array_keys( $this->search_columns_mapping ); $query_params['search_columns'] = array( 'description' => __( 'Column names to be searched. Accepts a single column or a comma separated list of columns.', 'lifterlms' ), 'type' => 'array', 'items' => array( 'type' => 'string', 'enum' => $search_columns, ), 'default' => $search_columns, ); } // page and per_page params are already specified in WP_Rest_Controller->get_collection_params(). $query_params['order'] = array( 'description' => __( 'Order sort attribute ascending or descending.', 'lifterlms' ), 'type' => 'string', 'default' => 'asc', 'enum' => array( 'asc', 'desc' ), 'validate_callback' => 'rest_validate_request_arg', ); $query_params['orderby'] = array( 'description' => __( 'Sort collection by object attribute.', 'lifterlms' ), 'type' => 'string', 'default' => $this->orderby_properties[0], 'enum' => $this->orderby_properties, 'validate_callback' => 'rest_validate_request_arg', ); $query_params['include'] = array( 'description' => __( 'Limit results to a list of ids. Accepts a single id or a comma separated list of ids.', 'lifterlms' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'validate_callback' => 'rest_validate_request_arg', ); $query_params['exclude'] = array( 'description' => __( 'Exclude a list of ids from results. Accepts a single id or a comma separated list of ids.', 'lifterlms' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'validate_callback' => 'rest_validate_request_arg', ); return $query_params; } /** * Get a single item. * * @since 1.0.0-beta.1 * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|WP_REST_Response */ public function get_item( $request ) { $object = $this->get_object( (int) $request['id'] ); if ( is_wp_error( $object ) ) { return $object; } $response = $this->prepare_item_for_response( $object, $request ); return rest_ensure_response( $response ); } /** * Retrieves all items * * @since 1.0.0-beta.1 * @since 1.0.0-beta.3 Fix an issue displaying a last page for lists with 0 possible results. * @since 1.0.0-beta.7 Broken into several methods so to improve abstraction. * @since 1.0.0-beta.12 Return early if `prepare_collection_query_args()` is a `WP_Error`. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { $prepared = $this->prepare_collection_query_args( $request ); if ( is_wp_error( $prepared ) ) { return $prepared; } $query = $this->get_objects_query( $prepared, $request ); $pagination = $this->get_pagination_data_from_query( $query, $prepared, $request ); // Out-of-bounds, run the query again on page one to get a proper total count. if ( $pagination['total_results'] < 1 ) { $prepared_for_total_count = $this->prepare_args_for_total_count_query( $prepared, $request ); $count_query = $this->get_objects_query( $prepared_for_total_count, $request ); $count_results = $this->get_pagination_data_from_query( $count_query, $prepared_for_total_count, $request ); $pagination['total_results'] = $count_results['total_results']; } if ( $pagination['current_page'] > $pagination['total_pages'] && $pagination['total_results'] > 0 ) { return llms_rest_bad_request_error( __( 'The page number requested is larger than the number of pages available.', 'lifterlms' ) ); } $objects = $this->get_objects_from_query( $query ); $items = $this->prepare_collection_items_for_response( $objects, $request ); $response = rest_ensure_response( $items ); $response = $this->add_header_pagination( $response, $pagination, $request ); return $response; } /** * Format query arguments to retrieve a collection of objects. * * @since 1.0.0-beta.7 * @since 1.0.0-beta.12 Prepare args for search and call collection params to query args map method. * * @param WP_REST_Request $request Full details about the request. * @return array|WP_Error */ protected function prepare_collection_query_args( $request ) { // Prepare all set args. $registered = $this->get_collection_params(); $prepared = array(); foreach ( $registered as $key => $value ) { if ( isset( $request[ $key ] ) ) { $prepared[ $key ] = $request[ $key ]; } } $prepared = $this->prepare_collection_query_search_args( $prepared, $request ); if ( is_wp_error( $prepared ) ) { return $prepared; } $prepared = $this->map_params_to_query_args( $prepared, $registered, $request ); return $prepared; } /** * Map schema to query arguments to retrieve a collection of objects. * * @since 1.0.0-beta.12 * * @param array $prepared Array of collection arguments. * @param array $registered Registered collection params. * @param WP_REST_Request $request Full details about the request. * @return array|WP_Error */ protected function map_params_to_query_args( $prepared, $registered, $request ) { return $prepared; } /** * Format search query arguments to retrieve a collection of objects. * * @since 1.0.0-beta.12 * @since 1.0.0-beta.21 Return an error if requesting a list ordered by 'relevance' without providing a search string. * * @param array $prepared Array of collection arguments. * @param WP_REST_Request $request Request object. * @return array|WP_Error */ protected function prepare_collection_query_search_args( $prepared, $request ) { // Search? if ( ! empty( $prepared['search'] ) ) { if ( ! empty( $this->search_columns_mapping ) ) { if ( empty( $prepared['search_columns'] ) ) { return llms_rest_bad_request_error( __( 'You must provide a valid set of columns to search into.', 'lifterlms' ) ); } // Filter search columns by context. $search_columns = array_keys( $this->filter_response_by_context( array_flip( $prepared['search_columns'] ), $request['context'] ) ); // Check if one of more unallowed search columns have been provided as request query params (not merged with defaults). if ( ! empty( $request->get_query_params()['search_columns'] ) ) { $forbidden_columns = array_diff( $prepared['search_columns'], $search_columns ); if ( ! empty( $forbidden_columns ) ) { return llms_rest_authorization_required_error( sprintf( // Translators: %1$s comma separated list of search columns. __( 'You are not allowed to search into the provided column(s): %1$s', 'lifterlms' ), implode( ',', $forbidden_columns ) ) ); } } $prepared['search_columns'] = array(); // Map our search columns into query compatible ones. foreach ( $search_columns as $search_column ) { if ( isset( $this->search_columns_mapping[ $search_column ] ) ) { $prepared['search_columns'][] = $this->search_columns_mapping[ $search_column ]; } } if ( empty( $prepared['search_columns'] ) ) { return llms_rest_bad_request_error( __( 'You must provide a valid set of columns to search into.', 'lifterlms' ) ); } } $prepared['search'] = '*' . $prepared['search'] . '*'; } else { // Ensure a search string is set in case the orderby is set to 'relevance'. if ( ! empty( $request['orderby'] ) && 'relevance' === $request['orderby'] ) { return llms_rest_bad_request_error( __( 'You need to define a search term to order by relevance.', 'lifterlms' ) ); } } return $prepared; } /** * Prepare query args for total count query. * * @since 1.0.0-beta.7 * * @param array $args Array of query args. * @param WP_REST_Request $request Full details about the request. * @return array */ protected function prepare_args_for_total_count_query( $args, $request ) { // Run the query again without pagination to get a proper total count. unset( $args['paged'], $args['page'] ); return $args; } /** * Prepare collection items for response. * * @since 1.0.0-beta.7 * * @param array $objects Array of objects to be prepared for response. * @param WP_REST_Request $request Full details about the request. * @return array */ protected function prepare_collection_items_for_response( $objects, $request ) { $items = array(); foreach ( $objects as $object ) { $object = $this->get_object( $object, false ); if ( ! $this->check_read_object_permissions( $object ) ) { continue; } $item = $this->prepare_item_for_response( $object, $request ); if ( ! is_wp_error( $item ) ) { $items[] = $this->prepare_response_for_collection( $item ); } } return $items; } /** * Add pagination info and links to the response header. * * @since 1.0.0-beta.7 * * @param WP_REST_Response $response Current response being served. * @param array $pagination Pagination array. * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response */ protected function add_header_pagination( $response, $pagination, $request ) { $response->header( 'X-WP-Total', $pagination['total_results'] ); $response->header( 'X-WP-TotalPages', $pagination['total_pages'] ); $base = add_query_arg( urlencode_deep( $request->get_query_params() ), rest_url( $request->get_route() ) ); // First page link. if ( 1 !== $pagination['current_page'] ) { $first_link = add_query_arg( 'page', 1, $base ); $response->link_header( 'first', $first_link ); } // Previous page link. if ( $pagination['current_page'] > 1 ) { $prev_page = $pagination['current_page'] - 1; if ( $prev_page > $pagination['total_pages'] ) { $prev_page = $pagination['total_pages']; } $prev_link = add_query_arg( 'page', $prev_page, $base ); $response->link_header( 'prev', $prev_link ); } // Next page link. if ( $pagination['total_pages'] > $pagination['current_page'] ) { $next_link = add_query_arg( 'page', $pagination['current_page'] + 1, $base ); $response->link_header( 'next', $next_link ); } // Last page link. if ( $pagination['total_pages'] && $pagination['total_pages'] !== $pagination['current_page'] ) { $last_link = add_query_arg( 'page', $pagination['total_pages'], $base ); $response->link_header( 'last', $last_link ); } return $response; } /** * Retrieves the query params for retrieving a single resource. * * @since 1.0.0-beta.1 * * @return array */ public function get_get_item_params() { return array( 'context' => $this->get_context_param( array( 'default' => 'view', ) ), ); } /** * Retrieve arguments for deleting a resource. * * @since 1.0.0-beta.1 * * @return array */ public function get_delete_item_args() { return array(); } /** * Map request keys to database keys for insertion. * * Array keys are the request fields (as defined in the schema) and * array values are the database fields. * * @since 1.0.0-beta.1 * * @return array */ protected function map_schema_to_database() { $schema = $this->get_item_schema(); $keys = array_keys( $schema['properties'] ); return array_combine( $keys, $keys ); } /** * Prepare request arguments for a database insert/update. * * @since 1.0.0-beta.1 * * @param WP_Rest_Request $request Request object. * @return array */ protected function prepare_item_for_database( $request ) { $prepared = array(); $map = $this->map_schema_to_database(); $schema = $this->get_item_schema(); foreach ( $map as $req_key => $db_key ) { if ( ! empty( $request[ $req_key ] ) ) { $prepared[ $db_key ] = $request[ $req_key ]; } } return $prepared; } /** * Prepares a single object for response. * * @since 1.0.0-beta.1 * @since 1.0.0-beta.3 Return early with a WP_Error if `$object` is a WP_Error. * @since 1.0.0-beta.14 Pass the `$request` parameter to `prepare_links()`. * * @param obj $object Raw object from database. * @param WP_REST_Request $request Request object. * @return WP_Error|WP_REST_Response */ public function prepare_item_for_response( $object, $request ) { if ( is_wp_error( $object ) ) { return $object; } $data = $this->prepare_object_for_response( $object, $request ); $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); // Wrap the data in a response object. $response = rest_ensure_response( $data ); // Add links. $response->add_links( $this->prepare_links( $object, $request ) ); return $response; } /** * Prepare links for the request. * * @since 1.0.0-beta.1 * @since 1.0.0-beta.14 Added $request parameter. * * @param obj $object Item object. * @param WP_REST_Request $request Request object. * @return array */ protected function prepare_links( $object, $request ) { $base = rest_url( sprintf( '/%1$s/%2$s', $this->namespace, $this->rest_base ) ); $links = array( 'self' => array( 'href' => sprintf( '%1$s/%2$d', $base, $this->get_object_id( $object ) ), ), 'collection' => array( 'href' => $base, ), ); return $links; } /** * Register routes. * * @since 1.0.0-beta.1 * * @return void */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => array( $this, 'create_item_permissions_check' ), 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)', array( 'args' => array( 'id' => array( 'description' => __( 'Unique identifier for the resource.', 'lifterlms' ), 'type' => 'integer', ), ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), 'args' => $this->get_get_item_params(), ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_item' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), // see class-wp-rest-controller.php. ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_item' ), 'permission_callback' => array( $this, 'delete_item_permissions_check' ), 'args' => $this->get_delete_item_args(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Update item. * * @since 1.0.0-beta.1 * @since 1.0.0-beta.12 Call `object_inserted` and `object_completely_inserted` after an object is * respectively inserted in the DB and all its additional fields have been * updated as well (completely inserted). * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error Response object or WP_Error on failure. */ public function update_item( $request ) { $object = $this->get_object( $request['id'] ); if ( is_wp_error( $object ) ) { return $object; } $item = $this->prepare_item_for_database( $request ); $object = $this->update_object( $item, $request ); $schema = $this->get_item_schema(); if ( is_wp_error( $object ) ) { return $object; } $this->object_inserted( $object, $request, $schema, false ); $fields_update = $this->update_additional_fields_for_object( $item, $request ); if ( is_wp_error( $fields_update ) ) { return $fields_update; } $this->object_completely_inserted( $object, $request, $schema, false ); $request->set_param( 'context', 'edit' ); $response = $this->prepare_item_for_response( $object, $request ); $response = rest_ensure_response( $response ); return $response; } }