├── .gitignore ├── .travis.yml ├── CHANGELONG.md ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── RestApi │ ├── CacheApiTrait.php │ └── RestDispatch.php ├── WpAdmin │ ├── Admin.php │ └── Settings.php └── WpRestApiCache.php ├── tests ├── bootstrap.php └── unit │ └── WpRestApiCacheTest.php ├── uninstall.php ├── views └── settings.php └── wp-rest-api-cache.php /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.idea 3 | coverage/ 4 | /vendor 5 | composer.lock -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - '7.0' 5 | - '7.1' 6 | - '7.2' 7 | 8 | install: composer install 9 | 10 | script: 11 | - ./vendor/bin/phpunit --coverage-html coverage 12 | - ./vendor/bin/phpcs --standard=PSR2 --extensions=php src 13 | 14 | after_script: 15 | - composer export-coverage 16 | 17 | cache: 18 | directories: 19 | - vendor 20 | 21 | branches: 22 | only: 23 | - master 24 | - develop 25 | 26 | notifications: 27 | email: 28 | on_success: never 29 | on_failure: change 30 | -------------------------------------------------------------------------------- /CHANGELONG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## 1.4 2019-04-17 8 | ### Added 9 | - Bypass cache setting added. 10 | 11 | ## 1.3.2 2019-02-14 12 | ### Added 13 | - Bypass cache when authorization headers present. 14 | 15 | ## 1.3.1 2019-02-06 16 | ### Updated 17 | - Added additional check for object on `site_transient_update_plugins` check. 18 | 19 | ## 1.3.0.1 2018-07-30 20 | ### Changed 21 | - Move the Admin class into an action hook on `after_setup_theme` to avoid conditional notices. 22 | 23 | ## 1.3.0 - 2018-07-27 24 | ### Updated 25 | - Removed the `helper.php` file. 26 | - Updated all the functions that were using the helper functions. 27 | - Update [thefrosty/wp-utilities](https://github.com/thefrosty/wp-utilities) to 1.2.2. 28 | - Fix save settings on admin page, (POST array key was incorrect). 29 | - Add confirm to clear all cache button on settings page. 30 | - Only load the Admin class in the admin. 31 | 32 | ### Changed 33 | - Added a new capability (`manage_wp_rest_api_cache`) to view the settings page and/or admin bar which 34 | is (mapped to `delete_users`). 35 | - The `Dwnload\WpRestApi\RestApi\RestDispatch::FILTER_CACHE_EXPIRE` filters expire sanitize function was changed from 36 | `absint` to `inval` function to allow for zero and negative numbers. 37 | - Pass `is_admin_bar_showing()` into FILTER_SHOW_ADMIN_BAR_MENU. 38 | 39 | ### Added 40 | - Added `wpCacheReplace()` to the `CacheApiTrait`. 41 | 42 | ## 1.2.3 - 2018-05-30 43 | ### Updated 44 | - Added permission check (`delete_users`) before adding admin bar node. 45 | - Change permission check on settings page from `manage_options` to `delete_users`. 46 | - Removed nonce check after successful cache flush for admin notice. 47 | 48 | ### Added 49 | - PHP 7.2 to the Travis build. 50 | 51 | ## 1.2.2 - 2018-04-30 52 | ### Fixed 53 | - When endpoints have multiple posts, the request bubbles up and appends the results which leads to a body size X's the 54 | requests. In other words, it's bad. This adds static property cache to break out of the possible loop. 55 | 56 | ## 1.2.1 - 2018-04-30 57 | ### Updated 58 | - Fixes PHP Warning: call_user_func_array() expects parameter 1 to be a valid callback , cannot access protected method Dwnload\WpRestApi\WpAdmin\Admin::renderPage(). 59 | 60 | ## 1.2.0 - 2018-04-25 61 | ### Added 62 | - Added new method `RestDispatch::queryParamContextIsEdit` 63 | - Added new method `RestDispatch::isUserAuthenticated`. 64 | 65 | ### Updated 66 | - `RestDispatch::isUserAuthenticated` uses a new filter `RestDispatch::FILTER_CACHE_VALIDATE_AUTH` to re-check requests containing `?context=edit` to avoid race conditions where a non-auth request returns results from cache. 67 | 68 | ## 1.1.1 - 2018-04-23 69 | ### Updated 70 | - Version bump for packagist. 71 | 72 | ## 1.1.0 - 2018-04-23 73 | ### Updated 74 | - The admin settings page now works. 75 | - wp_cache_set expire from settings is used (if available). 76 | - Be sure to clean the URL queries after calls to avoid caching delete requests. 77 | - Make sure the cache flush button in the settings is invoked to show the URL. 78 | 79 | ## 1.0.4 - 2018-04-19 80 | ### Updated 81 | - `RestDispatch::preDispatch` should set the $request_uri from `CacheApiTrait::getRequestUri` and not use 82 | `WP_REST_Request::get_route` to avoid query parameters getting stripped out of the cache request. 83 | - `CacheApiTrait::getRequestUri` to sanitize the REQUEST_URI 84 | 85 | ## 1.0.3 - 2018-04-18 86 | ### Updated 87 | - Bumped [thefrosty/wp-utilities](https://github.com/thefrosty/wp-utilities/) to version 1.1.3 88 | 89 | ## 1.0.2 - 2018-04-18 90 | ### Updated 91 | - Bumped [thefrosty/wp-utilities](https://github.com/thefrosty/wp-utilities/) to version 1.1.2 92 | which fixes `addOnHook` not executing when omitting a priority parameter less than 10. 93 | 94 | ## 1.0.1 - 2018-04-18 95 | ### Fixed 96 | - `addOnHook` expects a string value, not an object. 97 | 98 | ## 1.0.0 - 2018-04-09 99 | ### Updated 100 | - Bumped [thefrosty/wp-utilities](https://github.com/thefrosty/wp-utilities/) to version 1.1. 101 | - Updated the plugin to use the new PluginFactory. 102 | 103 | ## 0.2.1 - 2018-02-15 104 | ### Updated 105 | - Update conditional checks on `*_update_plugins` filters. 106 | 107 | ## 0.2 - 2018-02-14 108 | ### Updated 109 | - Global functions outside of namespace are now prefixed with a backslash. 110 | - Update README with new GitHub location URL. 111 | 112 | ## 0.1 - 2018-02-02 113 | ### Added 114 | - Forked [thefrosty/wp-rest-api-cache](https://github.com/thefrosty/wp-rest-api-cache/) which is a fork of 115 | [airesvsg/wp-rest-api-cache](https://github.com/airesvsg/wp-rest-api-cache/). 116 | - This CHANGELOG file. 117 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Austin Passy 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WordPress REST API Object Cache 2 | 3 | []() 4 | [](https://packagist.org/packages/dwnload/wp-rest-api-object-cache) 5 | [](https://packagist.org/packages/dwnload/wp-rest-api-object-cache) 6 | [](https://packagist.org/packages/dwnload/wp-rest-api-object-cache) 7 | [](https://travis-ci.org/dwnload/wp-rest-api-object-cache) 8 | 9 | Enable object caching for WordPress' REST API. Aids in increased response times of your applications endpoints. 10 | 11 | ## Package Installation (via Composer) 12 | 13 | To install this package, edit your `composer.json` file: 14 | 15 | ```json 16 | { 17 | "require": { 18 | "dwnload/wp-rest-api-object-cache": "^1.3.0" 19 | } 20 | } 21 | ``` 22 | 23 | Now run: 24 | 25 | `$ composer install dwnload/wp-rest-api-object-cache` 26 | 27 | ----- 28 | 29 | - [Actions](#actions) 30 | - [How to use actions](#how-to-use-actions) 31 | - [Filters](#filters) 32 | - [How to use filters](#how-to-use-filters) 33 | 34 | 35 | Actions 36 | ==== 37 | | Action | Argument(s) | 38 | |-----------|-----------| 39 | | Dwnload\WpRestApi\RestApi\RestDispatch::ACTION_CACHE_SKIPPED | mixed **$result**WP_REST_Server **$server**WP_REST_Request **$request** | 40 | | Dwnload\WpRestApi\WpAdmin\Admin::ACTION_REQUEST_FLUSH_CACHE | string **$message**string **$type**WP_User **$user** | 41 | 42 | How to use actions 43 | ---- 44 | 45 | ```php 46 | use Dwnload\WpRestApi\RestApi\RestDispatch; 47 | add_action( RestDispatch::ACTION_CACHE_SKIPPED, function( $result, \WP_REST_Server $server, \WP_REST_Request $request ) { 48 | // Do something here, like create a log entry using Wonolog. 49 | }, 10, 3 ); 50 | ``` 51 | 52 | ```php 53 | use Dwnload\WpRestApi\WpAdmin\Admin; 54 | add_action( Admin::ACTION_REQUEST_FLUSH_CACHE, function( $message, $type, WP_User $user ) { 55 | // Do something here, like create a log entry using Wonolog. 56 | }, 10, 3 ); 57 | ``` 58 | 59 | Filters 60 | ==== 61 | | Filter | Argument(s) | 62 | |-----------|-----------| 63 | | Dwnload\WpRestApi\RestApi\RestDispatch::FILTER_CACHE_HEADERS | array **$headers**string **$request_uri**WP_REST_Server **$server**WP_REST_Request **$request**WP_REST_Response **$response (`rest_pre_dispatch` only)** | 64 | | Dwnload\WpRestApi\RestApi\RestDispatch::FILTER_CACHE_SKIP | boolean **$skip** ( default: WP_DEBUG )string **$request_uri**WP_REST_Server **$server**WP_REST_Request **$request** | 65 | | Dwnload\WpRestApi\RestApi\RestDispatch::FILTER_API_KEY | string **$request_uri**WP_REST_Server **$server**WP_REST_Request **$request** | 66 | | Dwnload\WpRestApi\RestApi\RestDispatch::FILTER_API_GROUP | string **$cache_group** | 67 | | Dwnload\WpRestApi\RestApi\RestDispatch::FILTER_CACHE_EXPIRE | int **$expires** | 68 | | Dwnload\WpRestApi\WpAdmin\Admin::FILTER_CACHE_UPDATE_OPTIONS | array **$options** | 69 | | Dwnload\WpRestApi\WpAdmin\Admin::FILTER_CACHE_OPTIONS | array **$options** | 70 | | Dwnload\WpRestApi\WpAdmin\Admin::FILTER_SHOW_ADMIN | boolean **$show** | 71 | | Dwnload\WpRestApi\WpAdmin\Admin::FILTER_SHOW_ADMIN_MENU | boolean **$show** | 72 | | Dwnload\WpRestApi\WpAdmin\Admin::FILTER_SHOW_ADMIN_BAR_MENU | boolean **$show** | 73 | | Dwnload\WpRestApi\RestApi\RestDispatch::FILTER_ALLOWED_CACHE_STATUS | array **$status** HTTP Header statuses (defaults to `array( 200 )` | 74 | | Dwnload\WpRestApi\RestApi\RestDispatch::FILTER_CACHE_VALIDATE_AUTH | boolean **$authenticated**WP_REST_Request $request | 75 | 76 | How to use filters 77 | ---- 78 | **Sending headers.** 79 | 80 | ```php 81 | use Dwnload\WpRestApi\RestApi\RestDispatch; 82 | add_filter( RestDispatch::FILTER_CACHE_HEADERS, function( array $headers ) : array { 83 | $headers['Cache-Control'] = 'public, max-age=3600'; 84 | 85 | return $headers; 86 | } ); 87 | ``` 88 | 89 | **Changing the cache expire time.** 90 | 91 | ```php 92 | use Dwnload\WpRestApi\RestApi\RestDispatch; 93 | add_filter( RestDispatch::FILTER_CACHE_EXPIRE, function() : int { 94 | // https://codex.wordpress.org/Transients_API#Using_Time_Constants 95 | return ( HOUR_IN_SECONDS * 5 ); 96 | } ); 97 | ``` 98 | 99 | ```php 100 | use Dwnload\WpRestApi\WpAdmin\Admin; 101 | add_filter( Admin::FILTER_CACHE_OPTIONS, function( array $options ) : array { 102 | if ( ! isset( $options['timeout'] ) ) { 103 | $options['timeout'] = array(); 104 | } 105 | 106 | // https://codex.wordpress.org/Transients_API#Using_Time_Constants 107 | $options['timeout']['length'] = 15; 108 | $options['timeout']['period'] = DAY_IN_SECONDS; 109 | 110 | return $options; 111 | } ); 112 | ``` 113 | 114 | **Validating user auth when `?context=edit`** 115 | 116 | ```php 117 | use Dwnload\WpRestApi\RestApi\RestDispatch; 118 | add_filter( RestDispatch::FILTER_CACHE_VALIDATE_AUTH, function( bool $auth, WP_REST_Request $request ) : bool { 119 | // If you are running the Basic Auth plugin. 120 | if ( $GLOBALS['wp_json_basic_auth_error'] === true ) { 121 | $authorized = true; 122 | } 123 | // Otherwise, maybe do some additional logic on the request for current user... 124 | 125 | return $authorized; 126 | }, 10, 2 ); 127 | ``` 128 | 129 | **Skipping cache** 130 | 131 | ```php 132 | use Dwnload\WpRestApi\RestApi\RestDispatch; 133 | add_filter( RestDispatch::FILTER_CACHE_SKIP, function( bool $skip, string $request_uri ) : bool { 134 | if ( ! $skip && stripos( 'wp-json/dwnload/v2', $request_uri ) !== false ) { 135 | return true; 136 | } 137 | 138 | return $skip; 139 | }, 10, 2 ); 140 | ``` 141 | 142 | **Deleting cache** 143 | 144 | *Soft delete:* 145 | Append `RestDispatch::QUERY_CACHE_DELETE` to your query param: `add_query_arg( [ RestDispatch::QUERY_CACHE_DELETE, '1' ], '' )`. 146 | _soft delete will delete the cache after the current request completes (on WordPress shutdown)._ 147 | 148 | *Hard delete:* Append `RestDispatch::QUERY_CACHE_DELETE` && `RestDispatch::QUERY_CACHE_FORCE_DELETE` to your query param: 149 | `add_query_arg( [ RestDispatch::QUERY_CACHE_DELETE, '1', RestDispatch::QUERY_CACHE_FORCE_DELETE, '1' ], '' )`. 150 | _hard delete will delete the cache before the request, forcing it to repopulate._ 151 | 152 | 153 | **empty ALL cache on post-save** _this is not ideal_ 154 | 155 | You can use the WordPress filter `save_post` if you would like to empty **ALL** cache on post save. 156 | 157 | ```php 158 | use Dwnload\WpRestApi\RestApi\RestDispatch; 159 | add_action( 'save_post', function( $post_id ) { 160 | if ( class_exists( RestDispatch::class ) ) { 161 | call_user_func( [ ( WpRestApiCache::getRestDispatch(), 'wpCacheFlush' ] ); 162 | } 163 | } ); 164 | ``` 165 | 166 | **Maybe better to use `transition_post_status`** 167 | 168 | ```php 169 | add_action( 'transition_post_status', function( string $new_status, string $old_status, \WP_Post $post ) { 170 | if ( 'publish' === $new_status || 'publish' === $old_status ) { 171 | \wp_cache_flush(); 172 | } 173 | }, 99, 3 ); 174 | ``` 175 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dwnload/wp-rest-api-object-cache", 3 | "description": "Enable object caching for WordPress' REST API. Aids in increased response times of your applications endpoints.", 4 | "type": "wordpress-plugin", 5 | "version": "1.4.0", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Austin Passy", 10 | "email": "thefrosty@users.noreply.github.com", 11 | "homepage": "https://austin.passy.co", 12 | "role": "Developer" 13 | } 14 | ], 15 | "require": { 16 | "composer/installers": "~1.0", 17 | "thefrosty/wp-utilities": "^1.2.2", 18 | "php": ">=7.0.4" 19 | }, 20 | "require-dev": { 21 | "phpunit/phpunit": "6.*", 22 | "squizlabs/php_codesniffer": "^3.2" 23 | }, 24 | "scripts": { 25 | "phpcs": "./vendor/bin/phpcs --standard=PSR2 --extensions=php src", 26 | "test": "./vendor/bin/phpunit --colors", 27 | "export-coverage": "test-reporter" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "Dwnload\\WpRestApi\\": "src" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "Dwnload\\WpRestApi\\Tests\\": "tests/unit" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | ./tests/unit 12 | 13 | 14 | 15 | 16 | 17 | ./src 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/RestApi/CacheApiTrait.php: -------------------------------------------------------------------------------- 1 | sanitize(\apply_filters(RestDispatch::FILTER_API_KEY, $request_uri, $server, $request)); 47 | } 48 | 49 | /** 50 | * Get the cache group value. 51 | * 52 | * @return string 53 | */ 54 | protected function getCacheGroup() : string 55 | { 56 | return $this->sanitize(\apply_filters(RestDispatch::FILTER_API_GROUP, RestDispatch::CACHE_GROUP)); 57 | } 58 | 59 | /** 60 | * Empty all cache. 61 | * 62 | * @uses wp_cache_flush() 63 | * @return bool Returns TRUE on success or FALSE on failure. 64 | */ 65 | protected function wpCacheFlush() : bool 66 | { 67 | return \wp_cache_flush(); 68 | } 69 | 70 | /** 71 | * Empty all cache. 72 | * 73 | * @uses wp_cache_replace() 74 | * @param string $key The key under which the value is stored. 75 | * @return bool Returns TRUE on success or FALSE on failure. 76 | */ 77 | protected function wpCacheReplace(string $key) : bool 78 | { 79 | return \wp_cache_replace($this->cleanKey($key), false, $this->getCacheGroup(), -1); 80 | } 81 | 82 | /** 83 | * Empty all cache. 84 | * 85 | * @uses wp_cache_delete() 86 | * @param string $key The key under which the value is stored. 87 | * @return bool Returns TRUE on success or FALSE on failure. 88 | */ 89 | protected function wpCacheDeleteByKey(string $key) : bool 90 | { 91 | return \wp_cache_delete($this->cleanKey($key), $this->getCacheGroup()); 92 | } 93 | 94 | /** 95 | * Clean the cache of query params that shouldn't be part of the string; like delete params. 96 | * 97 | * @param string $key 98 | * @return string 99 | */ 100 | protected function cleanKey(string $key) : string 101 | { 102 | $query_args = [ 103 | RestDispatch::QUERY_CACHE_DELETE, 104 | RestDispatch::QUERY_CACHE_FORCE_DELETE, 105 | RestDispatch::QUERY_CACHE_REFRESH, 106 | ]; 107 | return \remove_query_arg($query_args, $key); 108 | } 109 | 110 | /** 111 | * Return the current REQUEST_URI from the global server variable. 112 | * Don't use `FILTER_SANITIZE_URL` since it will return false when 'http' isn't present. 113 | * 114 | * @return string 115 | */ 116 | protected function getRequestUri() : string 117 | { 118 | return $this->sanitize(\wp_unslash($_SERVER['REQUEST_URI'])); 119 | } 120 | 121 | /** 122 | * Sanitize incoming variables as a string value. 123 | * @param mixed $variable 124 | * @return string|false 125 | */ 126 | private function sanitize($variable) 127 | { 128 | return \filter_var($variable, FILTER_SANITIZE_STRING); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/RestApi/RestDispatch.php: -------------------------------------------------------------------------------- 1 | getOptions([]); 60 | if (!isset($options[Settings::BYPASS]) || $options[Settings::BYPASS] !== 'on') { 61 | $this->addFilter('rest_pre_dispatch', [$this, 'preDispatch'], 10, 3); 62 | $this->addFilter('rest_post_dispatch', [$this, 'postDispatch'], 10, 3); 63 | } 64 | } 65 | 66 | /** 67 | * Filters the pre-calculated result of a REST dispatch request. 68 | * 69 | * @param mixed $result Response to replace the requested version with. Can be anything 70 | * a normal endpoint can return, or null to not hijack the request. 71 | * @param WP_REST_Server $server Server instance. 72 | * @param WP_REST_Request $request Request used to generate the response. 73 | * 74 | * @return mixed Response 75 | */ 76 | protected function preDispatch($result, WP_REST_Server $server, WP_REST_Request $request) 77 | { 78 | if ($result !== null) { 79 | return $result; 80 | } 81 | $request_uri = $this->getRequestUri(); 82 | $group = $this->getCacheGroup(); 83 | $key = $this->getCacheKey($request_uri, $server, $request); 84 | 85 | /* 86 | * Return the result if: 87 | * It's a non-readable (GET) method. 88 | * It's been cached already. 89 | * The request has an authorization header. 90 | */ 91 | if ($request->get_method() !== WP_REST_Server::READABLE || 92 | (! empty(self::$cached[$this->cleanKey($key)]) && self::$cached[$this->cleanKey($key)] === true) || 93 | ! empty($request->get_header('authorization')) 94 | ) { 95 | return $result; 96 | } 97 | 98 | $this->sendHeaders($request_uri, $server, $request); 99 | 100 | // Delete the cache. 101 | if ($this->validateQueryParam($request, self::QUERY_CACHE_DELETE)) { 102 | // Force delete 103 | if ($this->validateQueryParam($request, self::QUERY_CACHE_FORCE_DELETE)) { 104 | if ($this->wpCacheDeleteByKey($key)) { 105 | $server->send_header(self::CACHE_HEADER_DELETE, 'true'); 106 | 107 | return $this->getCachedResult($server, $request, $key, $group, true); 108 | } 109 | } else { 110 | $server->send_header(self::CACHE_HEADER_DELETE, 'soft'); 111 | $this->dispatchShutdownAction($key); 112 | 113 | return $this->getCachedResult($server, $request, $key, $group); 114 | } 115 | } 116 | 117 | // Cache is refreshed (cached below). 118 | $refresh = \filter_var($request->get_param(self::QUERY_CACHE_REFRESH), FILTER_VALIDATE_BOOLEAN); 119 | if ($refresh) { 120 | $server->send_header( 121 | self::CACHE_HEADER, 122 | \esc_attr_x( 123 | 'refreshed', 124 | 'When the wp-api cache is skipped. This is the header value.', 125 | 'wp-rest-api-cache' 126 | ) 127 | ); 128 | 129 | return $result; 130 | } else { 131 | $server->send_header( 132 | self::CACHE_HEADER, 133 | \esc_attr_x( 134 | 'cached', 135 | 'When rest_cache is cached. This is the header value.', 136 | 'wp-rest-api-cache' 137 | ) 138 | ); 139 | } 140 | 141 | $skip = \filter_var( 142 | \apply_filters(self::FILTER_CACHE_SKIP, WP_DEBUG, $request_uri, $server, $request), 143 | FILTER_VALIDATE_BOOLEAN 144 | ); 145 | if ($skip) { 146 | $server->send_header( 147 | self::CACHE_HEADER, 148 | \esc_attr_x( 149 | 'skipped', 150 | 'When rest_cache is skipped. This is the header value.', 151 | 'wp-rest-api-cache' 152 | ) 153 | ); 154 | /** 155 | * Action hook when the cache is skipped. 156 | * 157 | * @param mixed $result Response to replace the requested version with. Can be anything 158 | * a normal endpoint can return, or null to not hijack the request. 159 | * @param WP_REST_Server $server Server instance. 160 | * @param WP_REST_Request $request Request used to generate the response. 161 | */ 162 | \do_action(self::ACTION_CACHE_SKIPPED, $result, $server, $request); 163 | 164 | return $result; 165 | } 166 | 167 | return $this->getCachedResult($server, $request, $key, $group); 168 | } 169 | 170 | /** 171 | * Filters the post-calculated result of a REST dispatch request. 172 | * 173 | * @param WP_Error|WP_HTTP_Response|WP_REST_Response $response 174 | * @param WP_REST_Server $server 175 | * @param WP_REST_Request $request 176 | * 177 | * @return WP_REST_Response 178 | */ 179 | protected function postDispatch($response, WP_REST_Server $server, WP_REST_Request $request) : WP_REST_Response 180 | { 181 | $request_uri = $this->getRequestUri(); 182 | $key = $this->getCacheKey($request_uri, $server, $request); 183 | 184 | // Don't cache WP_Error objects. 185 | if ($response instanceof WP_Error) { 186 | $this->wpCacheDeleteByKey($key); 187 | 188 | return \rest_ensure_response($response); 189 | } 190 | 191 | $allowed_cache_status = \apply_filters(self::FILTER_ALLOWED_CACHE_STATUS, [WP_Http::OK]); 192 | if (! \in_array($response->get_status(), $allowed_cache_status, true)) { 193 | $server->send_header( 194 | self::CACHE_HEADER, 195 | \esc_attr_x( 196 | 'incorrect-status', 197 | 'When rest_cache is skipped. This is the header value.', 198 | 'wp-rest-api-cache' 199 | ) 200 | ); 201 | $this->wpCacheDeleteByKey($key); 202 | } 203 | 204 | return \rest_ensure_response($response); 205 | } 206 | 207 | /** 208 | * Get the result from cache. 209 | * 210 | * @param WP_REST_Server $server 211 | * @param WP_REST_Request $request 212 | * @param string $key 213 | * @param string $group 214 | * @param bool $force 215 | * 216 | * @return bool|mixed|WP_REST_Response 217 | */ 218 | protected function getCachedResult( 219 | WP_REST_Server $server, 220 | WP_REST_Request $request, 221 | string $key, 222 | string $group, 223 | bool $force = false 224 | ) { 225 | $result = \wp_cache_get($this->cleanKey($key), $group, $force); 226 | self::$cached[$this->cleanKey($key)] = $result !== false; 227 | if ($result === false) { 228 | $result = $this->dispatchRequest($server, $request); 229 | $defaults = [ 230 | Settings::EXPIRATION => [ 231 | Settings::LENGTH => 10, 232 | Settings::PERIOD => MINUTE_IN_SECONDS, 233 | ], 234 | ]; 235 | $options = $this->getOptions($defaults); 236 | /** 237 | * Filter for cache expiration time. 238 | * @param int Expiration time. 239 | * @param array Array of settings from the expiration length and period. 240 | * @return int 241 | */ 242 | $expire = \apply_filters( 243 | self::FILTER_CACHE_EXPIRE, 244 | ($options[Settings::EXPIRATION][Settings::PERIOD] * $options[Settings::EXPIRATION][Settings::LENGTH]), 245 | $options[Settings::EXPIRATION] 246 | ); 247 | self::$cached[$this->cleanKey($key)] = \wp_cache_set( 248 | $this->cleanKey($key), 249 | $result, 250 | $group, 251 | \intval($expire) 252 | ); 253 | 254 | return $result; 255 | } 256 | 257 | /* 258 | * Attempt to validate the user if `?context=edit` to avoid returning results for non-auth'd requests after 259 | * a cached request from an authenticated request happens before cache flush. 260 | */ 261 | if ($this->queryParamContextIsEdit($request) && ! $this->isUserAuthenticated($request)) { 262 | \wp_cache_delete($this->cleanKey($key), $group); 263 | return $this->dispatchRequest($server, $request); 264 | } 265 | 266 | return $result; 267 | } 268 | 269 | /** 270 | * Send server headers if we have headers to send. 271 | * 272 | * @param string $request_uri 273 | * @param WP_REST_Server $server 274 | * @param WP_REST_Request $request 275 | * @param null|WP_REST_Response $response 276 | */ 277 | private function sendHeaders( 278 | string $request_uri, 279 | WP_REST_Server $server, 280 | WP_REST_Request $request, 281 | WP_REST_Response $response = null 282 | ) { 283 | $headers = \apply_filters( 284 | self::FILTER_CACHE_HEADERS, 285 | [], 286 | $request_uri, 287 | $server, 288 | $request, 289 | $response 290 | ); 291 | if (! empty($headers)) { 292 | $server->send_headers($headers); 293 | } 294 | } 295 | 296 | /** 297 | * Dispatch the REST request. 298 | * 299 | * @param WP_REST_Server $server 300 | * @param WP_REST_Request $request 301 | * 302 | * @return WP_REST_Response 303 | */ 304 | private function dispatchRequest(WP_REST_Server $server, WP_REST_Request $request) : WP_REST_Response 305 | { 306 | $request->set_param(self::QUERY_CACHE_REFRESH, true); 307 | 308 | // Don't filter the request of the dispatch, since we're in the filter right now. 309 | $this->removeFilter('rest_pre_dispatch', [$this, 'preDispatch'], 10); 310 | $results = $server->dispatch($request); 311 | $this->addFilter('rest_pre_dispatch', [$this, 'preDispatch'], 10, 3); 312 | 313 | return $results; 314 | } 315 | 316 | /** 317 | * Dispatch a function hooked to WordPress' `shutdown` action to clear the cache by key if it exists. 318 | * 319 | * @param string $key 320 | */ 321 | private function dispatchShutdownAction(string $key) 322 | { 323 | $this->addAction('shutdown', function () use ($key) { 324 | \call_user_func([$this, 'wpCacheDeleteByKey'], $key); 325 | }); 326 | } 327 | 328 | /** 329 | * Validate the HTTP query param. 330 | * 331 | * @param WP_REST_Request $request 332 | * @param string $key 333 | * 334 | * @return bool 335 | */ 336 | private function validateQueryParam(WP_REST_Request $request, string $key) : bool 337 | { 338 | return \array_key_exists($key, $request->get_query_params()) && 339 | \filter_var($request->get_query_params()[$key], FILTER_VALIDATE_INT) === 1; 340 | } 341 | 342 | /** 343 | * Validate the HTTP query param. 344 | * 345 | * @param WP_REST_Request $request 346 | * 347 | * @return bool 348 | */ 349 | private function queryParamContextIsEdit(WP_REST_Request $request) : bool 350 | { 351 | return \array_key_exists('context', $request->get_query_params()) && 352 | $request->get_query_params()['context'] === 'edit'; 353 | } 354 | 355 | /** 356 | * Apply a filter to allow user auth checks based on the $request headers. 357 | * A great example here is to use the Basic Auth plugin and check for the global `$wp_json_basic_auth_error` 358 | * is equal to true to validate the current request is an authenticated user. 359 | * 360 | * @param WP_REST_Request $request 361 | * 362 | * @return bool 363 | */ 364 | private function isUserAuthenticated(WP_REST_Request $request) : bool 365 | { 366 | /** 367 | * @param bool $authenticated Defaults to false, user needs to be authenticated. 368 | * @param WP_REST_Request $request 369 | */ 370 | return \apply_filters(self::FILTER_CACHE_VALIDATE_AUTH, false, $request) !== false; 371 | } 372 | 373 | /** 374 | * Get the options. 375 | * @param mixed $defaults 376 | * @return mixed 377 | */ 378 | private function getOptions($defaults) 379 | { 380 | return \get_option(Admin::OPTION_KEY, $defaults); 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /src/WpAdmin/Admin.php: -------------------------------------------------------------------------------- 1 | settings = new Settings([ 45 | Settings::LENGTH => 10, 46 | Settings::PERIOD => MINUTE_IN_SECONDS, 47 | ]); 48 | } 49 | 50 | /** 51 | * Add class hooks. 52 | */ 53 | public function addHooks() 54 | { 55 | if ($this->showAdmin()) { 56 | if ($this->showAdminMenu()) { 57 | $this->addAction('admin_menu', [$this, 'adminMenu']); 58 | } else { 59 | $this->addAction('admin_action_' . self::ADMIN_ACTION, [$this, 'adminAction']); 60 | $this->addAction('admin_notices', [$this, 'adminNotices']); 61 | } 62 | if ($this->showAdminMenuBar()) { 63 | $this->addAction('admin_bar_menu', [$this, 'adminBarMenu'], 999); 64 | } 65 | if ($this->showAdminMenu() || $this->showAdminMenuBar()) { 66 | $this->addFilter('map_meta_cap', [$this, 'mapMetaCap'], 10, 2); 67 | } 68 | } 69 | } 70 | 71 | 72 | /** 73 | * Map `self::CAPABILITY` capability. 74 | * 75 | * @param array $caps Returns the user's actual capabilities. 76 | * @param string $cap Capability name. 77 | * @return array 78 | */ 79 | protected function mapMetaCap(array $caps, string $cap) : array 80 | { 81 | // Map single-site cap check to 'manage_options' 82 | if ($cap === self::CAPABILITY) { 83 | if (! \is_multisite()) { 84 | $caps = ['delete_users']; 85 | } 86 | } 87 | 88 | return $caps; 89 | } 90 | 91 | /** 92 | * Hook into the WordPress admin menu. 93 | */ 94 | protected function adminMenu() 95 | { 96 | \add_submenu_page( 97 | 'options-general.php', 98 | \esc_html__('WP REST API Cache', 'wp-rest-api-cache'), 99 | \esc_html__('REST API Cache', 'wp-rest-api-cache'), 100 | self::CAPABILITY, 101 | self::MENU_SLUG, 102 | function () { 103 | $this->renderPage(); 104 | } 105 | ); 106 | } 107 | 108 | /** 109 | * Hook into the WordPress admin bar. 110 | * 111 | * @param WP_Admin_Bar $wp_admin_bar WP_Admin_Bar object. 112 | */ 113 | protected function adminBarMenu(WP_Admin_Bar $wp_admin_bar) 114 | { 115 | if (! \is_user_logged_in() || ! \current_user_can(self::CAPABILITY) || ! \is_admin_bar_showing()) { 116 | return; 117 | } 118 | 119 | $wp_admin_bar->add_node([ 120 | 'id' => WpRestApiCache::ID, 121 | 'title' => \sprintf( 122 | '%s', 123 | \esc_attr__('REST API Cache', 'wp-rest-api-cache'), 124 | \esc_html__('REST Cache', 'wp-rest-api-cache') 125 | ) 126 | ]); 127 | $wp_admin_bar->add_menu([ 128 | 'parent' => WpRestApiCache::ID, 129 | 'id' => self::MENU_ID, 130 | 'title' => \esc_html__('Empty all cache', 'wp-rest-api-cache'), 131 | 'href' => \esc_url($this->getEmptyCacheUrl()), 132 | 'meta' => [ 133 | 'onclick' => \sprintf( 134 | 'return confirm("%s")', 135 | \esc_attr__('This will clear ALL cache, continue?', 'wp-rest-api-cache') 136 | ) 137 | ] 138 | ]); 139 | } 140 | 141 | /** 142 | * Helper to check the request action. 143 | */ 144 | protected function adminAction() 145 | { 146 | $this->requestCallback(); 147 | 148 | $url = \add_query_arg( 149 | [self::NOTICE => 1], 150 | \remove_query_arg( 151 | [RestDispatch::QUERY_CACHE_DELETE, RestDispatch::QUERY_CACHE_REFRESH], 152 | \wp_get_referer() 153 | ) 154 | ); 155 | \wp_safe_redirect($url); 156 | exit; 157 | } 158 | 159 | /** 160 | * Maybe add an admin notice. 161 | */ 162 | protected function adminNotices() 163 | { 164 | if (! empty($_GET[self::NOTICE]) && 165 | \filter_var($_GET[self::NOTICE], FILTER_VALIDATE_INT) === 1 166 | ) { 167 | $message = \esc_html__('The cache has been successfully cleared.', 'wp-rest-api-cache'); 168 | echo "{$message}"; // PHPCS: XSS OK. 169 | } 170 | } 171 | 172 | /** 173 | * Render the admin settings page. 174 | */ 175 | protected function renderPage() 176 | { 177 | $this->requestCallback(); 178 | require_once \dirname(__FILE__) . '/../../views/settings.php'; 179 | } 180 | 181 | /** 182 | * Get an option from our options array. 183 | * 184 | * @param null|string $key Option key value. 185 | * @return mixed 186 | */ 187 | protected function getOptions($key = null) 188 | { 189 | $options = \apply_filters( 190 | self::FILTER_CACHE_OPTIONS, 191 | \get_option(self::OPTION_KEY, $this->settings->getSettings()) 192 | ); 193 | 194 | if (\is_string($key) && \array_key_exists($key, $options)) { 195 | return $options[$key]; 196 | } 197 | 198 | return $options; 199 | } 200 | 201 | /** 202 | * Helper to check the request action. 203 | */ 204 | private function requestCallback() 205 | { 206 | $type = 'warning'; 207 | $message = \esc_html__('Nothing to see here.', 'wp-rest-api-cache'); 208 | 209 | if (! empty($_REQUEST[self::NONCE_NAME]) && 210 | \wp_verify_nonce($_REQUEST[self::NONCE_NAME], 'rest_cache_options') !== false 211 | ) { 212 | if (! empty($_GET['rest_cache_empty']) && 213 | \filter_var($_GET['rest_cache_empty'], FILTER_VALIDATE_INT) === 1 214 | ) { 215 | if ($this->wpCacheFlush()) { 216 | $type = 'updated'; 217 | $message = \esc_html__('The cache has been successfully cleared', 'wp-rest-api-cache'); 218 | } else { 219 | $type = 'error'; 220 | $message = \esc_html__('The cache is already empty', 'wp-rest-api-cache'); 221 | } 222 | /** 223 | * Action hook when the cache is flushed. 224 | * 225 | * @param string $message The message set. 226 | * @param string $type The settings error code. 227 | * @param \WP_User The current user. 228 | */ 229 | \do_action(self::ACTION_REQUEST_FLUSH_CACHE, $message, $type, \wp_get_current_user()); 230 | } elseif (! empty($_POST[self::OPTION_KEY])) { 231 | if ($this->updateOptions($_POST[self::OPTION_KEY])) { 232 | $type = 'updated'; 233 | $message = \esc_html__('The cache time has been updated', 'wp-rest-api-cache'); 234 | } else { 235 | $type = 'error'; 236 | $message = \esc_html__('The cache time has not been updated', 'wp-rest-api-cache'); 237 | } 238 | } 239 | \add_settings_error('wp-rest-api-notice', \esc_attr('settings_updated'), $message, $type); 240 | } 241 | } 242 | 243 | /** 244 | * Update the option settings. 245 | * 246 | * @param array $options Incoming POST array. 247 | * @return bool 248 | */ 249 | private function updateOptions(array $options) : bool 250 | { 251 | $this->settings->setLength(absint($options[Settings::EXPIRATION][Settings::LENGTH])); 252 | $this->settings->setPeriod(absint($options[Settings::EXPIRATION][Settings::PERIOD])); 253 | $this->settings->setBypass(!empty($options[Settings::BYPASS]) ? 'on' : 'off'); 254 | 255 | return \update_option(self::OPTION_KEY, $this->settings->getSettings(), 'yes'); 256 | } 257 | 258 | /** 259 | * Build a clear cache URL query string. 260 | * 261 | * @return string 262 | */ 263 | private function getEmptyCacheUrl() : string 264 | { 265 | if ($this->showAdminMenu()) { 266 | return \wp_nonce_url( 267 | \add_query_arg( 268 | [ 269 | 'page' => self::MENU_SLUG, 270 | 'rest_cache_empty' => '1', 271 | ], 272 | \admin_url('options-general.php') 273 | ), 274 | 'rest_cache_options', 275 | self::NONCE_NAME 276 | ); 277 | } 278 | 279 | return \wp_nonce_url( 280 | \add_query_arg( 281 | [ 282 | 'action' => self::ADMIN_ACTION, 283 | 'rest_cache_empty' => '1', 284 | ], 285 | \admin_url('admin.php') 286 | ), 287 | 'rest_cache_options', 288 | self::NONCE_NAME 289 | ); 290 | } 291 | 292 | /** 293 | * Should the admin functions be shown? 294 | * @return bool 295 | */ 296 | private function showAdmin() : bool 297 | { 298 | return \apply_filters(self::FILTER_SHOW_ADMIN, true) === true; 299 | } 300 | 301 | /** 302 | * Show the admin menu be shown? 303 | * @return bool 304 | */ 305 | private function showAdminMenu() : bool 306 | { 307 | return \apply_filters(self::FILTER_SHOW_ADMIN_MENU, true) === true; 308 | } 309 | 310 | /** 311 | * Show the admin menu bar be shown? 312 | * @return bool 313 | */ 314 | private function showAdminMenuBar() : bool 315 | { 316 | return \apply_filters(self::FILTER_SHOW_ADMIN_BAR_MENU, \is_admin_bar_showing()) === true; 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /src/WpAdmin/Settings.php: -------------------------------------------------------------------------------- 1 | settings; 33 | } 34 | 35 | /** 36 | * Sets the expiration length. 37 | * @param int $length 38 | */ 39 | public function setLength(int $length) 40 | { 41 | $this->settings[self::EXPIRATION][self::LENGTH] = $length; 42 | } 43 | 44 | /** 45 | * Sets the expiration period. 46 | * @param int $period 47 | */ 48 | public function setPeriod(int $period) 49 | { 50 | $this->settings[self::EXPIRATION][self::PERIOD] = $period; 51 | } 52 | 53 | /** 54 | * Sets the bypass value. 55 | * @param string $value 56 | */ 57 | public function setBypass(string $value) 58 | { 59 | $this->settings[self::BYPASS] = $value; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/WpRestApiCache.php: -------------------------------------------------------------------------------- 1 | wp_rest_api_cache = new WpRestApiCache(); 33 | } 34 | 35 | public function tearDown() 36 | { 37 | unset($this->wp_rest_api_cache); 38 | } 39 | 40 | /** 41 | * Test class has constants. 42 | */ 43 | public function testConstants() 44 | { 45 | $expected = [ 46 | WpRestApiCache::FILTER_PREFIX, 47 | WpRestApiCache::ID, 48 | ]; 49 | $constants = $this->getReflection()->getConstants(); 50 | $this->assertNotEmpty($constants); 51 | $this->assertSame($expected, array_values($constants)); 52 | } 53 | 54 | /** 55 | * Test getRestDispatch. 56 | */ 57 | public function testGetRestDispatch() 58 | { 59 | $rest_dispatch = WpRestApiCache::getRestDispatch(); 60 | $this->assertInstanceOf(RestDispatch::class, $rest_dispatch); 61 | } 62 | 63 | /** 64 | * Gets an instance of the \ReflectionObject. 65 | * 66 | * @return \ReflectionObject 67 | */ 68 | private function getReflection() : \ReflectionObject 69 | { 70 | static $reflector; 71 | 72 | if (! ($reflector instanceof \ReflectionObject)) { 73 | $reflector = new \ReflectionObject($this->wp_rest_api_cache); 74 | } 75 | 76 | return $reflector; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /uninstall.php: -------------------------------------------------------------------------------- 1 | %s.', Admin::class ) ); 13 | } 14 | 15 | $reflection = new ReflectionObject( $this ); 16 | $cache_url = $reflection->getMethod( 'getEmptyCacheUrl' ); 17 | $cache_url->setAccessible( true ); 18 | $options = $this->getOptions(); 19 | 20 | settings_errors(); ?> 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | 32 | 33 | 34 | 35 | 38 | 39 | 40 | > 41 | 42 | 43 | > 44 | 45 | 46 | > 47 | 48 | 49 | > 50 | 51 | 52 | > 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | > 64 | 65 | 66 | 67 | 68 | 69 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /wp-rest-api-cache.php: -------------------------------------------------------------------------------- 1 | addOnHook(RestDispatch::class, 'rest_api_init')->initialize(); 22 | add_action('after_setup_theme', function () use ($plugin) { 23 | if (is_admin()) { 24 | $plugin->add(new Admin())->initialize(); 25 | } 26 | }); 27 | 28 | add_filter('site_transient_update_plugins', function ($value) { 29 | if (isset($value) && is_object($value) && (! empty($value->response) && is_array($value->response))) { 30 | unset($value->response[@plugin_basename(__FILE__)]); 31 | } 32 | 33 | return $value; 34 | }); 35 | --------------------------------------------------------------------------------
{$message}