diff --git a/README.md b/README.md index 2647d25..63b3c53 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,82 @@ Explore WordPress data via the WP REST API as a JSON file for static WordPress Hosting on [Shifter](https://getshifter.io). +This plugin dynamically discovers all publicly available REST API collections (posts, pages, media, custom post types, etc.) and compiles them into a single JSON file. It includes a comprehensive tabbed settings page to filter endpoints and fields, customize output keys, and trigger manual indexing. + 1. Install as a WordPress Plugin -2. Activate and save or create a new post or page -3. Create a new static Artifact on Shifter -4. Visit your new WP Serverless API endpoint at `example.com/wp-content/wp-sls-api/db.json` +2. Configure settings at **Settings -> WP Serverless API** +3. Save or create a new post or page (triggers background index) +4. Create a new static Artifact on Shifter +5. Visit your new WP Serverless API endpoint at `example.com/wp-content/wp-sls-api/db.json` + +## FEATURES + +- **Dynamic Discovery**: Automatically finds all valid REST API collection routes. +- **Asynchronous Processing**: Indexing runs in the background via WP-Cron to prevent UI blocking. +- **Tabbed Interface**: Organized settings for Paths and Fields. +- **Relative URL Support**: Automatically converts absolute staging URLs to relative paths, ensuring data works on production domains. +- **Component-Based Grouping**: Discovered paths and fields are organized by their source component (e.g., `wp v2`, `pods v1`). +- **Customizable Output**: Rename JSON keys for each endpoint. +- **Granular Field Filtering**: Exclude specific fields (e.g., `guid`, `_links`) from the final output. The field list automatically updates based on selected paths. +- **Accessibility Verification**: Automatically identifies and flags endpoints that are not publicly accessible. +- **Admin UI**: Filterable path list with item counts, object field counts, and direct preview links. ## CHANGELOG +### 1.1.0 + +- Added **Relative URL Support**: Automatically converts all `home_url` matches to relative paths in the JSON output. This fixes the issue where staging URLs would persist in production artifacts. +- Enabled relative URL conversion by default (toggleable in settings). +- Added "View in browser" link to the successful build notice for instant data verification. + +### 1.0.0 + +- Stable release. +- Added "View in browser" link to the database build success notice. +- Refined input path display by stripping namespace prefixes. +- Enhanced preview links to distinguish between lists (items) and objects (fields). +- Finalized UI grouping and tab organization. + +### 0.6.2 + +- UI Refinement: "Input Path" column now strips the namespace prefix already shown in the section header. +- Preview Link Enhancements: Lists show "View N items", Objects show "View N fields". +- Added `media` to the default exclusion list. + +### 0.6.1 + +- UI Refinement: Moved "Build Database Now" button to the bottom and added a top-level "Re-discover" button. +- Updated preview links for private endpoints to show "Not public" in grey while remaining clickable. +- Expanded default exclusion list to include `wp/v2/types` and `wp/v2/taxonomies`. + +### 0.6.0 + +- Organized Paths and Fields into grouped sections by component and version (e.g., `wp v2`, `elementor v1`). +- Enhanced Fields tab to show fields per component, filtered by currently selected paths. + +### 0.5.1 + +- Improved Friendly Name discovery for Taxonomies and core listing endpoints. +- Fixed "Reset to Defaults" behavior to properly clear cache and refresh UI. + +### 0.5.0 + +- Split settings into "Paths" and "Fields" tabs. +- Added `wp/v2/navigation` and `wp/v2/blocks` to default exclusions. + +### 0.4.0 + +- Added comprehensive Admin Settings page with Output Path customization. +- Added field-level exclusions with visual nested indentation. +- Added "Index Now" and "Reset to Defaults" buttons. + +### 0.3.0 + +- Added dynamic discovery of all public REST API collection routes. +- Added support for Custom Post Types (CPT) and plugin-provided endpoints. +- Switched to WordPress HTTP API (`wp_remote_get`) for better reliability and access checking. +- Offloaded indexing to a background WP-Cron task for non-blocking post saves. + ### 0.2.1 - Removed environment determination to generate db.json even in local environment [#2](https://github.com/getshifter/wp-serverless-api/pull/2) diff --git a/wp-serverless-api.php b/wp-serverless-api.php index 2f07564..a37726b 100644 --- a/wp-serverless-api.php +++ b/wp-serverless-api.php @@ -4,7 +4,7 @@ Plugin Name: WP Serverless API Plugin URI: https://github.com/getshifter/wp-serverless-api Description: WordPress REST API to JSON File -Version: 0.2.1 +Version: 1.1.0 Author: Shifter Author URI: https://getshifter.io */ @@ -21,27 +21,352 @@ function enable_permalinks_notice() { add_action( 'admin_notices', 'enable_permalinks_notice' ); } -function compile_db( - $routes = array( - 'posts', - 'pages', - 'media' - ) -) { +function wp_sls_api_discover_all() { + $url = esc_url( home_url( '/' ) ) . 'wp-json/'; + $response = wp_remote_get( $url ); + $data = array(); + if ( ! is_wp_error( $response ) ) { + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + } + + $rest_bases = array(); + + // Fetch Post Types + $types_url = esc_url( home_url( '/' ) ) . 'wp-json/wp/v2/types/'; + $types_response = wp_remote_get( $types_url ); + if ( ! is_wp_error( $types_response ) && wp_remote_retrieve_response_code($types_response) === 200 ) { + $types = json_decode( wp_remote_retrieve_body( $types_response ), true ); + if ( is_array($types) ) { + foreach ( $types as $type_slug => $type_info ) { + if ( isset( $type_info['rest_base'] ) ) { + $rest_bases[$type_info['rest_base']] = array( + 'name' => isset($type_info['name']) ? $type_info['name'] : $type_slug, + 'slug' => $type_slug, + ); + } + } + } + } + + // Fetch Taxonomies + $tax_url = esc_url( home_url( '/' ) ) . 'wp-json/wp/v2/taxonomies/'; + $tax_response = wp_remote_get( $tax_url ); + if ( ! is_wp_error( $tax_response ) && wp_remote_retrieve_response_code($tax_response) === 200 ) { + $taxonomies = json_decode( wp_remote_retrieve_body( $tax_response ), true ); + if ( is_array($taxonomies) ) { + foreach ( $taxonomies as $tax_slug => $tax_info ) { + if ( isset( $tax_info['rest_base'] ) ) { + $rest_bases[$tax_info['rest_base']] = array( + 'name' => isset($tax_info['name']) ? $tax_info['name'] : $tax_slug, + 'slug' => $tax_slug, + ); + } + } + } + } + + $groups = array(); + + if ( isset( $data['routes'] ) ) { + foreach ( $data['routes'] as $path => $details ) { + // Skip root + if ( $path === '/' ) continue; + // Skip namespace roots + if ( isset( $details['namespace'] ) && $path === '/' . $details['namespace'] ) continue; + // Skip routes with regex parameters + if ( strpos( $path, '(?P<' ) !== false ) continue; + // Skip schema endpoints + if ( substr( $path, -7 ) === '/schema' ) continue; + + // Only include routes that support GET + if ( ! isset($details['endpoints']) || ! is_array($details['endpoints']) ) continue; + + $valid_get_endpoint = false; + foreach ($details['endpoints'] as $endpoint) { + if ( in_array('GET', $endpoint['methods']) ) { + $has_required_args = false; + if ( isset($endpoint['args']) && is_array($endpoint['args']) ) { + foreach ($endpoint['args'] as $arg_details) { + if ( isset($arg_details['required']) && $arg_details['required'] === true ) { + $has_required_args = true; + break; + } + } + } + if ( ! $has_required_args ) { + $valid_get_endpoint = true; + break; + } + } + } + + if ( ! $valid_get_endpoint ) continue; + + $clean_path = ltrim( $path, '/' ); + $base_name = basename( $clean_path ); + + // Determine Group + $parts = explode('/', $clean_path); + if (count($parts) >= 2) { + $group_key = $parts[0] . '/' . $parts[1]; + $group_label = $parts[0] . ' ' . $parts[1]; + } else { + $group_key = 'other'; + $group_label = 'Other'; + } + + $endpoint_url = esc_url( home_url( '/' ) ) . 'wp-json/' . $clean_path . '?per_page=1'; + $endpoint_response = wp_remote_get( $endpoint_url ); + $is_accessible = false; + $total_items = 0; + $total_fields = 0; + $is_list = false; + $path_fields = array(); + + if ( ! is_wp_error( $endpoint_response ) ) { + if ( wp_remote_retrieve_response_code( $endpoint_response ) === 200 ) { + $is_accessible = true; + $sample_body = wp_remote_retrieve_body($endpoint_response); + $sample_data = json_decode($sample_body, true); + + if ( is_array($sample_data) && !empty($sample_data) ) { + $is_list = isset($sample_data[0]); + $first_item = array(); + + if ( $is_list ) { + $total_items = (int) wp_remote_retrieve_header( $endpoint_response, 'x-wp-total' ); + $first_item = $sample_data[0]; + } else { + $is_list = false; + $total_fields = count($sample_data); + $first_item = $sample_data; + } + + if ( is_array($first_item) ) { + foreach ($first_item as $field_key => $field_val) { + $path_fields[$field_key] = true; + if ( is_array($field_val) && $field_key === '_links' ) { + foreach ($field_val as $sub_key => $sub_val) { + $path_fields[$field_key . '/' . $sub_key] = true; + } + } + } + } + } + } + } + + $is_default_checked = false; + $friendly_name = isset($rest_bases[$base_name]['name']) ? $rest_bases[$base_name]['name'] : ''; + + if ( $clean_path === 'wp/v2/types' ) $friendly_name = 'Post Types'; + if ( $clean_path === 'wp/v2/taxonomies' ) $friendly_name = 'Taxonomies'; + + if ( $group_key === 'wp/v2' ) { + if ( !empty($friendly_name) || isset( $rest_bases[$base_name] ) ) { + $is_default_checked = true; + } + + if ( $base_name === 'nav_menu_item' || + $base_name === 'navigation' || + $base_name === 'blocks' || + $base_name === 'types' || + $base_name === 'taxonomies' || + $base_name === 'media' || + strpos($base_name, 'wp_') === 0 || + strpos($base_name, 'e-') === 0 || + strpos($base_name, 'elementor_') === 0 ) { + $is_default_checked = false; + } + } + + if (!isset($groups[$group_key])) { + $groups[$group_key] = array( + 'label' => $group_label, + 'paths' => array(), + 'fields' => array() + ); + } + + $groups[$group_key]['paths'][$clean_path] = array( + 'accessible' => $is_accessible, + 'name' => $friendly_name, + 'default_checked' => $is_default_checked, + 'url' => esc_url( home_url( '/' ) ) . 'wp-json/' . $clean_path, + 'total_items' => $total_items, + 'total_fields' => $total_fields, + 'is_list' => $is_list, + 'base_name' => $base_name, + 'fields' => array_keys($path_fields) + ); + + foreach ($path_fields as $f => $v) { + $groups[$group_key]['fields'][$f] = true; + } + } + } + + // Ensure wp/v2 is first + if (isset($groups['wp/v2'])) { + $wp_v2 = array('wp/v2' => $groups['wp/v2']); + unset($groups['wp/v2']); + $groups = array_merge($wp_v2, $groups); + } + + return array( + 'groups' => $groups + ); +} + +function wp_sls_api_get_discovery() { + $cache = get_transient('wp_sls_api_discovery'); + if ( $cache !== false ) { + return $cache; + } + $discovery = wp_sls_api_discover_all(); + set_transient('wp_sls_api_discovery', $discovery, HOUR_IN_SECONDS); + return $discovery; +} + +function wp_sls_api_filter_fields($item, $excluded_fields) { + if ( !is_array($item) ) return $item; + foreach ($excluded_fields as $field) { + if ( strpos($field, '/') !== false ) { + $parts = explode('/', $field, 2); + $parent = $parts[0]; + $child = $parts[1]; + if ( isset($item[$parent]) && is_array($item[$parent]) ) { + unset($item[$parent][$child]); + if ( empty($item[$parent]) ) { + unset($item[$parent]); + } + } + } else { + unset($item[$field]); + } + } + return $item; +} + +/** + * Recursively convert all home_url matches to relative paths + */ +function wp_sls_api_make_relative( $input ) { + static $home_url; + if ( ! isset( $home_url ) ) { + $home_url = home_url(); + } + + if ( is_array( $input ) ) { + foreach ( $input as $key => $value ) { + $input[$key] = wp_sls_api_make_relative( $value ); + } + } elseif ( is_string( $input ) ) { + if ( strpos( $input, $home_url ) === 0 ) { + return '/' . ltrim( str_replace( $home_url, '', $input ), '/' ); + } + } + return $input; +} + +function compile_db( $routes = array() ) { + $has_saved_paths = get_option('wp_sls_api_excluded_paths') !== false; + $excluded_paths = get_option('wp_sls_api_excluded_paths', array()); + $custom_output_paths = get_option('wp_sls_api_output_paths', array()); + $excluded_fields = get_option('wp_sls_api_excluded_fields', array('guid', '_links/curies')); + $make_relative = get_option('wp_sls_api_relative_urls', 'yes') === 'yes'; + + if ( empty( $routes ) ) { + $discovery = wp_sls_api_get_discovery(); + $routes = array(); + foreach ( $discovery['groups'] as $group_key => $group ) { + foreach ( $group['paths'] as $path => $info ) { + if ( ! $info['accessible'] ) continue; + $is_checked = $has_saved_paths ? !in_array($path, $excluded_paths) : $info['default_checked']; + if ( $is_checked ) { + $routes[] = $path; + } + } + } + } $db_array = array(); - foreach ($routes as $route) { - $url = esc_url( home_url( '/' ) ) . 'wp-json/wp/v2/' . $route; - $jsonData = json_decode( file_get_contents($url) ); + foreach ( $routes as $route ) { + $page = 1; + $total_pages = 1; + $all_items = array(); + $is_collection = true; + + while ( $page <= $total_pages ) { + $url = esc_url( home_url( '/' ) ) . 'wp-json/' . $route; + $url = add_query_arg( array( + 'per_page' => 100, + 'page' => $page, + ), $url ); + + $response = wp_remote_get( $url, array( 'timeout' => 30 ) ); + if ( is_wp_error( $response ) ) { + break; + } + + $code = wp_remote_retrieve_response_code( $response ); + if ( $code !== 200 ) { + break; + } + + if ( $page === 1 ) { + $total_pages = (int) wp_remote_retrieve_header( $response, 'x-wp-totalpages' ); + if ( $total_pages === 0 ) { + $total_pages = 1; + $is_collection = false; + } + } + + $body = wp_remote_retrieve_body( $response ); + $jsonData = json_decode( $body, true ); + + if ( ! is_array( $jsonData ) ) { + break; + } + + if ( $is_collection ) { + $all_items = array_merge( $all_items, $jsonData ); + } else { + $all_items = $jsonData; + break; // Not a collection, just a single object + } - $db_array[$route] = (array) $jsonData; + $page++; + } + + // Only include non-empty results and simplify the key + if ( ! empty( $all_items ) ) { + if ( ! empty( $excluded_fields ) ) { + if ( $is_collection ) { + foreach ( $all_items as &$item ) { + if ( is_array( $item ) ) { + $item = wp_sls_api_filter_fields( $item, $excluded_fields ); + } + } + } else { + $all_items = wp_sls_api_filter_fields( $all_items, $excluded_fields ); + } + } + + if ( $make_relative ) { + $all_items = wp_sls_api_make_relative( $all_items ); + } + + $key = isset( $custom_output_paths[$route] ) && ! empty( $custom_output_paths[$route] ) ? $custom_output_paths[$route] : basename( $route ); + $db_array[$key] = $all_items; + } } $db = json_encode($db_array); return $db; - } function save_db( @@ -66,8 +391,301 @@ function build_db() $db = compile_db(); save_db($db); } +add_action( 'wp_serverless_api_build_db_worker', 'build_db' ); + +function schedule_build_db() +{ + if ( ! wp_next_scheduled( 'wp_serverless_api_build_db_worker' ) ) { + wp_schedule_single_event( time(), 'wp_serverless_api_build_db_worker' ); + } +} /** * Build on Post Save */ -add_action( 'save_post', 'build_db' ); +add_action( 'save_post', 'schedule_build_db' ); + +// --- Admin Settings Page --- + +add_action( 'admin_menu', 'wp_sls_api_admin_menu' ); + +function wp_sls_api_admin_menu() { + add_options_page( 'WP Serverless API Settings', 'WP Serverless API', 'manage_options', 'wp-sls-api', 'wp_sls_api_settings_page' ); +} + +function wp_sls_api_admin_menu_styles() { + echo ''; +} +add_action('admin_head', 'wp_sls_api_admin_menu_styles'); + +function wp_sls_api_settings_page() { + $current_tab = isset($_GET['tab']) ? sanitize_text_field($_GET['tab']) : 'paths'; + + if ( isset( $_POST['wp_sls_api_build_now'] ) && check_admin_referer( 'wp_sls_api_save_action' ) ) { + build_db(); + $db_url = content_url('/wp-sls-api/db.json'); + echo '

Database built successfully. View in browser

'; + } + + if ( isset( $_POST['wp_sls_api_rediscover'] ) && check_admin_referer( 'wp_sls_api_save_action' ) ) { + delete_transient( 'wp_sls_api_discovery' ); + echo '

Discovery cache refreshed.

'; + } + + if ( isset( $_POST['wp_sls_api_reset'] ) && check_admin_referer( 'wp_sls_api_save_action' ) ) { + delete_option( 'wp_sls_api_excluded_paths' ); + delete_option( 'wp_sls_api_excluded_fields' ); + delete_option( 'wp_sls_api_output_paths' ); + delete_option( 'wp_sls_api_relative_urls' ); + delete_transient( 'wp_sls_api_discovery' ); + echo ''; + return; + } + + if ( isset( $_POST['wp_sls_api_save'] ) && check_admin_referer( 'wp_sls_api_save_action' ) ) { + $discovery = wp_sls_api_get_discovery(); + $groups = $discovery['groups']; + + if ( $current_tab === 'paths' ) { + $submitted_included_paths = isset($_POST['included_paths']) ? array_map('sanitize_text_field', $_POST['included_paths']) : array(); + $submitted_output_paths = isset($_POST['output_paths']) ? $_POST['output_paths'] : array(); + $relative_urls = isset($_POST['relative_urls']) ? 'yes' : 'no'; + + $excluded_paths = array(); + foreach ( $groups as $group ) { + foreach ($group['paths'] as $path => $info) { + if ( !in_array($path, $submitted_included_paths) ) { + $excluded_paths[] = $path; + } + } + } + + $output_paths = array(); + foreach ( $submitted_output_paths as $path => $val ) { + $output_paths[sanitize_text_field($path)] = sanitize_text_field($val); + } + + update_option( 'wp_sls_api_excluded_paths', $excluded_paths ); + update_option( 'wp_sls_api_output_paths', $output_paths ); + update_option( 'wp_sls_api_relative_urls', $relative_urls ); + } else { + $submitted_included_fields = isset($_POST['included_fields']) ? array_map('sanitize_text_field', $_POST['included_fields']) : array(); + + $all_fields = array(); + foreach ( $groups as $group ) { + foreach ($group['fields'] as $f => $v) $all_fields[$f] = true; + } + $all_field_keys = array_keys($all_fields); + + $excluded_fields = array(); + foreach ( $all_field_keys as $field ) { + if ( !in_array($field, $submitted_included_fields) ) { + $excluded_fields[] = $field; + } + } + update_option( 'wp_sls_api_excluded_fields', $excluded_fields ); + } + + echo '

Settings saved.

'; + } + + $discovery = wp_sls_api_get_discovery(); + $groups = $discovery['groups']; + + $has_saved_paths = get_option('wp_sls_api_excluded_paths') !== false; + $saved_excluded_paths = get_option('wp_sls_api_excluded_paths', array()); + $saved_excluded_fields = get_option('wp_sls_api_excluded_fields', array('guid', '_links/curies')); + $saved_output_paths = get_option('wp_sls_api_output_paths', array()); + $saved_relative_urls = get_option('wp_sls_api_relative_urls', 'yes') === 'yes'; + + $active_fields = array(); + foreach ( $groups as $group ) { + foreach ( $group['paths'] as $path => $info ) { + $is_checked = $has_saved_paths ? !in_array($path, $saved_excluded_paths) : $info['default_checked']; + if ( $is_checked && $info['accessible'] ) { + foreach ($info['fields'] as $f) $active_fields[$f] = true; + } + } + } + $display_fields = array_keys($active_fields); + sort($display_fields); + + ?> +
+

WP Serverless API Settings

+ + + +
+ + +
+ + + Filter List: + + + + + Fields grouped by component + +
+ + + $group ): ?> +

+ + + + + + + + + + + + $info ): + $is_checked = $has_saved_paths ? !in_array($path, $saved_excluded_paths) : $info['default_checked']; + $accessible = $info['accessible']; + $disabled_attr = ! $accessible ? 'disabled' : ''; + if ( ! $accessible ) $is_checked = false; + $out_val = isset($saved_output_paths[$path]) ? $saved_output_paths[$path] : $info['base_name']; + $has_name = !empty($info['name']); + + $row_classes = array(); + if ( !$accessible ) $row_classes[] = 'row-private'; + if ( !$has_name ) $row_classes[] = 'row-unnamed'; + + // Strip prefix implied by section + $display_input_path = ltrim(substr($path, strlen($group_key)), '/'); + if ( empty($display_input_path) ) $display_input_path = $path; // fallback + ?> + + + + + + + + + +
Input PathOutput PathFriendly NamePreview
+ /> + > + + + > + 0 ) { + echo sprintf('View %d items', $info['total_items']); + } else if ( !$info['is_list'] && $info['total_fields'] > 0 ) { + echo sprintf('View %d fields', $info['total_fields']); + } else { + echo 'View'; + } + else: ?> + Not public + + +
+ + + $group ): + // Only show fields for paths that are currently selected AND accessible + $display_group_fields = array(); + foreach ($group['paths'] as $path => $info) { + $is_checked = $has_saved_paths ? !in_array($path, $saved_excluded_paths) : $info['default_checked']; + if ($is_checked && $info['accessible']) { + foreach ($info['fields'] as $f) $display_group_fields[$f] = true; + } + } + if (empty($display_group_fields)) continue; + $sorted_fields = array_keys($display_group_fields); + sort($sorted_fields); + ?> +

+
+ +
+ +
+ +
+ + + +

+ + + +

+
+
+ +