diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000000..69cb76019a --- /dev/null +++ b/.codecov.yml @@ -0,0 +1 @@ +comment: false diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000000..86ed70e654 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,10 @@ +The REST API has been merged into WordPress core! Development and support are no longer taking place in this repository. + +Please post support requests to the WordPress forums at https://wordpress.org/support/forum/how-to-and-troubleshooting/ with the topic tag "rest-api". + +For bugs and patches, please post the issue or patch to WordPress core Trac at https://core.trac.wordpress.org -- Be sure to include full details and reproduction steps about the issue you are experiencing. + +If you are unfamiliar with Trac, you can refer to the "Opening a Ticket" user guide at https://make.wordpress.org/core/handbook/tutorials/trac/opening-a-ticket/ for an introduction! + +Thank you, +The REST API team diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..86ed70e654 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +The REST API has been merged into WordPress core! Development and support are no longer taking place in this repository. + +Please post support requests to the WordPress forums at https://wordpress.org/support/forum/how-to-and-troubleshooting/ with the topic tag "rest-api". + +For bugs and patches, please post the issue or patch to WordPress core Trac at https://core.trac.wordpress.org -- Be sure to include full details and reproduction steps about the issue you are experiencing. + +If you are unfamiliar with Trac, you can refer to the "Opening a Ticket" user guide at https://make.wordpress.org/core/handbook/tutorials/trac/opening-a-ticket/ for an introduction! + +Thank you, +The REST API team diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index c6b9415b0c..0000000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,42 +0,0 @@ -# Scrutinizer Configuration File - -tools: - external_code_coverage: false - - php_sim: - enabled: true - min_mass: 50 - - php_pdepend: - enabled: true - configuration_file: null - suffixes: - - php - excluded_dirs: { } - - php_analyzer: - enabled: true - extensions: - - php - dependency_paths: { } - path_configs: { } - - php_changetracking: - enabled: true - bug_patterns: - - '\bfix(?:es|ed)?\b' - feature_patterns: - - '\badd(?:s|ed)?\b' - - '\bimplement(?:s|ed)?\b' - - php_hhvm: - enabled: true - command: hhvm - extensions: - - php - path_configs: { } - -before_commands: { } -after_commands: { } -artifacts: { } -build_failure_conditions: { } diff --git a/.travis.yml b/.travis.yml index 8e28abaca1..391d386c1f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,25 +8,23 @@ sudo: false matrix: include: - php: 5.6 - env: WP_TRAVISCI=travis:phpunit WP_VERSION=nightly - - php: 5.6 - env: WP_TRAVISCI=travis:phpunit WP_VERSION=latest + env: WP_TRAVISCI=travis:phpunit WP_VERSION=4.6.1 - php: 5.6 env: WP_TRAVISCI=travis:phpvalidate - php: 5.6 env: WP_TRAVISCI=travis:codecoverage - php: 5.5 - env: WP_TRAVISCI=travis:phpunit WP_VERSION=nightly + env: WP_TRAVISCI=travis:phpunit WP_VERSION=4.6.1 - php: 5.4 - env: WP_TRAVISCI=travis:phpunit WP_VERSION=nightly + env: WP_TRAVISCI=travis:phpunit WP_VERSION=4.6.1 - php: 5.3 - env: WP_TRAVISCI=travis:phpunit WP_VERSION=nightly + env: WP_TRAVISCI=travis:phpunit WP_VERSION=4.6.1 - php: 5.2 - env: WP_TRAVISCI=travis:phpunit WP_VERSION=nightly + env: WP_TRAVISCI=travis:phpunit WP_VERSION=4.6.1 - php: hhvm - env: WP_TRAVISCI=travis:phpunit WP_VERSION=nightly + env: WP_TRAVISCI=travis:phpunit WP_VERSION=4.6.1 - php: 7.0 - env: WP_TRAVISCI=travis:phpunit WP_VERSION=nightly + env: WP_TRAVISCI=travis:phpunit WP_VERSION=4.6.1 allow_failures: - php: hhvm fast_finish: true diff --git a/CHANGELOG.md b/CHANGELOG.md index e816055e1a..b2481ba8b1 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,404 @@ # Changelog +## 2.0 Beta 15.0 (October 07, 2016) + +- Introduce support for Post Meta, Term Meta, User Meta, and Comment Meta in +their parent endpoints. + + For your meta fields to be exposed in the REST API, you need to register + them. WordPress includes a `register_meta()` function which is not usually + required to get/set fields, but is required for API support. + + To register your field, simply call register_meta and set the show_in_rest + flag to true. Note: register_meta must be called separately for each meta + key. + + (props @rmccue, @danielbachhuber, @kjbenk, @duncanjbrown, [#2765][gh-2765]) + +- Introduce Settings endpoint. + + Expose options to the REST API with the `register_setting()` function, by + passing `$args = array( 'show_in_rest' => true )`. Note: WordPress 4.7 is + required. See changeset [38635][https://core.trac.wordpress.org/changeset/38635]. + + (props @joehoyle, @fjarrett, @danielbachhuber, @jonathanbardo, + @greatislander, [#2739][gh-2739]) + +- Attachments controller, change permissions check to match core. + + Check for the `upload_files` capability when creating an attachment. + + (props @nullvariable, @adamsilverstein, [#2743][gh-2743]) + +- Add `?{taxonomy}_exclude=` query parameter + + This mirrors our existing support for ?{taxonomy}= filtering in the posts + controller (which allows querying for only records with are associated with + any of the provided term IDs for the specified taxonomy) by adding an + equivalent `_exclude` variant to list IDs of terms for which associated posts + should NOT be returned. + + (props @kadamwhite, [#2756][gh-2756]) + +- Use `get_comment_type()` when comparing updating comment status. + + Comments having a empty `comment_type` within WordPress bites us again. + Fixes a bug where comments could not be updated because of bad comparison + logic. + + (props @joehoyle, [#2753][gh-2753]) + +[gh-2765]: https://github.com/WP-API/WP-API/issues/2765 +[gh-2739]: https://github.com/WP-API/WP-API/issues/2739 +[gh-2743]: https://github.com/WP-API/WP-API/issues/2743 +[gh-2756]: https://github.com/WP-API/WP-API/issues/2756 +[gh-2753]: https://github.com/WP-API/WP-API/issues/2753 + +## 2.0 Beta 14.0 (September 30, 2016) + +- Add support for password protected posts + + Password protected posts are now fully supported, you can create edit and read password protected posts in the REST API. There is now a `protected` attribute in the `content` and `excerpt` fields in post response. + + To view password protected posts via the API, use the `password` query parameter to provide the post's password. + + (props @joehoyle, [#2720][gh-2720]) + +- Allow returning an error from field update callbacks + + Fields added via `register_rest_field` can now return an instance of `WP_Error` in the `update_callback`. + + (props @rmccue, [#2702][gh-2702]) + +- Update the wp-api.js client from the client-js repo. + + (props @joehoyle, [#2746][gh-2746]) + +- Add `relevance` `orderby` to posts endpoint + + (props @websupporter, [#2579][gh-2579]) + +- Ability to order by `slug`, `email` and `url` on the users endpoints. + + (props @joehoyle, [#2721][gh-2721]) + +- Add `sticky` parameter to the posts endpoint. + + (props @joehoyle, [#2708][gh-2708]) + +- Add link to comment children, allowing threaded comment querying + + (props @BE-Webdesign, [#2662][gh-2662], [#1612][gh-1612]) + +- Avoid unnecessary SQL query by passing `$user_nicename` + + (props @danielbachhuber, [#2435][gh-2435]) + +- Don't allow reading / creating of posts with no parent + + (props @rachelbaker, [#2744][gh-2744]) + +- Mark Users' `capabilities` property as readonly + + (props @danielbachhuber, [#2440][gh-2440]) + +- Mark some post properties as readonly + + (props @danielbachhuber, [#2438][gh-2438], [#2439][gh-2439]) + +- Use WPINC instead of wp-includes/ + + (props @websupporter, [#2461][gh-2461]) + +- Return error, if user can't list users & content=edit + + (props @websupporter, [#2463][gh-2463]) + +- Conditionally model the term response based on its schema + + (props @danielbachhuber, [#2470][gh-2470]) + +- Include Post data on the response object when declared + + (props @websupporter, [#2423][gh-2423], [#2416][gh-2416]) + +- Add boolean type support to rest_validate_request_arg() + + (props @westonruter, [#2478][gh-2478]) + +- Fix create/update requests not processing data included in the schema + + (props @websupporter, [#2479][gh-2479]) + +- Remove Unused Parameter in lib/endpoints/class-wp-rest-controller.php + + (props @hideokamoto, [#2500][gh-2500]) + +- Update post schema status description to reflect csv support. + + (props @coderkevin, [#2534][gh-2534]) + +- Allow Comments to be created with a passed `author_ip` + + (props @rachelbaker, [#1880][gh-1880]) + +- The get_the_excerpt filter expects the post object as of WP 4.5. + + (props @lgedeon, [#2553][gh-2553]) + +- Introduce WP_REST_Controller::get_post() for allowing plugins to mutate +get_post()'s return value + + (props @westonruter, [#2535][gh-2535]) + +- Use `show_in_rest` to determine "public" post types to check + + (props @danielbachhuber, [#2384][gh-2384]) + +- #2426 Fix inconsistent type for user caps + + (props @BE-Webdesign, [#2429][gh-2429], [#2426][gh-2426]) + +- Define user `type` as a string, not an array + + (props @danielbachhuber, [#2556][gh-2556]) + +- Fix failing test: Typecast the user_id in search to a string + + (props @rachelbaker, [#2617][gh-2617]) + +- #2587 Fix registered date schema + + (props @BE-Webdesign, [#2628][gh-2628], [#2587][gh-2587]) + +- Fix forum url and installer-name in readme + + (props @torounit, [#2656][gh-2656]) + +- Document options of the "status" parameter for Post collection GETs + + (props @kadamwhite, [#2645][gh-2645]) + +- Improve WP_REST_Controller::filter_response_by_context(). + + (props @tfrommen, [#2641][gh-2641]) + +- #2424 Consistent slashes in rest_url() usage + + (props @BE-Webdesign, [#2428][gh-2428], [#2424][gh-2424]) + +- Add filters to allow for relevance search + + (props @websupporter, [#2665][gh-2665]) + +- Alter default comment sort order to be "desc" + + (props @kadamwhite, [#2684][gh-2684]) + +- Add raw and rendered to revisions schema + + (props @websupporter, [#2693][gh-2693]) + +- "WP API" -> "WordPress REST API" in README files + + (props @kadamwhite, [#2697][gh-2697]) + +- Improve boolean validation from schema + + (props @BE-Webdesign, [#2704][gh-2704], [#2616][gh-2616]) + +- Fix typo (PUT vs POST) in readme.md + + (props @kadamwhite, [#2716][gh-2716]) + +- Add Codecov configuration + + (props @danielbachhuber, [#2718][gh-2718]) + +- Fix inefficiency in users endpoint using `search => **` + + (props @joehoyle, [#2722][gh-2722]) + +- Ensure the terms list is a list + + (props @joehoyle, [#2724][gh-2724]) + +- Added @return on handle_featured_media() doc + + (props @vishalkakadiya, [#2725][gh-2725]) + +- #2730 Update attachments fields added with `register_rest_field` + + (props @BE-Webdesign, [#2731][gh-2731], [#2730][gh-2730]) + +- #2582 Ensure the `roles` property is always an array + + (props @BE-Webdesign, [#2728][gh-2728], [#2582][gh-2582]) + +- Move post_password_required filtering to preparation + + (props @rmccue, [#2735][gh-2735]) + +- Use wrapper for `sanitize_title` to avoid messed up slugs. + + (props @joehoyle, [#2723][gh-2723]) + +- Force per_page to override the filter variable + + (props @rmccue, [#2699][gh-2699]) + +[gh-1612]: https://github.com/WP-API/WP-API/issues/1612 +[gh-1880]: https://github.com/WP-API/WP-API/issues/1880 +[gh-2384]: https://github.com/WP-API/WP-API/issues/2384 +[gh-2416]: https://github.com/WP-API/WP-API/issues/2416 +[gh-2423]: https://github.com/WP-API/WP-API/issues/2423 +[gh-2424]: https://github.com/WP-API/WP-API/issues/2424 +[gh-2426]: https://github.com/WP-API/WP-API/issues/2426 +[gh-2428]: https://github.com/WP-API/WP-API/issues/2428 +[gh-2429]: https://github.com/WP-API/WP-API/issues/2429 +[gh-2435]: https://github.com/WP-API/WP-API/issues/2435 +[gh-2436]: https://github.com/WP-API/WP-API/issues/2436 +[gh-2438]: https://github.com/WP-API/WP-API/issues/2438 +[gh-2439]: https://github.com/WP-API/WP-API/issues/2439 +[gh-2440]: https://github.com/WP-API/WP-API/issues/2440 +[gh-2441]: https://github.com/WP-API/WP-API/issues/2441 +[gh-2461]: https://github.com/WP-API/WP-API/issues/2461 +[gh-2463]: https://github.com/WP-API/WP-API/issues/2463 +[gh-2470]: https://github.com/WP-API/WP-API/issues/2470 +[gh-2478]: https://github.com/WP-API/WP-API/issues/2478 +[gh-2479]: https://github.com/WP-API/WP-API/issues/2479 +[gh-2500]: https://github.com/WP-API/WP-API/issues/2500 +[gh-2534]: https://github.com/WP-API/WP-API/issues/2534 +[gh-2535]: https://github.com/WP-API/WP-API/issues/2535 +[gh-2553]: https://github.com/WP-API/WP-API/issues/2553 +[gh-2556]: https://github.com/WP-API/WP-API/issues/2556 +[gh-2579]: https://github.com/WP-API/WP-API/issues/2579 +[gh-2582]: https://github.com/WP-API/WP-API/issues/2582 +[gh-2587]: https://github.com/WP-API/WP-API/issues/2587 +[gh-2616]: https://github.com/WP-API/WP-API/issues/2616 +[gh-2617]: https://github.com/WP-API/WP-API/issues/2617 +[gh-2628]: https://github.com/WP-API/WP-API/issues/2628 +[gh-2641]: https://github.com/WP-API/WP-API/issues/2641 +[gh-2645]: https://github.com/WP-API/WP-API/issues/2645 +[gh-2656]: https://github.com/WP-API/WP-API/issues/2656 +[gh-2662]: https://github.com/WP-API/WP-API/issues/2662 +[gh-2665]: https://github.com/WP-API/WP-API/issues/2665 +[gh-2684]: https://github.com/WP-API/WP-API/issues/2684 +[gh-2693]: https://github.com/WP-API/WP-API/issues/2693 +[gh-2697]: https://github.com/WP-API/WP-API/issues/2697 +[gh-2699]: https://github.com/WP-API/WP-API/issues/2699 +[gh-2702]: https://github.com/WP-API/WP-API/issues/2702 +[gh-2704]: https://github.com/WP-API/WP-API/issues/2704 +[gh-2708]: https://github.com/WP-API/WP-API/issues/2708 +[gh-2716]: https://github.com/WP-API/WP-API/issues/2716 +[gh-2718]: https://github.com/WP-API/WP-API/issues/2718 +[gh-2720]: https://github.com/WP-API/WP-API/issues/2720 +[gh-2721]: https://github.com/WP-API/WP-API/issues/2721 +[gh-2722]: https://github.com/WP-API/WP-API/issues/2722 +[gh-2723]: https://github.com/WP-API/WP-API/issues/2723 +[gh-2724]: https://github.com/WP-API/WP-API/issues/2724 +[gh-2725]: https://github.com/WP-API/WP-API/issues/2725 +[gh-2728]: https://github.com/WP-API/WP-API/issues/2728 +[gh-2730]: https://github.com/WP-API/WP-API/issues/2730 +[gh-2731]: https://github.com/WP-API/WP-API/issues/2731 +[gh-2735]: https://github.com/WP-API/WP-API/issues/2735 +[gh-2744]: https://github.com/WP-API/WP-API/issues/2744 +[gh-2746]: https://github.com/WP-API/WP-API/issues/2746 + +## 2.0 Beta 13.0 (March 29, 2016) + +- BREAKING CHANGE: Fix Content-Disposition header parsing. + + Allows regular form submissions from HTML forms, as well as properly formatted HTTP requests from clients. Note: this breaks backwards compatibility, as previously, the header parsing was completely wrong. + + (props @rmccue, [#2239](https://github.com/WP-API/WP-API/pull/2239)) + +- BREAKING CHANGE: Use compact links for embedded responses if they are available. + + Introduces curies for sites running WordPress 4.5 or greater; no changes for those running WordPress 4.4. + + (props @joehoyle, [#2412](https://github.com/WP-API/WP-API/pull/2412)) + +- JavaScript client updates: + + * Support lodash, plus older and newer underscore: add an alias for `_.contains` + * Add args and options on the model/collection prototypes + * Rework category/tag mixins to support new API structure + * Add workaround for the null/empty values returned by the API when creating a new post - these values are not accepted for subsequent updates/saves, so explicitly excluding them. See https://github.com/WP-API/WP-API/pull/2393 + * Better handling of the (special) `me` endpoint + * Schema parsing cleanup + * Introduce `wp.api.loadPromise` so developers can ensure api load complete before using + + (props @adamsilverstein, [#2403](https://github.com/WP-API/WP-API/pull/2403)) + +- Only adds alternate link header for publicly viewable CPTs. + + (props @bradyvercher, [#2387](https://github.com/WP-API/WP-API/pull/2387)) + +- Adds `roles` param for `GET /wp/v2/users`. + + (props @BE-Webdesign, [#2372](https://github.com/WP-API/WP-API/pull/2372)) + +- Declares `password` in user schema, but never displays it. + + (props @danielbachhuber, [#2386](https://github.com/WP-API/WP-API/pull/2386)) + +- Permits `edit` context for requests which can edit the user. + + (props @danielbachhuber, [#2383](https://github.com/WP-API/WP-API/pull/2383)) + +- Adds `rest_pre_insert_{$taxonomy}` filter for terms. + + (props @kjbenk, [#2377](https://github.com/WP-API/WP-API/pull/2377)) + +- Supports taxonomy collection args on posts endpoint. + + (props @joehoyle, [#2287](https://github.com/WP-API/WP-API/pull/2287)) + +- Removes post meta link from post response. + + (props @joehoyle, [#2288](https://github.com/WP-API/WP-API/pull/2288)) + +- Registers `description` attribute when registering args from schema. + + (props @danielbachhuber, [#2362](https://github.com/WP-API/WP-API/pull/2362)) + +- Uses `$comment` from the database with `rest_insert_comment` action. + + (props @danielbachhuber, [#2349](https://github.com/WP-API/WP-API/pull/2349)) + +- Removes unnecessary global variables from users controller. + + (props @claudiosmweb, [#2335](https://github.com/WP-API/WP-API/pull/2335)) + +- Ensures `GET /wp/v2/categories` with out of bounds offset doesn't return results. + + (props @danielbachhuber, [#2313](https://github.com/WP-API/WP-API/pull/2313)) + +- Adds top-level support for date queries on posts and comments. + + (props @BE-Webdesign, [#2266](https://github.com/WP-API/WP-API/pull/2266), [#2291](https://github.com/WP-API/WP-API/pull/2291)) + +- Respects `show_avatars` setting for comments. + + (props @BE-Webdesign, [#2271](https://github.com/WP-API/WP-API/pull/2271)) + +- Uses cached `get_the_terms()` for terms-for-post for better performance. + + (props @rmccue, [#2257](https://github.com/WP-API/WP-API/pull/2257)) + +- Ensures comments search is an empty string. + + (props @rmccue, [#2256](https://github.com/WP-API/WP-API/pull/2256)) + +- If no title is provided in create attachment request or file metadata, falls back to filename. + + (props @danielbachhuber, [#2254](https://github.com/WP-API/WP-API/pull/2254)) + +- Removes unused `$img_url_basename` variable in attachments controller. + + (props @danielbachhuber, [#2250](https://github.com/WP-API/WP-API/pull/2250)) + ## 2.0 Beta 12.0 (February 9, 2016) - BREAKING CHANGE: Removes meta endpoints from primary plugin. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md old mode 100644 new mode 100755 index 4a90bcb701..1214a2486f --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,31 +1,11 @@ # Contributing + Hi, and thanks for considering contributing! Before you do though, here's a few notes on how best to contribute. Don't worry, I'll keep it short! -## Best Practices - -### Commit Messages -Commit messages should follow the standard laid out in the git manual; that is, -a one-line summary () - - Short (50 chars or less) summary of changes - - More detailed explanatory text, if necessary. Wrap it to about 72 - characters or so. In some contexts, the first line is treated as the - subject of an email and the rest of the text as the body. The blank - line separating the summary from the body is critical (unless you omit - the body entirely); tools like rebase can get confused if you run the - two together. - - Further paragraphs come after blank lines. - - - Bullet points are okay, too - - - Typically a hyphen or asterisk is used for the bullet, preceded by a - single space, with blank lines in between, but conventions vary here +The WP REST API code now lives in core WordPress, so any contributions to the +API (**including new issues**) should be made via +[WordPress core Trac](https://core.trac.wordpress.org). -## Commit Process -Changes are proposed in the form of pull requests by you, the contributor! After -submitting your proposed changes, a member of the API team will review your -commits and mark them for merge by assigning it to themselves. Your pull request -will then be merged after final review by another member. +See also: +[Contributing to WordPress](https://codex.wordpress.org/Contributing_to_WordPress) diff --git a/README.md b/README.md old mode 100644 new mode 100755 index 9a3074c417..e5b24d1973 --- a/README.md +++ b/README.md @@ -1,18 +1,22 @@ -# WP REST API v2.0 (WP-API) +# WP REST API v2.0 (formerly known as WP-API) Access your WordPress site's data through an easy-to-use HTTP REST API. -[![Build Status](https://travis-ci.org/WP-API/WP-API.svg?branch=develop)](https://travis-ci.org/WP-API/WP-API) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/WP-API/WP-API/badges/quality-score.png?b=develop)](https://scrutinizer-ci.com/g/WP-API/WP-API/?branch=develop) -[![codecov.io](http://codecov.io/github/WP-API/WP-API/coverage.svg?branch=develop)](http://codecov.io/github/WP-API/WP-API?branch=develop) +**Development is no longer taking place in this repository**. -## WARNING +- For support requests, use the + [WordPress forums](https://wordpress.org/support/). +- For bugs and patches, use + [WordPress core Trac](https://core.trac.wordpress.org). + Be sure to include full details and reproduction steps about the issue you are + experiencing, and ideally a patch with unit tests. -The **"develop"** branch is undergoing substantial changes and is **NOT COMPLETE OR STABLE**. [Read the in-progress documentation](http://v2.wp-api.org/) to introduce yourself to endpoints, internal patterns, and implementation details. +The **"develop"** branch is version 2 which represents the last "beta" versions of the +[plugin](https://wordpress.org/plugins/rest-api/). +[Read the documentation](https://developer.wordpress.org/rest-api/) +to introduce yourself to endpoints, internal patterns, and implementation details. -The **"master"** branch represents a **BETA** of our next version release. - -The latest **stable** version is available from the [WordPress Plugin Directory](https://wordpress.org/plugins/rest-api/). +The **"master"** branch represents the **legacy** version of the REST API. ## About @@ -26,15 +30,15 @@ site's data in simple JSON format, including users, posts, taxonomies and more. Retrieving or updating data is as simple as sending a HTTP request. Want to get your site's posts? Simply send a `GET` request to `/wp-json/wp/v2/posts`. -Update user with ID 4? Send a `POST` request to `/wp-json/wp/v2/users/4`. Get all -posts with the search term "awesome"? `GET /wp-json/wp/v2/posts?filter[s]=awesome`. -It's that easy. +Update user with ID 4? Send a `PUT` request to `/wp-json/wp/v2/users/4`. Get the page +with slug "about-me"? `GET /wp-json/wp/v2/pages?slug=about-me`. Get all posts with +the search term "awesome"? `GET /wp-json/wp/v2/posts?search=awesome`. It's that easy. -WP API exposes a simple yet easy interface to WP Query, the posts API, post meta -API, users API, revisions API and many more. Chances are, if you can do it with -WordPress, WP API will let you do it. +The WordPress REST API exposes a simple yet easy interface to WP Query, the posts +API, post meta API, users API, revisions API and many more. Chances are, if you +can do it with WordPress, the API will let you do it. -WP API also includes an easy-to-use JavaScript API based on Backbone models, +The REST API also includes an easy-to-use JavaScript API based on Backbone models, allowing plugin and theme developers to get up and running without needing to know anything about the details of getting connected. @@ -42,30 +46,56 @@ Check out [our documentation][docs] for information on what's available in the API and how to use it. We've also got documentation on extending the API with extra data for plugin and theme developers! -There's no fixed timeline for integration into core at this time, but getting closer! +The API code in this plugin is currently integrated into core WordPress starting in +[4.7](https://wordpress.org/news/2016/12/vaughan/). + +**Development is no longer taking place in this repository**. + +- For support requests, use the + [WordPress forums](https://wordpress.org/support/). +- For bugs and patches, use + [WordPress core Trac](https://core.trac.wordpress.org). + Be sure to include full details and reproduction steps about the issue you are + experiencing, and ideally a patch with unit tests. + +## Quick Setup +Want to test out the WP REST API? The easiest way is just to install a +recent version of WordPress +([4.7](https://wordpress.org/news/2016/12/vaughan/) or later). -## Installation +### Testing -Drop this directory in and activate it. You need to be using pretty permalinks -to use the plugin, as it uses custom rewrite rules to power the API. +You can also set up a development environment to work on the API code. -Also, be sure to use the Subversion `trunk` branch of WordPress Core as there are potentially recent commits to Core that the REST API relies on. See the [WordPress.org website](https://wordpress.org/download/svn/) for simple instructions. +See the +[instructions for running the WordPress PHPUnit test suite](https://make.wordpress.org/core/handbook/testing/automated-testing/phpunit/) +to get started. ## Issue Tracking -All tickets for the project are being tracked on [GitHub][]. You can also take a -look at the [recent updates][] for the project. +All tickets for the project are being tracked on +[WordPress core Trac](https://core.trac.wordpress.org). + +Some previous issues can be found on the +[issue tracker for this repository](/WP-API/WP-API/issues); +however, now that development of the API has moved to core Trac, new issues +**should not be filed here**. + +## Contributing + +Want to get involved? Check out [Contributing.md][contributing] for details on +submitting fixes and new features. ## Security We take the security of the API extremely seriously. If you think you've found a security issue with the API (whether information disclosure, privilege -escalation, or another issue), we'd appreciate responsible disclosure as soon as -possible. +escalation, or another issue), we'd appreciate responsible disclosure as soon +as possible. -To report a security issue, you can either email `security[at]wordpress.org`, or -[file an issue on HackerOne][hackerone]. We will attempt to give an initial +To report a security issue, you can either email `security[at]wordpress.org`, +or [file an issue on HackerOne][hackerone]. We will attempt to give an initial response to security issues within 48 hours at most, however keep in mind that the team is distributed across various timezones, and delays may occur as we discuss internally. @@ -77,7 +107,6 @@ WordPress on your own server. **Do not test on servers you do not own.**) [GPLv2+](http://www.gnu.org/licenses/gpl-2.0.html) -[docs]: http://v2.wp-api.org/ -[GitHub]: https://github.com/WP-API/WP-API/issues -[recent updates]: https://make.wordpress.org/core/tag/json-api/ +[docs]: https://developer.wordpress.org/rest-api/ +[contributing]: CONTRIBUTING.md [hackerone]: https://hackerone.com/wp-api diff --git a/bin/readme.txt b/bin/readme.txt index b82f37c996..86e18e2409 100644 --- a/bin/readme.txt +++ b/bin/readme.txt @@ -2,8 +2,8 @@ Contributors: rmccue, rachelbaker, danielbachhuber, joehoyle Tags: json, rest, api, rest-api Requires at least: 4.4 -Tested up to: 4.5-alpha -Stable tag: 2.0-beta12 +Tested up to: 4.5 +Stable tag: 2.0-beta13 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -16,9 +16,9 @@ This plugin provides an easy to use REST API, available via HTTP. Grab your site Want to get your site's posts? Simply send a `GET` request to `/wp-json/wp/v2/posts`. Update user with ID 4? Send a `PUT` request to `/wp-json/wp/v2/users/4`. Get all posts with the search term "awesome"? `GET /wp-json/wp/v2/posts?filter[s]=awesome`. It's that easy. -WP API exposes a simple yet easy interface to WP Query, the posts API, post meta API, users API, revisions API and many more. Chances are, if you can do it with WordPress, WP API will let you do it. +The WordPress REST API exposes a simple yet easy interface to WP Query, the posts API, post meta API, users API, revisions API and many more. Chances are, if you can do it with WordPress, the API will let you do it. -WP API also includes an easy-to-use Javascript API based on Backbone models, allowing plugin and theme developers to get up and running without needing to know anything about the details of getting connected. +The REST API also includes an easy-to-use JavaScript API based on Backbone models, allowing plugin and theme developers to get up and running without needing to know anything about the details of getting connected. Check out [our documentation][docs] for information on what's available in the API and how to use it. We've also got documentation on extending the API with extra data for plugin and theme developers! @@ -38,6 +38,100 @@ Once you've installed and activated the plugin, [check out the documentation](ht == Changelog == += 2.0 Beta 13.0 (March 29, 2016) = + +* BREAKING CHANGE: Fix Content-Disposition header parsing. + + Allows regular form submissions from HTML forms, as well as properly formatted HTTP requests from clients. Note: this breaks backwards compatibility, as previously, the header parsing was completely wrong. + + (props @rmccue, [#2239](https://github.com/WP-API/WP-API/pull/2239)) + +* BREAKING CHANGE: Use compact links for embedded responses if they are available. + + Introduces curies for sites running WordPress 4.5 or greater; no changes for those running WordPress 4.4. + + (props @joehoyle, [#2412](https://github.com/WP-API/WP-API/pull/2412)) + +* JavaScript client updates: + + * Support lodash, plus older and newer underscore: add an alias for `_.contains` + * Add args and options on the model/collection prototypes + * Rework category/tag mixins to support new API structure + * Add workaround for the null/empty values returned by the API when creating a new post * these values are not accepted for subsequent updates/saves, so explicitly excluding them. See https://github.com/WP-API/WP-API/pull/2393 + * Better handling of the (special) `me` endpoint + * Schema parsing cleanup + * Introduce `wp.api.loadPromise` so developers can ensure api load complete before using + + (props @adamsilverstein, [#2403](https://github.com/WP-API/WP-API/pull/2403)) + +* Only adds alternate link header for publicly viewable CPTs. + + (props @bradyvercher, [#2387](https://github.com/WP-API/WP-API/pull/2387)) + +* Adds `roles` param for `GET /wp/v2/users`. + + (props @BE-Webdesign, [#2372](https://github.com/WP-API/WP-API/pull/2372)) + +* Declares `password` in user schema, but never displays it. + + (props @danielbachhuber, [#2386](https://github.com/WP-API/WP-API/pull/2386)) + +* Permits `edit` context for requests which can edit the user. + + (props @danielbachhuber, [#2383](https://github.com/WP-API/WP-API/pull/2383)) + +* Adds `rest_pre_insert_{$taxonomy}` filter for terms. + + (props @kjbenk, [#2377](https://github.com/WP-API/WP-API/pull/2377)) + +* Supports taxonomy collection args on posts endpoint. + + (props @joehoyle, [#2287](https://github.com/WP-API/WP-API/pull/2287)) + +* Removes post meta link from post response. + + (props @joehoyle, [#2288](https://github.com/WP-API/WP-API/pull/2288)) + +* Registers `description` attribute when registering args from schema. + + (props @danielbachhuber, [#2362](https://github.com/WP-API/WP-API/pull/2362)) + +* Uses `$comment` from the database with `rest_insert_comment` action. + + (props @danielbachhuber, [#2349](https://github.com/WP-API/WP-API/pull/2349)) + +* Removes unnecessary global variables from users controller. + + (props @claudiosmweb, [#2335](https://github.com/WP-API/WP-API/pull/2335)) + +* Ensures `GET /wp/v2/categories` with out of bounds offset doesn't return results. + + (props @danielbachhuber, [#2313](https://github.com/WP-API/WP-API/pull/2313)) + +* Adds top-level support for date queries on posts and comments. + + (props @BE-Webdesign, [#2266](https://github.com/WP-API/WP-API/pull/2266), [#2291](https://github.com/WP-API/WP-API/pull/2291)) + +* Respects `show_avatars` setting for comments. + + (props @BE-Webdesign, [#2271](https://github.com/WP-API/WP-API/pull/2271)) + +* Uses cached `get_the_terms()` for terms-for-post for better performance. + + (props @rmccue, [#2257](https://github.com/WP-API/WP-API/pull/2257)) + +* Ensures comments search is an empty string. + + (props @rmccue, [#2256](https://github.com/WP-API/WP-API/pull/2256)) + +* If no title is provided in create attachment request or file metadata, falls back to filename. + + (props @danielbachhuber, [#2254](https://github.com/WP-API/WP-API/pull/2254)) + +* Removes unused `$img_url_basename` variable in attachments controller. + + (props @danielbachhuber, [#2250](https://github.com/WP-API/WP-API/pull/2250)) + = 2.0 Beta 12.0 (February 9, 2016) = * BREAKING CHANGE: Removes meta endpoints from primary plugin. diff --git a/composer.json b/composer.json index 6a85df3fe7..685dbfd2f5 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ ], "support": { "issues": "https://github.com/WP-API/WP-API/issues", - "forum": "https://wordpress.org/support/plugin/json-rest-api" + "forum": "https://wordpress.org/support/plugin/rest-api" }, "require": { "composer/installers": "~1.0" @@ -22,7 +22,7 @@ "wp-coding-standards/wpcs": "0.6.0" }, "extra": { - "installer-name": "json-rest-api" + "installer-name": "rest-api" }, "scripts": { "post-install-cmd": "\"vendor/bin/phpcs\" --config-set installed_paths vendor/wp-coding-standards/wpcs", diff --git a/core-integration.php b/core-integration.php index 38b261ebed..eeb6fb53df 100644 --- a/core-integration.php +++ b/core-integration.php @@ -28,10 +28,53 @@ function wp_parse_slug_list( $list ) { $list = preg_split( '/[\s,]+/', $list ); } - foreach ( $list as $key => $value ) { - $list[ $key ] = sanitize_title( $value ); + return array_unique( array_map( 'sanitize_title', $list ) ); + } +} + +if ( ! function_exists( 'rest_get_server' ) ) { + /** + * Retrieves the current REST server instance. + * + * Instantiates a new instance if none exists already. + * + * @since 4.5.0 + * + * @global WP_REST_Server $wp_rest_server REST server instance. + * + * @return WP_REST_Server REST server instance. + */ + function rest_get_server() { + /* @var WP_REST_Server $wp_rest_server */ + global $wp_rest_server; + + if ( empty( $wp_rest_server ) ) { + /** + * Filter the REST Server Class. + * + * This filter allows you to adjust the server class used by the API, using a + * different class to handle requests. + * + * @since 4.4.0 + * + * @param string $class_name The name of the server class. Default 'WP_REST_Server'. + */ + $wp_rest_server_class = apply_filters( 'wp_rest_server_class', 'WP_REST_Server' ); + $wp_rest_server = new $wp_rest_server_class; + + /** + * Fires when preparing to serve an API request. + * + * Endpoint objects should be created and register their hooks on this action rather + * than another action to ensure they're only loaded when needed. + * + * @since 4.4.0 + * + * @param WP_REST_Server $wp_rest_server Server object. + */ + do_action( 'rest_api_init', $wp_rest_server ); } - return array_unique( $list ); + return $wp_rest_server; } } diff --git a/extras.php b/extras.php index 2318c34a39..9cb7461a76 100755 --- a/extras.php +++ b/extras.php @@ -24,7 +24,7 @@ function rest_register_scripts() { // Use minified scripts if SCRIPT_DEBUG is not on. $suffix = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min'; - wp_register_script( 'wp-api', plugins_url( 'wp-api' . $suffix . '.js', __FILE__ ), array( 'jquery', 'backbone', 'underscore' ), '1.1', true ); + wp_register_script( 'wp-api', plugins_url( 'wp-api' . $suffix . '.js', __FILE__ ), array( 'jquery', 'backbone', 'underscore' ), '1.2', true ); $settings = array( 'root' => esc_url_raw( get_rest_url() ), diff --git a/lib/endpoints/class-wp-rest-attachments-controller.php b/lib/endpoints/class-wp-rest-attachments-controller.php index 38c86ba60f..08eb75611b 100755 --- a/lib/endpoints/class-wp-rest-attachments-controller.php +++ b/lib/endpoints/class-wp-rest-attachments-controller.php @@ -6,22 +6,22 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller { * Determine the allowed query_vars for a get_items() response and * prepare for WP_Query. * - * @param array $prepared_args - * @param WP_REST_Request $request - * @return array $query_args + * @param array $prepared_args Optional. Array of prepared arguments. + * @param WP_REST_Request $request Optional. Request to prepare items for. + * @return array Array of query arguments. */ protected function prepare_items_query( $prepared_args = array(), $request = null ) { $query_args = parent::prepare_items_query( $prepared_args, $request ); - if ( empty( $query_args['post_status'] ) || ! in_array( $query_args['post_status'], array( 'inherit', 'private', 'trash' ) ) ) { + if ( empty( $query_args['post_status'] ) || ! in_array( $query_args['post_status'], array( 'inherit', 'private', 'trash' ), true ) ) { $query_args['post_status'] = 'inherit'; } $media_types = $this->get_media_types(); - if ( ! empty( $request['media_type'] ) && in_array( $request['media_type'], array_keys( $media_types ) ) ) { + if ( ! empty( $request['media_type'] ) && isset( $media_types[ $request['media_type'] ] ) ) { $query_args['post_mime_type'] = $media_types[ $request['media_type'] ]; } if ( ! empty( $request['mime_type'] ) ) { $parts = explode( '/', $request['mime_type'] ); - if ( isset( $media_types[ $parts[0] ] ) && in_array( $request['mime_type'], $media_types[ $parts[0] ] ) ) { + if ( isset( $media_types[ $parts[0] ] ) && in_array( $request['mime_type'], $media_types[ $parts[0] ], true ) ) { $query_args['post_mime_type'] = $request['mime_type']; } } @@ -32,7 +32,7 @@ protected function prepare_items_query( $prepared_args = array(), $request = nul * Check if a given request has access to create an attachment. * * @param WP_REST_Request $request Full details about the request. - * @return WP_Error|boolean + * @return WP_Error|true Boolean true if the attachment may be created, or a WP_Error if not. */ public function create_item_permissions_check( $request ) { $ret = parent::create_item_permissions_check( $request ); @@ -40,15 +40,13 @@ public function create_item_permissions_check( $request ) { return $ret; } - // "upload_files" cap is returned for an attachment by $post_type_obj->cap->create_posts - $post_type_obj = get_post_type_object( $this->post_type ); - if ( ! current_user_can( $post_type_obj->cap->create_posts ) || ! current_user_can( $post_type_obj->cap->edit_posts ) ) { + if ( ! current_user_can( 'upload_files' ) ) { return new WP_Error( 'rest_cannot_create', __( 'Sorry, you are not allowed to upload media on this site.' ), array( 'status' => 400 ) ); } - // Attaching media to a post requires ability to edit said post + // Attaching media to a post requires ability to edit said post. if ( ! empty( $request['post'] ) ) { - $parent = get_post( (int) $request['post'] ); + $parent = $this->get_post( (int) $request['post'] ); $post_parent_type = get_post_type_object( $parent->post_type ); if ( ! current_user_can( $post_parent_type->cap->edit_post, $request['post'] ) ) { return new WP_Error( 'rest_cannot_edit', __( 'Sorry, you are not allowed to upload media to this resource.' ), array( 'status' => rest_authorization_required_code() ) ); @@ -59,14 +57,14 @@ public function create_item_permissions_check( $request ) { } /** - * Create a single attachment + * Create a single attachment. * - * @param WP_REST_Request $request Full details about the request - * @return WP_Error|WP_REST_Response + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response Response object on success, WP_Error object on failure. */ public function create_item( $request ) { - if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ) ) ) { + if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ), true ) ) { return new WP_Error( 'rest_invalid_param', __( 'Invalid parent type.' ), array( 'status' => 400 ) ); } @@ -116,16 +114,16 @@ public function create_item( $request ) { $id = wp_insert_post( $attachment, true ); if ( is_wp_error( $id ) ) { - if ( in_array( $id->get_error_code(), array( 'db_update_error' ) ) ) { + if ( 'db_update_error' === $id->get_error_code() ) { $id->add_data( array( 'status' => 500 ) ); } else { $id->add_data( array( 'status' => 400 ) ); } return $id; } - $attachment = get_post( $id ); + $attachment = $this->get_post( $id ); - /** Include admin functions to get access to wp_generate_attachment_metadata() */ + // Include admin functions to get access to wp_generate_attachment_metadata(). require_once ABSPATH . 'wp-admin/includes/admin.php'; wp_update_attachment_metadata( $id, wp_generate_attachment_metadata( $id, $file ) ); @@ -134,13 +132,16 @@ public function create_item( $request ) { update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( $request['alt_text'] ) ); } - $this->update_additional_fields_for_object( $attachment, $request ); + $fields_update = $this->update_additional_fields_for_object( $attachment, $request ); + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } $request->set_param( 'context', 'edit' ); $response = $this->prepare_item_for_response( $attachment, $request ); $response = rest_ensure_response( $response ); $response->set_status( 201 ); - $response->header( 'Location', rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $id ) ) ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $id ) ) ); /** * Fires after a single attachment is created or updated via the REST API. @@ -156,13 +157,13 @@ public function create_item( $request ) { } /** - * Update a single post + * Update a single post. * - * @param WP_REST_Request $request Full details about the request - * @return WP_Error|WP_REST_Response + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response Response object on success, WP_Error object on failure. */ public function update_item( $request ) { - if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ) ) ) { + if ( ! empty( $request['post'] ) && in_array( get_post_type( $request['post'] ), array( 'revision', 'attachment' ), true ) ) { return new WP_Error( 'rest_invalid_param', __( 'Invalid parent type.' ), array( 'status' => 400 ) ); } $response = parent::update_item( $request ); @@ -177,7 +178,13 @@ public function update_item( $request ) { update_post_meta( $data['id'], '_wp_attachment_image_alt', $request['alt_text'] ); } - $attachment = get_post( $request['id'] ); + $attachment = $this->get_post( $request['id'] ); + + $fields_update = $this->update_additional_fields_for_object( $attachment, $request ); + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + $request->set_param( 'context', 'edit' ); $response = $this->prepare_item_for_response( $attachment, $request ); $response = rest_ensure_response( $response ); @@ -189,10 +196,10 @@ public function update_item( $request ) { } /** - * Prepare a single attachment for create or update + * Prepare a single attachment for create or update. * - * @param WP_REST_Request $request Request object - * @return WP_Error|stdClass $prepared_attachment Post object + * @param WP_REST_Request $request Request object. + * @return WP_Error|stdClass $prepared_attachment Post object. */ protected function prepare_item_for_database( $request ) { $prepared_attachment = parent::prepare_item_for_database( $request ); @@ -213,11 +220,11 @@ protected function prepare_item_for_database( $request ) { } /** - * Prepare a single attachment output for response + * Prepare a single attachment output for response. * - * @param WP_Post $post Post object - * @param WP_REST_Request $request Request object - * @return WP_REST_Response $response + * @param WP_Post $post Post object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. */ public function prepare_item_for_response( $post, $request ) { $response = parent::prepare_item_for_response( $post, $request ); @@ -232,7 +239,7 @@ public function prepare_item_for_response( $post, $request ) { $data['post'] = ! empty( $post->post_parent ) ? (int) $post->post_parent : null; $data['source_url'] = wp_get_attachment_url( $post->ID ); - // Ensure empty details is an empty object + // Ensure empty details is an empty object. if ( empty( $data['media_details'] ) ) { $data['media_details'] = new stdClass; } elseif ( ! empty( $data['media_details']['sizes'] ) ) { @@ -244,7 +251,7 @@ public function prepare_item_for_response( $post, $request ) { unset( $size_data['mime-type'] ); } - // Use the same method image_downsize() does + // Use the same method image_downsize() does. $image_src = wp_get_attachment_image_src( $post->ID, $size ); if ( ! $image_src ) { continue; @@ -271,7 +278,7 @@ public function prepare_item_for_response( $post, $request ) { $data = $this->filter_response_by_context( $data, $context ); - // Wrap the data in a response object + // Wrap the data in a response object. $response = rest_ensure_response( $data ); $response->add_links( $this->prepare_links( $post ) ); @@ -289,9 +296,9 @@ public function prepare_item_for_response( $post, $request ) { } /** - * Get the Attachment's schema, conforming to JSON Schema + * Get the Attachment's schema, conforming to JSON Schema. * - * @return array + * @return array Item schema as an array. */ public function get_item_schema() { @@ -329,7 +336,7 @@ public function get_item_schema() { 'readonly' => true, ); $schema['properties']['mime_type'] = array( - 'description' => __( 'Mime type of resource.' ), + 'description' => __( 'MIME type of resource.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, @@ -356,26 +363,26 @@ public function get_item_schema() { } /** - * Handle an upload via raw POST data + * Handle an upload via raw POST data. * - * @param array $data Supplied file data - * @param array $headers HTTP headers from the request - * @return array|WP_Error Data from {@see wp_handle_sideload()} + * @param array $data Supplied file data. + * @param array $headers HTTP headers from the request. + * @return array|WP_Error Data from {@see wp_handle_sideload()}. */ protected function upload_from_data( $data, $headers ) { if ( empty( $data ) ) { - return new WP_Error( 'rest_upload_no_data', __( 'No data supplied' ), array( 'status' => 400 ) ); + return new WP_Error( 'rest_upload_no_data', __( 'No data supplied.' ), array( 'status' => 400 ) ); } if ( empty( $headers['content_type'] ) ) { - return new WP_Error( 'rest_upload_no_content_type', __( 'No Content-Type supplied' ), array( 'status' => 400 ) ); + return new WP_Error( 'rest_upload_no_content_type', __( 'No Content-Type supplied.' ), array( 'status' => 400 ) ); } if ( empty( $headers['content_disposition'] ) ) { - return new WP_Error( 'rest_upload_no_content_disposition', __( 'No Content-Disposition supplied' ), array( 'status' => 400 ) ); + return new WP_Error( 'rest_upload_no_content_disposition', __( 'No Content-Disposition supplied.' ), array( 'status' => 400 ) ); } - $filename = $this->get_filename_from_disposition( $headers['content_disposition'] ); + $filename = self::get_filename_from_disposition( $headers['content_disposition'] ); if ( empty( $filename ) ) { return new WP_Error( 'rest_upload_invalid_disposition', __( 'Invalid Content-Disposition supplied. Content-Disposition needs to be formatted as `attachment; filename="image.png"` or similar.' ), array( 'status' => 400 ) ); @@ -387,29 +394,29 @@ protected function upload_from_data( $data, $headers ) { $actual = md5( $data ); if ( $expected !== $actual ) { - return new WP_Error( 'rest_upload_hash_mismatch', __( 'Content hash did not match expected' ), array( 'status' => 412 ) ); + return new WP_Error( 'rest_upload_hash_mismatch', __( 'Content hash did not match expected.' ), array( 'status' => 412 ) ); } } - // Get the content-type + // Get the content-type. $type = array_shift( $headers['content_type'] ); /** Include admin functions to get access to wp_tempnam() and wp_handle_sideload() */ require_once ABSPATH . 'wp-admin/includes/admin.php'; - // Save the file + // Save the file. $tmpfname = wp_tempnam( $filename ); $fp = fopen( $tmpfname, 'w+' ); if ( ! $fp ) { - return new WP_Error( 'rest_upload_file_error', __( 'Could not open file handle' ), array( 'status' => 500 ) ); + return new WP_Error( 'rest_upload_file_error', __( 'Could not open file handle.' ), array( 'status' => 500 ) ); } fwrite( $fp, $data ); fclose( $fp ); - // Now, sideload it in + // Now, sideload it in. $file_data = array( 'error' => null, 'tmp_name' => $tmpfname, @@ -459,7 +466,7 @@ protected function upload_from_data( $data, $headers ) { * @return string|null Filename if available, or null if not found. */ public static function get_filename_from_disposition( $disposition_header ) { - // Get the filename + // Get the filename. $filename = null; foreach ( $disposition_header as $value ) { @@ -499,7 +506,7 @@ public static function get_filename_from_disposition( $disposition_header ) { /** * Get the query params for collections of attachments. * - * @return array + * @return array Query parameters for the attachment collection as an array. */ public function get_collection_params() { $params = parent::get_collection_params(); @@ -515,7 +522,7 @@ public function get_collection_params() { ); $params['mime_type'] = array( 'default' => null, - 'description' => __( 'Limit result set to attachments of a particular mime type.' ), + 'description' => __( 'Limit result set to attachments of a particular MIME type.' ), 'type' => 'string', ); return $params; @@ -524,10 +531,10 @@ public function get_collection_params() { /** * Validate whether the user can query private statuses * - * @param mixed $value - * @param WP_REST_Request $request - * @param string $parameter - * @return WP_Error|boolean + * @param mixed $value Status value. + * @param WP_REST_Request $request Request object. + * @param string $parameter Additional parameter to pass to validation. + * @return WP_Error|boolean Boolean true if the user may query, WP_Error if not. */ public function validate_user_can_query_private_statuses( $value, $request, $parameter ) { if ( 'inherit' === $value ) { @@ -537,37 +544,37 @@ public function validate_user_can_query_private_statuses( $value, $request, $par } /** - * Handle an upload via multipart/form-data ($_FILES) + * Handle an upload via multipart/form-data ($_FILES). * - * @param array $files Data from $_FILES - * @param array $headers HTTP headers from the request - * @return array|WP_Error Data from {@see wp_handle_upload()} + * @param array $files Data from $_FILES. + * @param array $headers HTTP headers from the request. + * @return array|WP_Error Data from {@see wp_handle_upload()}. */ protected function upload_from_file( $files, $headers ) { if ( empty( $files ) ) { - return new WP_Error( 'rest_upload_no_data', __( 'No data supplied' ), array( 'status' => 400 ) ); + return new WP_Error( 'rest_upload_no_data', __( 'No data supplied.' ), array( 'status' => 400 ) ); } - // Verify hash, if given + // Verify hash, if given. if ( ! empty( $headers['content_md5'] ) ) { $content_md5 = array_shift( $headers['content_md5'] ); $expected = trim( $content_md5 ); $actual = md5_file( $files['file']['tmp_name'] ); if ( $expected !== $actual ) { - return new WP_Error( 'rest_upload_hash_mismatch', __( 'Content hash did not match expected' ), array( 'status' => 412 ) ); + return new WP_Error( 'rest_upload_hash_mismatch', __( 'Content hash did not match expected.' ), array( 'status' => 412 ) ); } } - // Pass off to WP to handle the actual upload + // Pass off to WP to handle the actual upload. $overrides = array( 'test_form' => false, ); - // Bypasses is_uploaded_file() when running unit tests + // Bypasses is_uploaded_file() when running unit tests. if ( defined( 'DIR_TESTDATA' ) && DIR_TESTDATA ) { $overrides['action'] = 'wp_handle_mock_upload'; } - /** Include admin functions to get access to wp_handle_upload() */ + // Include admin functions to get access to wp_handle_upload(). require_once ABSPATH . 'wp-admin/includes/admin.php'; $file = wp_handle_upload( $files['file'], $overrides ); @@ -580,7 +587,8 @@ protected function upload_from_file( $files, $headers ) { /** * Get the supported media types. - * Media types are considered the mime type category + * + * Media types are considered the MIME type category. * * @return array */ diff --git a/lib/endpoints/class-wp-rest-comments-controller.php b/lib/endpoints/class-wp-rest-comments-controller.php index b0932ca8e9..e0ca944072 100755 --- a/lib/endpoints/class-wp-rest-comments-controller.php +++ b/lib/endpoints/class-wp-rest-comments-controller.php @@ -5,9 +5,19 @@ */ class WP_REST_Comments_Controller extends WP_REST_Controller { + /** + * Instance of a comment meta fields object. + * + * @access protected + * @var WP_REST_Comment_Meta_Fields + */ + protected $meta; + public function __construct() { $this->namespace = 'wp/v2'; $this->rest_base = 'comments'; + + $this->meta = new WP_REST_Comment_Meta_Fields(); } /** @@ -71,10 +81,10 @@ public function get_items_permissions_check( $request ) { if ( ! empty( $request['post'] ) ) { foreach ( (array) $request['post'] as $post_id ) { - $post = get_post( $post_id ); + $post = $this->get_post( $post_id ); if ( ! empty( $post_id ) && $post && ! $this->check_read_post_permission( $post ) ) { return new WP_Error( 'rest_cannot_read_post', __( 'Sorry, you cannot read the post for this comment.' ), array( 'status' => rest_authorization_required_code() ) ); - } else if ( 0 === $post_id && ! current_user_can( 'moderate_comments' ) ) { + } elseif ( 0 === $post_id && ! current_user_can( 'moderate_comments' ) ) { return new WP_Error( 'rest_cannot_read', __( 'Sorry, you cannot read comments without a post.' ), array( 'status' => rest_authorization_required_code() ) ); } } @@ -92,11 +102,11 @@ public function get_items_permissions_check( $request ) { if ( 'approve' !== $request[ $param ] ) { $forbidden_params[] = $param; } - } else if ( 'type' === $param ) { + } elseif ( 'type' === $param ) { if ( 'comment' !== $request[ $param ] ) { $forbidden_params[] = $param; } - } else if ( ! empty( $request[ $param ] ) ) { + } elseif ( ! empty( $request[ $param ] ) ) { $forbidden_params[] = $param; } } @@ -115,43 +125,69 @@ public function get_items_permissions_check( $request ) { * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { - $prepared_args = array( - 'author_email' => isset( $request['author_email'] ) ? $request['author_email'] : '', - 'comment__in' => $request['include'], - 'comment__not_in' => $request['exclude'], - 'karma' => isset( $request['karma'] ) ? $request['karma'] : '', - 'number' => $request['per_page'], - 'post__in' => $request['post'], - 'parent__in' => $request['parent'], - 'parent__not_in' => $request['parent_exclude'], - 'search' => $request['search'], - 'offset' => $request['offset'], - 'orderby' => $this->normalize_query_param( $request['orderby'] ), - 'order' => $request['order'], - 'status' => $request['status'], - 'type' => $request['type'], - 'no_found_rows' => false, - 'author__in' => $request['author'], - 'author__not_in' => $request['author_exclude'], + + // Retrieve the list of registered collection query parameters. + $registered = $this->get_collection_params(); + + // This array defines mappings between public API query parameters whose + // values are accepted as-passed, and their internal WP_Query parameter + // name equivalents (some are the same). Only values which are also + // present in $registered will be set. + $parameter_mappings = array( + 'author' => 'author__in', + 'author_email' => 'author_email', + 'author_exclude' => 'author__not_in', + 'exclude' => 'comment__not_in', + 'include' => 'comment__in', + 'karma' => 'karma', + 'offset' => 'offset', + 'order' => 'order', + 'parent' => 'parent__in', + 'parent_exclude' => 'parent__not_in', + 'per_page' => 'number', + 'post' => 'post__in', + 'search' => 'search', + 'status' => 'status', + 'type' => 'type', ); + $prepared_args = array(); + + // For each known parameter which is both registered and present in the request, + // set the parameter's value on the query $prepared_args. + foreach ( $parameter_mappings as $api_param => $wp_param ) { + if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) { + $prepared_args[ $wp_param ] = $request[ $api_param ]; + } + } + + // Ensure certain parameter values default to empty strings. + foreach ( array( 'author_email', 'karma', 'search' ) as $param ) { + if ( ! isset( $prepared_args[ $param ] ) ) { + $prepared_args[ $param ] = ''; + } + } + + if ( isset( $registered['orderby'] ) ) { + $prepared_args['orderby'] = $this->normalize_query_param( $request['orderby'] ); + } + + $prepared_args['no_found_rows'] = false; + $prepared_args['date_query'] = array(); // Set before into date query. Date query must be specified as an array of an array. - if ( isset( $request['before'] ) ) { + if ( isset( $registered['before'], $request['before'] ) ) { $prepared_args['date_query'][0]['before'] = $request['before']; } // Set after into date query. Date query must be specified as an array of an array. - if ( isset( $request['after'] ) ) { + if ( isset( $registered['after'], $request['after'] ) ) { $prepared_args['date_query'][0]['after'] = $request['after']; } - if ( empty( $request['offset'] ) ) { + if ( isset( $registered['page'] ) && empty( $request['offset'] ) ) { $prepared_args['offset'] = $prepared_args['number'] * ( absint( $request['page'] ) - 1 ); } - if ( empty( $request['search'] ) ) { - $prepared_args['search'] = ''; - } /** * Filter arguments, before passing to WP_Comment_Query, when querying comments via the REST API. @@ -180,8 +216,7 @@ public function get_items( $request ) { $max_pages = (int) $query->max_num_pages; if ( $total_comments < 1 ) { // Out-of-bounds, run the query again without LIMIT for total count - unset( $prepared_args['number'] ); - unset( $prepared_args['offset'] ); + unset( $prepared_args['number'], $prepared_args['offset'] ); $query = new WP_Comment_Query; $prepared_args['count'] = true; @@ -193,7 +228,7 @@ public function get_items( $request ) { $response->header( 'X-WP-Total', $total_comments ); $response->header( 'X-WP-TotalPages', $max_pages ); - $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) ); + $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); if ( $request['page'] > 1 ) { $prev_page = $request['page'] - 1; if ( $prev_page > $max_pages ) { @@ -230,7 +265,7 @@ public function get_item_permissions_check( $request ) { return new WP_Error( 'rest_cannot_read', __( 'Sorry, you cannot read this comment.' ), array( 'status' => rest_authorization_required_code() ) ); } - $post = get_post( $comment->comment_post_ID ); + $post = $this->get_post( $comment->comment_post_ID ); if ( $post && ! $this->check_read_post_permission( $post ) ) { return new WP_Error( 'rest_cannot_read_post', __( 'Sorry, you cannot read the post for this comment.' ), array( 'status' => rest_authorization_required_code() ) ); @@ -258,7 +293,7 @@ public function get_item( $request ) { } if ( ! empty( $comment->comment_post_ID ) ) { - $post = get_post( $comment->comment_post_ID ); + $post = $this->get_post( $comment->comment_post_ID ); if ( empty( $post ) ) { return new WP_Error( 'rest_post_invalid_id', __( 'Invalid post id.' ), array( 'status' => 404 ) ); } @@ -293,7 +328,18 @@ public function create_item_permissions_check( $request ) { return new WP_Error( 'rest_comment_invalid_status', __( 'Sorry, you cannot set status for comments.' ), array( 'status' => rest_authorization_required_code() ) ); } - if ( ! empty( $request['post'] ) && $post = get_post( (int) $request['post'] ) ) { + if ( empty( $request['post'] ) && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( 'rest_comment_invalid_post_id', __( 'Sorry, you cannot create this comment without a post.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + if ( ! empty( $request['post'] ) && $post = $this->get_post( (int) $request['post'] ) ) { + if ( 'draft' === $post->post_status ) { + return new WP_Error( 'rest_comment_draft_post', __( 'Sorry, you cannot create a comment on this post.' ), array( 'status' => 403 ) ); + } + + if ( 'trash' === $post->post_status ) { + return new WP_Error( 'rest_comment_trash_post', __( 'Sorry, you cannot create a comment on this post.' ), array( 'status' => 403 ) ); + } if ( ! $this->check_read_post_permission( $post ) ) { return new WP_Error( 'rest_cannot_read_post', __( 'Sorry, you cannot read the post for this comment.' ), array( 'status' => rest_authorization_required_code() ) ); @@ -319,6 +365,18 @@ public function create_item( $request ) { } $prepared_comment = $this->prepare_item_for_database( $request ); + if ( is_wp_error( $prepared_comment ) ) { + return $prepared_comment; + } + + /** + * Do not allow a comment to be created with an empty string for + * comment_content. + * See `wp_handle_comment_submission()`. + */ + if ( '' === $prepared_comment['comment_content'] ) { + return new WP_Error( 'rest_comment_content_invalid', __( 'Comment content is invalid.' ), array( 'status' => 400 ) ); + } // Setting remaining values before wp_insert_comment so we can // use wp_allow_comment(). @@ -340,15 +398,44 @@ public function create_item( $request ) { $prepared_comment['comment_author_url'] = $user->user_url; } + // Honor the discussion setting that requires a name and email address + // of the comment author. + if ( get_option( 'require_name_email' ) ) { + if ( ! isset( $prepared_comment['comment_author'] ) && ! isset( $prepared_comment['comment_author_email'] ) ) { + return new WP_Error( 'rest_comment_author_data_required', __( 'Creating a comment requires valid author name and email values.' ), array( 'status' => 400 ) ); + } + if ( ! isset( $prepared_comment['comment_author'] ) ) { + return new WP_Error( 'rest_comment_author_required', __( 'Creating a comment requires a valid author name.' ), array( 'status' => 400 ) ); + } + if ( ! isset( $prepared_comment['comment_author_email'] ) ) { + return new WP_Error( 'rest_comment_author_email_required', __( 'Creating a comment requires a valid author email.' ), array( 'status' => 400 ) ); + } + } + if ( ! isset( $prepared_comment['comment_author_email'] ) ) { $prepared_comment['comment_author_email'] = ''; } if ( ! isset( $prepared_comment['comment_author_url'] ) ) { $prepared_comment['comment_author_url'] = ''; } - $prepared_comment['comment_author_IP'] = '127.0.0.1'; + $prepared_comment['comment_agent'] = ''; - $prepared_comment['comment_approved'] = wp_allow_comment( $prepared_comment ); + $prepared_comment['comment_approved'] = wp_allow_comment( $prepared_comment, true ); + + if ( is_wp_error( $prepared_comment['comment_approved'] ) ) { + $error_code = $prepared_comment['comment_approved']->get_error_code(); + $error_message = $prepared_comment['comment_approved']->get_error_message(); + + if ( 'comment_duplicate' === $error_code ) { + return new WP_Error( $error_code, $error_message, array( 'status' => 409 ) ); + } + + if ( 'comment_flood' === $error_code ) { + return new WP_Error( $error_code, $error_message, array( 'status' => 400 ) ); + } + + return $prepared_comment['comment_approved']; + } /** * Filter a comment before it is inserted via the REST API. @@ -370,15 +457,26 @@ public function create_item( $request ) { $this->handle_status_param( $request['status'], $comment ); } + $schema = $this->get_item_schema(); + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], $comment_id ); + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + $comment = get_comment( $comment_id ); - $this->update_additional_fields_for_object( $comment, $request ); + $fields_update = $this->update_additional_fields_for_object( $comment, $request ); + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } $context = current_user_can( 'moderate_comments' ) ? 'edit' : 'view'; $request->set_param( 'context', $context ); $response = $this->prepare_item_for_response( $comment, $request ); $response = rest_ensure_response( $response ); $response->set_status( 201 ); - $response->header( 'Location', rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $comment_id ) ) ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment_id ) ) ); /** * Fires after a comment is created or updated via the REST API. @@ -425,11 +523,14 @@ public function update_item( $request ) { return new WP_Error( 'rest_comment_invalid_id', __( 'Invalid comment id.' ), array( 'status' => 404 ) ); } - if ( isset( $request['type'] ) && $request['type'] !== $comment->comment_type ) { + if ( isset( $request['type'] ) && get_comment_type( $id ) !== $request['type'] ) { return new WP_Error( 'rest_comment_invalid_type', __( 'Sorry, you cannot change the comment type.' ), array( 'status' => 404 ) ); } $prepared_args = $this->prepare_item_for_database( $request ); + if ( is_wp_error( $prepared_args ) ) { + return $prepared_args; + } if ( empty( $prepared_args ) && isset( $request['status'] ) ) { // Only the comment status is being changed. @@ -438,6 +539,10 @@ public function update_item( $request ) { return new WP_Error( 'rest_comment_failed_edit', __( 'Updating comment status failed.' ), array( 'status' => 500 ) ); } } else { + if ( is_wp_error( $prepared_args ) ) { + return $prepared_args; + } + $prepared_args['comment_ID'] = $id; $updated = wp_update_comment( $prepared_args ); @@ -450,8 +555,19 @@ public function update_item( $request ) { } } + $schema = $this->get_item_schema(); + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], $id ); + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + $comment = get_comment( $id ); - $this->update_additional_fields_for_object( $comment, $request ); + $fields_update = $this->update_additional_fields_for_object( $comment, $request ); + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } $request->set_param( 'context', 'edit' ); $response = $this->prepare_item_for_response( $comment, $request ); @@ -575,6 +691,10 @@ public function prepare_item_for_response( $comment, $request ) { $data['author_avatar_urls'] = rest_get_avatar_urls( $comment->comment_author_email ); } + if ( ! empty( $schema['properties']['meta'] ) ) { + $data['meta'] = $this->meta->get_value( $comment->comment_ID, $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 ); @@ -605,28 +725,28 @@ public function prepare_item_for_response( $comment, $request ) { protected function prepare_links( $comment ) { $links = array( 'self' => array( - 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $comment->comment_ID ) ), + 'href' => rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment->comment_ID ) ), ), 'collection' => array( - 'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ), + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), ), ); if ( 0 !== (int) $comment->user_id ) { $links['author'] = array( - 'href' => rest_url( '/wp/v2/users/' . $comment->user_id ), + 'href' => rest_url( 'wp/v2/users/' . $comment->user_id ), 'embeddable' => true, ); } if ( 0 !== (int) $comment->comment_post_ID ) { - $post = get_post( $comment->comment_post_ID ); + $post = $this->get_post( $comment->comment_post_ID ); if ( ! empty( $post->ID ) ) { $obj = get_post_type_object( $post->post_type ); $base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name; $links['up'] = array( - 'href' => rest_url( '/wp/v2/' . $base . '/' . $comment->comment_post_ID ), + 'href' => rest_url( 'wp/v2/' . $base . '/' . $comment->comment_post_ID ), 'embeddable' => true, 'post_type' => $post->post_type, ); @@ -635,11 +755,22 @@ protected function prepare_links( $comment ) { if ( 0 !== (int) $comment->comment_parent ) { $links['in-reply-to'] = array( - 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $comment->comment_parent ) ), + 'href' => rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment->comment_parent ) ), 'embeddable' => true, ); } + // Only grab one comment to verify the comment has children. + $comment_children = $comment->get_children( array( 'number' => 1, 'count' => true ) ); + if ( ! empty( $comment_children ) ) { + $args = array( 'parent' => $comment->comment_ID ); + $rest_url = add_query_arg( $args, rest_url( $this->namespace . '/' . $this->rest_base ) ); + + $links['children'] = array( + 'href' => $rest_url, + ); + } + return $links; } @@ -711,8 +842,14 @@ protected function prepare_status_response( $comment_approved ) { protected function prepare_item_for_database( $request ) { $prepared_comment = array(); - if ( isset( $request['content'] ) ) { - $prepared_comment['comment_content'] = $request['content']; + /** + * Allow the comment_content to be set via the 'content' or + * the 'content.raw' properties of the Request object. + */ + if ( isset( $request['content'] ) && is_string( $request['content'] ) ) { + $prepared_comment['comment_content'] = wp_filter_kses( $request['content'] ); + } elseif ( isset( $request['content']['raw'] ) && is_string( $request['content']['raw'] ) ) { + $prepared_comment['comment_content'] = wp_filter_kses( $request['content']['raw'] ); } if ( isset( $request['post'] ) ) { @@ -724,7 +861,15 @@ protected function prepare_item_for_database( $request ) { } if ( isset( $request['author'] ) ) { - $prepared_comment['user_id'] = $request['author']; + $user = new WP_User( $request['author'] ); + if ( $user->exists() ) { + $prepared_comment['user_id'] = $user->ID; + $prepared_comment['comment_author'] = $user->display_name; + $prepared_comment['comment_author_email'] = $user->user_email; + $prepared_comment['comment_author_url'] = $user->user_url; + } else { + return new WP_Error( 'rest_comment_author_invalid', __( 'Invalid comment author id.' ), array( 'status' => 400 ) ); + } } if ( isset( $request['author_name'] ) ) { @@ -739,8 +884,13 @@ protected function prepare_item_for_database( $request ) { $prepared_comment['comment_author_url'] = $request['author_url']; } + if ( isset( $request['author_ip'] ) ) { + $prepared_comment['comment_author_IP'] = $request['author_ip']; + } + if ( isset( $request['type'] ) ) { - $prepared_comment['comment_type'] = $request['type']; + // Comment type "comment" needs to be created as an empty string. + $prepared_comment['comment_type'] = 'comment' === $request['type'] ? '' : $request['type']; } if ( isset( $request['karma'] ) ) { @@ -761,6 +911,12 @@ protected function prepare_item_for_database( $request ) { } } + // Require 'comment_content' unless only the 'comment_status' is being + // updated. + if ( ! empty( $prepared_comment ) && ! isset( $prepared_comment['comment_content'] ) ) { + return new WP_Error( 'rest_comment_content_required', __( 'Missing comment content.' ), array( 'status' => 400 ) ); + } + return apply_filters( 'rest_preprocess_comment', $prepared_comment, $request ); } @@ -795,8 +951,11 @@ public function get_item_schema() { 'author_ip' => array( 'description' => __( 'IP address for the object author.' ), 'type' => 'string', + 'format' => 'ipv4', 'context' => array( 'edit' ), - 'readonly' => true, + 'arg_options' => array( + 'default' => '127.0.0.1', + ), ), 'author_name' => array( 'description' => __( 'Display name for the object author.' ), @@ -804,7 +963,6 @@ public function get_item_schema() { 'context' => array( 'view', 'edit', 'embed' ), 'arg_options' => array( 'sanitize_callback' => 'sanitize_text_field', - 'default' => '', ), ), 'author_url' => array( @@ -835,10 +993,6 @@ public function get_item_schema() { 'context' => array( 'view', 'edit', 'embed' ), ), ), - 'arg_options' => array( - 'sanitize_callback' => 'wp_filter_post_kses', - 'default' => '', - ), ), 'date' => array( 'description' => __( 'The date the object was published.' ), @@ -892,9 +1046,9 @@ public function get_item_schema() { 'description' => __( 'Type of Comment for the object.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), + 'default' => 'comment', 'arg_options' => array( 'sanitize_callback' => 'sanitize_key', - 'default' => '', ), ), ), @@ -922,6 +1076,8 @@ public function get_item_schema() { ); } + $schema['properties']['meta'] = $this->meta->get_field_schema(); + return $this->add_additional_fields_schema( $schema ); } @@ -945,20 +1101,17 @@ public function get_collection_params() { 'description' => __( 'Limit result set to comments assigned to specific user ids. Requires authorization.' ), 'sanitize_callback' => 'wp_parse_id_list', 'type' => 'array', - 'validate_callback' => 'rest_validate_request_arg', ); $query_params['author_exclude'] = array( 'description' => __( 'Ensure result set excludes comments assigned to specific user ids. Requires authorization.' ), 'sanitize_callback' => 'wp_parse_id_list', 'type' => 'array', - 'validate_callback' => 'rest_validate_request_arg', ); $query_params['author_email'] = array( 'default' => null, 'description' => __( 'Limit result set to that from a specific author email. Requires authorization.' ), 'format' => 'email', 'sanitize_callback' => 'sanitize_email', - 'validate_callback' => 'rest_validate_request_arg', 'type' => 'string', ); $query_params['before'] = array( @@ -972,14 +1125,12 @@ public function get_collection_params() { 'type' => 'array', 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', - 'validate_callback' => 'rest_validate_request_arg', ); $query_params['include'] = array( 'description' => __( 'Limit result set to specific ids.' ), 'type' => 'array', 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', - 'validate_callback' => 'rest_validate_request_arg', ); $query_params['karma'] = array( 'default' => null, @@ -999,7 +1150,7 @@ public function get_collection_params() { 'type' => 'string', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', - 'default' => 'asc', + 'default' => 'desc', 'enum' => array( 'asc', 'desc', @@ -1026,21 +1177,18 @@ public function get_collection_params() { 'description' => __( 'Limit result set to resources of specific parent ids.' ), 'sanitize_callback' => 'wp_parse_id_list', 'type' => 'array', - 'validate_callback' => 'rest_validate_request_arg', ); $query_params['parent_exclude'] = array( 'default' => array(), 'description' => __( 'Ensure result set excludes specific parent ids.' ), 'sanitize_callback' => 'wp_parse_id_list', 'type' => 'array', - 'validate_callback' => 'rest_validate_request_arg', ); $query_params['post'] = array( 'default' => array(), 'description' => __( 'Limit result set to resources assigned to specific post ids.' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', - 'validate_callback' => 'rest_validate_request_arg', ); $query_params['status'] = array( 'default' => 'approve', @@ -1124,9 +1272,13 @@ protected function check_read_post_permission( $post ) { * @return boolean Can we read it? */ protected function check_read_permission( $comment ) { - - if ( 1 === (int) $comment->comment_approved ) { - return true; + if ( ! empty( $comment->comment_post_ID ) ) { + $post = get_post( $comment->comment_post_ID ); + if ( $post ) { + if ( $this->check_read_post_permission( $post ) && 1 === (int) $comment->comment_approved ) { + return true; + } + } } if ( 0 === get_current_user_id() ) { @@ -1137,13 +1289,6 @@ protected function check_read_permission( $comment ) { return false; } - $post = get_post( $comment->comment_post_ID ); - if ( $comment->comment_post_ID && $post ) { - if ( ! $this->check_read_post_permission( $post ) ) { - return false; - } - } - if ( ! empty( $comment->user_id ) && get_current_user_id() === (int) $comment->user_id ) { return true; } diff --git a/lib/endpoints/class-wp-rest-controller.php b/lib/endpoints/class-wp-rest-controller.php index 096abd92cc..4cb19b4e83 100755 --- a/lib/endpoints/class-wp-rest-controller.php +++ b/lib/endpoints/class-wp-rest-controller.php @@ -21,7 +21,7 @@ abstract class WP_REST_Controller { * Register the routes for the objects of the controller. */ public function register_routes() { - _doing_it_wrong( 'WP_REST_Controller::register_routes', __( 'The register_routes() method must be overriden' ), 'WPAPI-2.0' ); + _doing_it_wrong( 'WP_REST_Controller::register_routes', __( 'The register_routes() method must be overridden' ), 'WPAPI-2.0' ); } /** @@ -31,7 +31,7 @@ public function register_routes() { * @return WP_Error|boolean */ public function get_items_permissions_check( $request ) { - return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be over-ridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); + return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); } /** @@ -41,7 +41,7 @@ public function get_items_permissions_check( $request ) { * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { - return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be over-ridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); + return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); } /** @@ -51,7 +51,7 @@ public function get_items( $request ) { * @return WP_Error|boolean */ public function get_item_permissions_check( $request ) { - return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be over-ridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); + return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); } /** @@ -61,7 +61,7 @@ public function get_item_permissions_check( $request ) { * @return WP_Error|WP_REST_Response */ public function get_item( $request ) { - return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be over-ridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); + return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); } /** @@ -71,7 +71,7 @@ public function get_item( $request ) { * @return WP_Error|boolean */ public function create_item_permissions_check( $request ) { - return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be over-ridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); + return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); } /** @@ -81,7 +81,7 @@ public function create_item_permissions_check( $request ) { * @return WP_Error|WP_REST_Response */ public function create_item( $request ) { - return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be over-ridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); + return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); } /** @@ -91,7 +91,7 @@ public function create_item( $request ) { * @return WP_Error|boolean */ public function update_item_permissions_check( $request ) { - return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be over-ridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); + return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); } /** @@ -101,7 +101,7 @@ public function update_item_permissions_check( $request ) { * @return WP_Error|WP_REST_Response */ public function update_item( $request ) { - return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be over-ridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); + return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); } /** @@ -111,7 +111,7 @@ public function update_item( $request ) { * @return WP_Error|boolean */ public function delete_item_permissions_check( $request ) { - return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be over-ridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); + return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); } /** @@ -121,7 +121,7 @@ public function delete_item_permissions_check( $request ) { * @return WP_Error|WP_REST_Response */ public function delete_item( $request ) { - return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be over-ridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); + return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); } /** @@ -131,7 +131,7 @@ public function delete_item( $request ) { * @return WP_Error|object $prepared_item */ protected function prepare_item_for_database( $request ) { - return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be over-ridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); + return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); } /** @@ -139,10 +139,10 @@ protected function prepare_item_for_database( $request ) { * * @param mixed $item WordPress representation of the item. * @param WP_REST_Request $request Request object. - * @return WP_REST_Response $response + * @return WP_Error|WP_REST_Response $response */ public function prepare_item_for_response( $item, $request ) { - return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be over-ridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); + return new WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass." ), __METHOD__ ), array( 'status' => 405 ) ); } /** @@ -157,7 +157,14 @@ public function prepare_response_for_collection( $response ) { } $data = (array) $response->get_data(); - $links = WP_REST_Server::get_response_links( $response ); + $server = rest_get_server(); + + if ( method_exists( $server, 'get_compact_response_links' ) ) { + $links = call_user_func( array( $server, 'get_compact_response_links' ), $response ); + } else { + $links = call_user_func( array( $server, 'get_response_links' ), $response ); + } + if ( ! empty( $links ) ) { $data['_links'] = $links; } @@ -180,8 +187,9 @@ public function filter_response_by_context( $data, $context ) { continue; } - if ( ! in_array( $context, $schema['properties'][ $key ]['context'] ) ) { + if ( ! in_array( $context, $schema['properties'][ $key ]['context'], true ) ) { unset( $data[ $key ] ); + continue; } if ( 'object' === $schema['properties'][ $key ]['type'] && ! empty( $schema['properties'][ $key ]['properties'] ) ) { @@ -189,7 +197,7 @@ public function filter_response_by_context( $data, $context ) { if ( empty( $details['context'] ) ) { continue; } - if ( ! in_array( $context, $details['context'] ) ) { + if ( ! in_array( $context, $details['context'], true ) ) { if ( isset( $data[ $key ][ $attribute ] ) ) { unset( $data[ $key ][ $attribute ] ); } @@ -220,9 +228,7 @@ public function get_public_item_schema() { $schema = $this->get_item_schema(); foreach ( $schema['properties'] as &$property ) { - if ( isset( $property['arg_options'] ) ) { - unset( $property['arg_options'] ); - } + unset( $property['arg_options'] ); } return $schema; @@ -282,7 +288,7 @@ public function get_context_param( $args = array() ) { return array_merge( $param_details, $args ); } $contexts = array(); - foreach ( $schema['properties'] as $key => $attributes ) { + foreach ( $schema['properties'] as $attributes ) { if ( ! empty( $attributes['context'] ) ) { $contexts = array_merge( $contexts, $attributes['context'] ); } @@ -322,13 +328,12 @@ protected function add_additional_fields_to_object( $object, $request ) { * * @param array $object * @param WP_REST_Request $request + * @return bool|WP_Error True on success, WP_Error object if a field cannot be updated. */ protected function update_additional_fields_for_object( $object, $request ) { - $additional_fields = $this->get_additional_fields(); foreach ( $additional_fields as $field_name => $field_options ) { - if ( ! $field_options['update_callback'] ) { continue; } @@ -338,8 +343,13 @@ protected function update_additional_fields_for_object( $object, $request ) { continue; } - call_user_func( $field_options['update_callback'], $request[ $field_name ], $object, $field_name, $request, $this->get_object_type() ); + $result = call_user_func( $field_options['update_callback'], $request[ $field_name ], $object, $field_name, $request, $this->get_object_type() ); + if ( is_wp_error( $result ) ) { + return $result; + } } + + return true; } /** @@ -348,6 +358,7 @@ protected function update_additional_fields_for_object( $object, $request ) { * The type of object is inferred from the passed schema. * * @param array $schema Schema array. + * @return array Modified Schema array. */ protected function add_additional_fields_schema( $schema ) { if ( empty( $schema['title'] ) ) { @@ -473,4 +484,50 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE return $endpoint_args; } + /** + * Retrieves post data given a post ID or post object. + * + * This is a subset of the functionality of the `get_post()` function, with + * the additional functionality of having `the_post` action done on the + * resultant post object. This is done so that plugins may manipulate the + * post that is used in the REST API. + * + * @see get_post() + * @global WP_Query $wp_query + * + * @param int|WP_Post $post Post ID or post object. Defaults to global $post. + * @return WP_Post|null A `WP_Post` object when successful. + */ + public function get_post( $post ) { + $post_obj = get_post( $post ); + + /** + * Filter the post. + * + * Allows plugins to filter the post object as returned by `\WP_REST_Controller::get_post()`. + * + * @param WP_Post|null $post_obj The post object as returned by `get_post()`. + * @param int|WP_Post $post The original value used to obtain the post object. + */ + $post = apply_filters( 'rest_the_post', $post_obj, $post ); + + return $post; + } + + /** + * Sanitize the slug value. + * + * @internal We can't use {@see sanitize_title} directly, as the second + * parameter is the fallback title, which would end up being set to the + * request object. + * @see https://github.com/WP-API/WP-API/issues/1585 + * + * @todo Remove this in favour of https://core.trac.wordpress.org/ticket/34659 + * + * @param string $slug Slug value passed in request. + * @return string Sanitized value for the slug. + */ + public function sanitize_slug( $slug ) { + return sanitize_title( $slug ); + } } diff --git a/lib/endpoints/class-wp-rest-post-statuses-controller.php b/lib/endpoints/class-wp-rest-post-statuses-controller.php index d5c6e13605..faf73f0ea2 100755 --- a/lib/endpoints/class-wp-rest-post-statuses-controller.php +++ b/lib/endpoints/class-wp-rest-post-statuses-controller.php @@ -155,9 +155,9 @@ public function prepare_item_for_response( $status, $request ) { $response = rest_ensure_response( $data ); if ( 'publish' === $status->name ) { - $response->add_link( 'archives', rest_url( '/wp/v2/posts' ) ); + $response->add_link( 'archives', rest_url( 'wp/v2/posts' ) ); } else { - $response->add_link( 'archives', add_query_arg( 'status', $status->name, rest_url( '/wp/v2/posts' ) ) ); + $response->add_link( 'archives', add_query_arg( 'status', $status->name, rest_url( 'wp/v2/posts' ) ) ); } /** diff --git a/lib/endpoints/class-wp-rest-posts-controller.php b/lib/endpoints/class-wp-rest-posts-controller.php index 469d763f0c..9d7f85a8a7 100755 --- a/lib/endpoints/class-wp-rest-posts-controller.php +++ b/lib/endpoints/class-wp-rest-posts-controller.php @@ -2,13 +2,34 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { + /** + * Post type. + * + * @access protected + * @var string + */ protected $post_type; + /** + * Instance of a post meta fields object. + * + * @access protected + * @var WP_REST_Post_Meta_Fields + */ + protected $meta; + + /** + * Constructor. + * + * @param string $post_type Post type. + */ public function __construct( $post_type ) { $this->post_type = $post_type; $this->namespace = 'wp/v2'; $obj = get_post_type_object( $post_type ); $this->rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name; + + $this->meta = new WP_REST_Post_Meta_Fields( $this->post_type ); } /** @@ -37,7 +58,10 @@ public function register_routes() { 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), 'args' => array( - 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + 'password' => array( + 'description' => __( 'The password for the post if it is password protected.' ), + ), ), ), array( @@ -72,7 +96,7 @@ public function get_items_permissions_check( $request ) { $post_type = get_post_type_object( $this->post_type ); if ( 'edit' === $request['context'] && ! current_user_can( $post_type->cap->edit_posts ) ) { - return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit these posts in this post type' ), array( 'status' => rest_authorization_required_code() ) ); + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit these posts in this post type.' ), array( 'status' => rest_authorization_required_code() ) ); } return true; @@ -85,39 +109,90 @@ public function get_items_permissions_check( $request ) { * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { - $args = array(); - $args['author__in'] = $request['author']; - $args['author__not_in'] = $request['author_exclude']; - $args['menu_order'] = $request['menu_order']; - $args['offset'] = $request['offset']; - $args['order'] = $request['order']; - $args['orderby'] = $request['orderby']; - $args['paged'] = $request['page']; - $args['post__in'] = $request['include']; - $args['post__not_in'] = $request['exclude']; - $args['posts_per_page'] = $request['per_page']; - $args['name'] = $request['slug']; - $args['post_parent__in'] = $request['parent']; - $args['post_parent__not_in'] = $request['parent_exclude']; - $args['post_status'] = $request['status']; - $args['s'] = $request['search']; + + // Make sure a search string is set in case the orderby is set to 'relevance'. + if ( ! empty( $request['orderby'] ) && 'relevance' === $request['orderby'] && empty( $request['search'] ) && empty( $request['filter']['s'] ) ) { + return new WP_Error( 'rest_no_search_term_defined', __( 'You need to define a search term to order by relevance.' ), array( 'status' => 400 ) ); + } + + // Retrieve the list of registered collection query parameters. + $registered = $this->get_collection_params(); + $args = array(); + + // This array defines mappings between public API query parameters whose + // values are accepted as-passed, and their internal WP_Query parameter + // name equivalents (some are the same). Only values which are also + // present in $registered will be set. + $parameter_mappings = array( + 'author' => 'author__in', + 'author_exclude' => 'author__not_in', + 'exclude' => 'post__not_in', + 'include' => 'post__in', + 'menu_order' => 'menu_order', + 'offset' => 'offset', + 'order' => 'order', + 'orderby' => 'orderby', + 'page' => 'paged', + 'parent' => 'post_parent__in', + 'parent_exclude' => 'post_parent__not_in', + 'search' => 's', + 'slug' => 'name', + 'status' => 'post_status', + ); + + // For each known parameter which is both registered and present in the request, + // set the parameter's value on the query $args. + foreach ( $parameter_mappings as $api_param => $wp_param ) { + if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) { + $args[ $wp_param ] = $request[ $api_param ]; + } + } + + // Check for & assign any parameters which require special handling or setting. $args['date_query'] = array(); // Set before into date query. Date query must be specified as an array of an array. - if ( isset( $request['before'] ) ) { + if ( isset( $registered['before'], $request['before'] ) ) { $args['date_query'][0]['before'] = $request['before']; } // Set after into date query. Date query must be specified as an array of an array. - if ( isset( $request['after'] ) ) { + if ( isset( $registered['after'], $request['after'] ) ) { $args['date_query'][0]['after'] = $request['after']; } - if ( is_array( $request['filter'] ) ) { + if ( isset( $registered['filter'] ) && is_array( $request['filter'] ) ) { $args = array_merge( $args, $request['filter'] ); unset( $args['filter'] ); } + // Ensure our per_page parameter overrides any provided posts_per_page filter. + if ( isset( $registered['per_page'] ) ) { + $args['posts_per_page'] = $request['per_page']; + } + + if ( isset( $registered['sticky'], $request['sticky'] ) ) { + $sticky_posts = get_option( 'sticky_posts', array() ); + if ( $sticky_posts && $request['sticky'] ) { + // As post__in will be used to only get sticky posts, + // we have to support the case where post__in was already + // specified. + $args['post__in'] = $args['post__in'] ? array_intersect( $sticky_posts, $args['post__in'] ) : $sticky_posts; + + // If we intersected, but there are no post ids in common, + // WP_Query won't return "no posts" for `post__in = array()` + // so we have to fake it a bit. + if ( ! $args['post__in'] ) { + $args['post__in'] = array( -1 ); + } + } elseif ( $sticky_posts ) { + // As post___not_in will be used to only get posts that + // are not sticky, we have to support the case where post__not_in + // was already specified. + $args['post__not_in'] = array_merge( $args['post__not_in'], $sticky_posts ); + } + } + // Force the post_type argument, since it's not a user input variable. $args['post_type'] = $this->post_type; @@ -127,7 +202,7 @@ public function get_items( $request ) { * Enables adding extra arguments or setting defaults for a post * collection request. * - * @see https://developer.wordpress.org/reference/classes/wp_user_query/ + * @see https://developer.wordpress.org/reference/classes/wp_query/ * * @param array $args Key value array of query var to query value. * @param WP_REST_Request $request The request used. @@ -138,6 +213,7 @@ public function get_items( $request ) { $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) ); foreach ( $taxonomies as $taxonomy ) { $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; + $tax_exclude = $base . '_exclude'; if ( ! empty( $request[ $base ] ) ) { $query_args['tax_query'][] = array( @@ -147,11 +223,26 @@ public function get_items( $request ) { 'include_children' => false, ); } + + if ( ! empty( $request[ $tax_exclude ] ) ) { + $query_args['tax_query'][] = array( + 'taxonomy' => $taxonomy->name, + 'field' => 'term_id', + 'terms' => $request[ $tax_exclude ], + 'include_children' => false, + 'operator' => 'NOT IN', + ); + } } $posts_query = new WP_Query(); $query_result = $posts_query->query( $query_args ); + // Allow access to all password protected posts if the context is edit. + if ( 'edit' === $request['context'] ) { + add_filter( 'post_password_required', '__return_false' ); + } + $posts = array(); foreach ( $query_result as $post ) { if ( ! $this->check_read_permission( $post ) ) { @@ -162,11 +253,16 @@ public function get_items( $request ) { $posts[] = $this->prepare_response_for_collection( $data ); } + // Reset filter. + if ( 'edit' === $request['context'] ) { + remove_filter( 'post_password_required', '__return_false' ); + } + $page = (int) $query_args['paged']; $total_posts = $posts_query->found_posts; if ( $total_posts < 1 ) { - // Out-of-bounds, run the query again without LIMIT for total count + // Out-of-bounds, run the query again without LIMIT for total count. unset( $query_args['paged'] ); $count_query = new WP_Query(); $count_query->query( $query_args ); @@ -182,10 +278,9 @@ public function get_items( $request ) { $request_params = $request->get_query_params(); if ( ! empty( $request_params['filter'] ) ) { // Normalize the pagination params. - unset( $request_params['filter']['posts_per_page'] ); - unset( $request_params['filter']['paged'] ); + unset( $request_params['filter']['posts_per_page'], $request_params['filter']['paged'] ); } - $base = add_query_arg( $request_params, rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) ); + $base = add_query_arg( $request_params, rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); if ( $page > 1 ) { $prev_page = $page - 1; @@ -212,10 +307,22 @@ public function get_items( $request ) { */ public function get_item_permissions_check( $request ) { - $post = get_post( (int) $request['id'] ); + $post = $this->get_post( (int) $request['id'] ); if ( 'edit' === $request['context'] && $post && ! $this->check_update_permission( $post ) ) { - return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit this post' ), array( 'status' => rest_authorization_required_code() ) ); + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit this post.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + if ( $post && ! empty( $request['password'] ) ) { + // Check post password, and return error if invalid. + if ( ! hash_equals( $post->post_password, $request['password'] ) ) { + return new WP_Error( 'rest_post_incorrect_password', __( 'Incorrect post password.' ), array( 'status' => 403 ) ); + } + } + + // Allow access to all password protected posts if the context is edit. + if ( 'edit' === $request['context'] ) { + add_filter( 'post_password_required', '__return_false' ); } if ( $post ) { @@ -225,6 +332,36 @@ public function get_item_permissions_check( $request ) { return true; } + /** + * Can the user access password-protected content? + * + * This method determines whether we need to override the regular password + * check in core with a filter. + * + * @param WP_Post $post Post to check against. + * @param WP_REST_Request $request Request data to check. + * @return bool True if the user can access password-protected content, false otherwise. + */ + protected function can_access_password_content( $post, $request ) { + if ( empty( $post->post_password ) ) { + // No filter required. + return false; + } + + // Edit context always gets access to password-protected posts. + if ( 'edit' === $request['context'] ) { + return true; + } + + // No password, no auth. + if ( empty( $request['password'] ) ) { + return false; + } + + // Double-check the request password. + return hash_equals( $post->post_password, $request['password'] ); + } + /** * Get a single post. * @@ -233,7 +370,7 @@ public function get_item_permissions_check( $request ) { */ public function get_item( $request ) { $id = (int) $request['id']; - $post = get_post( $id ); + $post = $this->get_post( $id ); if ( empty( $id ) || empty( $post->ID ) || $this->post_type !== $post->post_type ) { return new WP_Error( 'rest_post_invalid_id', __( 'Invalid post id.' ), array( 'status' => 404 ) ); @@ -259,10 +396,6 @@ public function create_item_permissions_check( $request ) { $post_type = get_post_type_object( $this->post_type ); - if ( ! empty( $request['password'] ) && ! current_user_can( $post_type->cap->publish_posts ) ) { - return new WP_Error( 'rest_cannot_publish', __( 'Sorry, you are not allowed to create password protected posts in this post type' ), array( 'status' => rest_authorization_required_code() ) ); - } - if ( ! empty( $request['author'] ) && get_current_user_id() !== $request['author'] && ! current_user_can( $post_type->cap->edit_others_posts ) ) { return new WP_Error( 'rest_cannot_edit_others', __( 'You are not allowed to create posts as this user.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -298,7 +431,7 @@ public function create_item( $request ) { if ( is_wp_error( $post_id ) ) { - if ( in_array( $post_id->get_error_code(), array( 'db_insert_error' ) ) ) { + if ( 'db_insert_error' === $post_id->get_error_code() ) { $post_id->add_data( array( 'status' => 500 ) ); } else { $post_id->add_data( array( 'status' => 400 ) ); @@ -333,8 +466,18 @@ public function create_item( $request ) { return $terms_update; } - $post = get_post( $post_id ); - $this->update_additional_fields_for_object( $post, $request ); + $post = $this->get_post( $post_id ); + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], (int) $request['id'] ); + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $post, $request ); + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } /** * Fires after a single post is created or updated via the REST API. @@ -349,7 +492,7 @@ public function create_item( $request ) { $response = $this->prepare_item_for_response( $post, $request ); $response = rest_ensure_response( $response ); $response->set_status( 201 ); - $response->header( 'Location', rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $post_id ) ) ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $post_id ) ) ); return $response; } @@ -362,15 +505,11 @@ public function create_item( $request ) { */ public function update_item_permissions_check( $request ) { - $post = get_post( $request['id'] ); + $post = $this->get_post( $request['id'] ); $post_type = get_post_type_object( $this->post_type ); if ( $post && ! $this->check_update_permission( $post ) ) { - return new WP_Error( 'rest_cannot_edit', __( 'Sorry, you are not allowed to update this post.' ), array( 'status' => rest_authorization_required_code() ) );; - } - - if ( ! empty( $request['password'] ) && ! current_user_can( $post_type->cap->publish_posts ) ) { - return new WP_Error( 'rest_cannot_publish', __( 'Sorry, you are not allowed to create password protected posts in this post type' ), array( 'status' => rest_authorization_required_code() ) ); + return new WP_Error( 'rest_cannot_edit', __( 'Sorry, you are not allowed to update this post.' ), array( 'status' => rest_authorization_required_code() ) ); } if ( ! empty( $request['author'] ) && get_current_user_id() !== $request['author'] && ! current_user_can( $post_type->cap->edit_others_posts ) ) { @@ -392,20 +531,20 @@ public function update_item_permissions_check( $request ) { */ public function update_item( $request ) { $id = (int) $request['id']; - $post = get_post( $id ); + $post = $this->get_post( $id ); if ( empty( $id ) || empty( $post->ID ) || $this->post_type !== $post->post_type ) { - return new WP_Error( 'rest_post_invalid_id', __( 'Post id is invalid.' ), array( 'status' => 400 ) ); + return new WP_Error( 'rest_post_invalid_id', __( 'Post id is invalid.' ), array( 'status' => 404 ) ); } $post = $this->prepare_item_for_database( $request ); if ( is_wp_error( $post ) ) { return $post; } - // convert the post object to an array, otherwise wp_update_post will expect non-escaped input + // convert the post object to an array, otherwise wp_update_post will expect non-escaped input. $post_id = wp_update_post( (array) $post, true ); if ( is_wp_error( $post_id ) ) { - if ( in_array( $post_id->get_error_code(), array( 'db_update_error' ) ) ) { + if ( 'db_update_error' === $post_id->get_error_code() ) { $post_id->add_data( array( 'status' => 500 ) ); } else { $post_id->add_data( array( 'status' => 400 ) ); @@ -440,8 +579,19 @@ public function update_item( $request ) { return $terms_update; } - $post = get_post( $post_id ); - $this->update_additional_fields_for_object( $post, $request ); + $post = $this->get_post( $post_id ); + + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], $post->ID ); + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $post, $request ); + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } /* This action is documented in lib/endpoints/class-wp-rest-controller.php */ do_action( "rest_insert_{$this->post_type}", $post, $request, false ); @@ -459,7 +609,7 @@ public function update_item( $request ) { */ public function delete_item_permissions_check( $request ) { - $post = get_post( $request['id'] ); + $post = $this->get_post( $request['id'] ); if ( $post && ! $this->check_delete_permission( $post ) ) { return new WP_Error( 'rest_cannot_delete', __( 'Sorry, you are not allowed to delete posts.' ), array( 'status' => rest_authorization_required_code() ) ); @@ -478,14 +628,14 @@ public function delete_item( $request ) { $id = (int) $request['id']; $force = (bool) $request['force']; - $post = get_post( $id ); + $post = $this->get_post( $id ); if ( empty( $id ) || empty( $post->ID ) || $this->post_type !== $post->post_type ) { return new WP_Error( 'rest_post_invalid_id', __( 'Invalid post id.' ), array( 'status' => 404 ) ); } $supports_trash = ( EMPTY_TRASH_DAYS > 0 ); - if ( $post->post_type === 'attachment' ) { + if ( 'attachment' === $post->post_type ) { $supports_trash = $supports_trash && MEDIA_TRASH; } @@ -545,13 +695,13 @@ public function delete_item( $request ) { * Determine the allowed query_vars for a get_items() response and * prepare for WP_Query. * - * @param array $prepared_args - * @param WP_REST_Request $request + * @param array $prepared_args Prepared WP_Query arguments. + * @param WP_REST_Request $request Full details about the request. * @return array $query_args */ protected function prepare_items_query( $prepared_args = array(), $request = null ) { - $valid_vars = array_flip( $this->get_allowed_query_vars() ); + $valid_vars = array_flip( $this->get_allowed_query_vars( $request ) ); $query_args = array(); foreach ( $valid_vars as $var => $index ) { if ( isset( $prepared_args[ $var ] ) ) { @@ -561,7 +711,6 @@ protected function prepare_items_query( $prepared_args = array(), $request = nul * The dynamic portion of the hook name, $var, refers to the query_var key. * * @param mixed $prepared_args[ $var ] The query_var value. - * */ $query_args[ $var ] = apply_filters( "rest_query_var-{$var}", $prepared_args[ $var ] ); } @@ -581,9 +730,10 @@ protected function prepare_items_query( $prepared_args = array(), $request = nul /** * Get all the WP Query vars that are allowed for the API request. * + * @param WP_REST_Request $request Full details about the request. * @return array */ - protected function get_allowed_query_vars() { + protected function get_allowed_query_vars( $request = null ) { global $wp; /** @@ -641,40 +791,20 @@ protected function get_allowed_query_vars() { * Array of allowed WP_Query query vars. * * @param string $allowed_query_var The query var to allow. + * @param WP_REST_Request $request Request object. * } */ - $valid_vars = apply_filters( 'rest_query_vars', $valid_vars ); + $valid_vars = apply_filters( 'rest_query_vars', $valid_vars, $request ); return $valid_vars; } - /** - * Check the post excerpt and prepare it for single post output. - * - * @param string $excerpt - * @return string|null $excerpt - */ - protected function prepare_excerpt_response( $excerpt ) { - if ( post_password_required() ) { - return __( 'There is no excerpt because this is a protected post.' ); - } - - /** This filter is documented in wp-includes/post-template.php */ - $excerpt = apply_filters( 'the_excerpt', apply_filters( 'get_the_excerpt', $excerpt ) ); - - if ( empty( $excerpt ) ) { - return ''; - } - - return $excerpt; - } - /** * Check the post_date_gmt or modified_gmt and prepare any post or * modified date for single post output. * - * @param string $date_gmt - * @param string|null $date + * @param string $date_gmt GMT publication time. + * @param string|null $date Optional, default is null. Local publication time. * @return string|null ISO8601/RFC3339 formatted datetime. */ protected function prepare_date_response( $date_gmt, $date = null ) { @@ -692,21 +822,6 @@ protected function prepare_date_response( $date_gmt, $date = null ) { return mysql_to_rfc3339( $date_gmt ); } - protected function prepare_password_response( $password ) { - if ( ! empty( $password ) ) { - /** - * Fake the correct cookie to fool post_password_required(). - * Without this, get_the_content() will give a password form. - */ - require_once ABSPATH . 'wp-includes/class-phpass.php'; - $hasher = new PasswordHash( 8, true ); - $value = $hasher->HashPassword( $password ); - $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] = wp_slash( $value ); - } - - return $password; - } - /** * Prepare a single post for create or update. * @@ -761,7 +876,7 @@ protected function prepare_item_for_database( $request ) { $post_type = get_post_type_object( $prepared_post->post_type ); // Post status. - if ( isset( $request['status'] ) ) { + if ( ! empty( $schema['properties']['status'] ) && isset( $request['status'] ) ) { $status = $this->handle_status_param( $request['status'], $post_type ); if ( is_wp_error( $status ) ) { return $status; @@ -771,13 +886,13 @@ protected function prepare_item_for_database( $request ) { } // Post date. - if ( ! empty( $request['date'] ) ) { + if ( ! empty( $schema['properties']['date'] ) && ! empty( $request['date'] ) ) { $date_data = rest_get_date_with_gmt( $request['date'] ); if ( ! empty( $date_data ) ) { list( $prepared_post->post_date, $prepared_post->post_date_gmt ) = $date_data; } - } elseif ( ! empty( $request['date_gmt'] ) ) { + } elseif ( ! empty( $schema['properties']['date_gmt'] ) && ! empty( $request['date_gmt'] ) ) { $date_data = rest_get_date_with_gmt( $request['date_gmt'], true ); if ( ! empty( $date_data ) ) { @@ -785,11 +900,11 @@ protected function prepare_item_for_database( $request ) { } } // Post slug. - if ( isset( $request['slug'] ) ) { + if ( ! empty( $schema['properties']['slug'] ) && isset( $request['slug'] ) ) { $prepared_post->post_name = $request['slug']; } - // Author + // Author. if ( ! empty( $schema['properties']['author'] ) && ! empty( $request['author'] ) ) { $post_author = (int) $request['author']; if ( get_current_user_id() !== $post_author ) { @@ -802,7 +917,7 @@ protected function prepare_item_for_database( $request ) { } // Post password. - if ( isset( $request['password'] ) && '' !== $request['password'] ) { + if ( ! empty( $schema['properties']['password'] ) && isset( $request['password'] ) && '' !== $request['password'] ) { $prepared_post->post_password = $request['password']; if ( ! empty( $schema['properties']['sticky'] ) && ! empty( $request['sticky'] ) ) { @@ -814,7 +929,7 @@ protected function prepare_item_for_database( $request ) { } } - if ( ! empty( $request['sticky'] ) ) { + if ( ! empty( $schema['properties']['sticky'] ) && ! empty( $request['sticky'] ) ) { if ( ! empty( $prepared_post->ID ) && post_password_required( $prepared_post->ID ) ) { return new WP_Error( 'rest_invalid_field', __( 'A password protected post can not be set to sticky.' ), array( 'status' => 400 ) ); } @@ -822,7 +937,7 @@ protected function prepare_item_for_database( $request ) { // Parent. if ( ! empty( $schema['properties']['parent'] ) && ! empty( $request['parent'] ) ) { - $parent = get_post( (int) $request['parent'] ); + $parent = $this->get_post( (int) $request['parent'] ); if ( empty( $parent ) ) { return new WP_Error( 'rest_post_invalid_id', __( 'Invalid post parent id.' ), array( 'status' => 400 ) ); } @@ -861,8 +976,8 @@ protected function prepare_item_for_database( $request ) { /** * Determine validity and normalize provided status param. * - * @param string $post_status - * @param object $post_type + * @param string $post_status Post status. + * @param object $post_type Post type. * @return WP_Error|string $post_status */ protected function handle_status_param( $post_status, $post_type ) { @@ -873,13 +988,13 @@ protected function handle_status_param( $post_status, $post_type ) { break; case 'private': if ( ! current_user_can( $post_type->cap->publish_posts ) ) { - return new WP_Error( 'rest_cannot_publish', __( 'Sorry, you are not allowed to create private posts in this post type' ), array( 'status' => rest_authorization_required_code() ) ); + return new WP_Error( 'rest_cannot_publish', __( 'Sorry, you are not allowed to create private posts in this post type.' ), array( 'status' => rest_authorization_required_code() ) ); } break; case 'publish': case 'future': if ( ! current_user_can( $post_type->cap->publish_posts ) ) { - return new WP_Error( 'rest_cannot_publish', __( 'Sorry, you are not allowed to publish posts in this post type' ), array( 'status' => rest_authorization_required_code() ) ); + return new WP_Error( 'rest_cannot_publish', __( 'Sorry, you are not allowed to publish posts in this post type.' ), array( 'status' => rest_authorization_required_code() ) ); } break; default: @@ -895,8 +1010,9 @@ protected function handle_status_param( $post_status, $post_type ) { /** * Determine the featured media based on a request param. * - * @param int $featured_media - * @param int $post_id + * @param int $featured_media Featured Media ID. + * @param int $post_id Post ID. + * @return bool|WP_Error */ protected function handle_featured_media( $featured_media, $post_id ) { @@ -917,11 +1033,11 @@ protected function handle_featured_media( $featured_media, $post_id ) { /** * Set the template for a page. * - * @param string $template - * @param integer $post_id + * @param string $template Page template filename. + * @param integer $post_id Post ID. */ public function handle_template( $template, $post_id ) { - if ( in_array( $template, array_keys( wp_get_theme()->get_page_templates( get_post( $post_id ) ) ) ) ) { + if ( in_array( $template, array_keys( wp_get_theme()->get_page_templates( $this->get_post( $post_id ) ) ), true ) ) { update_post_meta( $post_id, '_wp_page_template', $template ); } else { update_post_meta( $post_id, '_wp_page_template', '' ); @@ -933,7 +1049,7 @@ public function handle_template( $template, $post_id ) { * * @param int $post_id The post ID to update the terms form. * @param WP_REST_Request $request The request object with post and terms data. - * @return null|WP_Error WP_Error on an error assigning any of ther terms. + * @return null|WP_Error WP_Error on an error assigning any of the terms. */ protected function handle_terms( $post_id, $request ) { $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) ); @@ -954,7 +1070,7 @@ protected function handle_terms( $post_id, $request ) { /** * Check if a given post type should be viewed or managed. * - * @param object|string $post_type + * @param object|string $post_type Post type name or object. * @return boolean Is post type allowed? */ protected function check_is_post_type_allowed( $post_type ) { @@ -978,10 +1094,6 @@ protected function check_is_post_type_allowed( $post_type ) { * @return boolean Can we read it? */ public function check_read_permission( $post ) { - if ( ! empty( $post->post_password ) && ! $this->check_update_permission( $post ) ) { - return false; - } - $post_type = get_post_type_object( $post->post_type ); if ( ! $this->check_is_post_type_allowed( $post_type ) ) { return false; @@ -999,7 +1111,7 @@ public function check_read_permission( $post ) { // Can we read the parent if we're inheriting? if ( 'inherit' === $post->post_status && $post->post_parent > 0 ) { - $parent = get_post( $post->post_parent ); + $parent = $this->get_post( $post->post_parent ); return $this->check_read_permission( $parent ); } @@ -1063,7 +1175,7 @@ protected function check_delete_permission( $post ) { /** * Prepare a single post output for response. * - * @param WP_Post $post Post object. + * @param WP_Post $post Post object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $data */ @@ -1071,59 +1183,99 @@ public function prepare_item_for_response( $post, $request ) { $GLOBALS['post'] = $post; setup_postdata( $post ); + $schema = $this->get_item_schema(); + // Base fields for every post. - $data = array( - 'id' => $post->ID, - 'date' => $this->prepare_date_response( $post->post_date_gmt, $post->post_date ), - 'date_gmt' => $this->prepare_date_response( $post->post_date_gmt ), - 'guid' => array( + $data = array(); + + if ( ! empty( $schema['properties']['id'] ) ) { + $data['id'] = $post->ID; + } + + if ( ! empty( $schema['properties']['date'] ) ) { + $data['date'] = $this->prepare_date_response( $post->post_date_gmt, $post->post_date ); + } + + if ( ! empty( $schema['properties']['date_gmt'] ) ) { + $data['date_gmt'] = $this->prepare_date_response( $post->post_date_gmt ); + } + + if ( ! empty( $schema['properties']['guid'] ) ) { + $data['guid'] = array( /** This filter is documented in wp-includes/post-template.php */ 'rendered' => apply_filters( 'get_the_guid', $post->guid ), 'raw' => $post->guid, - ), - 'modified' => $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified ), - 'modified_gmt' => $this->prepare_date_response( $post->post_modified_gmt ), - 'password' => $post->post_password, - 'slug' => $post->post_name, - 'status' => $post->post_status, - 'type' => $post->post_type, - 'link' => get_permalink( $post->ID ), - ); + ); + } - $schema = $this->get_item_schema(); + if ( ! empty( $schema['properties']['modified'] ) ) { + $data['modified'] = $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified ); + } + + if ( ! empty( $schema['properties']['modified_gmt'] ) ) { + $data['modified_gmt'] = $this->prepare_date_response( $post->post_modified_gmt ); + } + + if ( ! empty( $schema['properties']['password'] ) ) { + $data['password'] = $post->post_password; + } + + if ( ! empty( $schema['properties']['slug'] ) ) { + $data['slug'] = $post->post_name; + } + + if ( ! empty( $schema['properties']['status'] ) ) { + $data['status'] = $post->post_status; + } + + if ( ! empty( $schema['properties']['type'] ) ) { + $data['type'] = $post->post_type; + } + + if ( ! empty( $schema['properties']['link'] ) ) { + $data['link'] = get_permalink( $post->ID ); + } if ( ! empty( $schema['properties']['title'] ) ) { + add_filter( 'protected_title_format', array( $this, 'protected_title_format' ) ); $data['title'] = array( 'raw' => $post->post_title, 'rendered' => get_the_title( $post->ID ), ); + remove_filter( 'protected_title_format', array( $this, 'protected_title_format' ) ); } - if ( ! empty( $schema['properties']['content'] ) ) { - - if ( ! empty( $post->post_password ) ) { - $this->prepare_password_response( $post->post_password ); - } + $has_password_filter = false; + if ( $this->can_access_password_content( $post, $request ) ) { + // Allow access to the post, permissions already checked before. + add_filter( 'post_password_required', '__return_false' ); + $has_password_filter = true; + } + if ( ! empty( $schema['properties']['content'] ) ) { $data['content'] = array( - 'raw' => $post->post_content, + 'raw' => $post->post_content, /** This filter is documented in wp-includes/post-template.php */ - 'rendered' => apply_filters( 'the_content', $post->post_content ), + 'rendered' => post_password_required( $post ) ? '' : apply_filters( 'the_content', $post->post_content ), + 'protected' => (bool) $post->post_password, ); - - // Don't leave our cookie lying around: https://github.com/WP-API/WP-API/issues/1055. - if ( ! empty( $post->post_password ) ) { - $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] = ''; - } } if ( ! empty( $schema['properties']['excerpt'] ) ) { + /** This filter is documented in wp-includes/post-template.php */ + $excerpt = apply_filters( 'the_excerpt', apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ) ); $data['excerpt'] = array( - 'raw' => $post->post_excerpt, - 'rendered' => $this->prepare_excerpt_response( $post->post_excerpt ), + 'raw' => $post->post_excerpt, + 'rendered' => post_password_required( $post ) ? '' : $excerpt, + 'protected' => (bool) $post->post_password, ); } + if ( $has_password_filter ) { + // Reset filter. + remove_filter( 'post_password_required', '__return_false' ); + } + if ( ! empty( $schema['properties']['author'] ) ) { $data['author'] = (int) $post->post_author; } @@ -1168,11 +1320,17 @@ public function prepare_item_for_response( $post, $request ) { } } + if ( ! empty( $schema['properties']['meta'] ) ) { + $data['meta'] = $this->meta->get_value( $post->ID, $request ); + } + $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) ); foreach ( $taxonomies as $taxonomy ) { $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; - $terms = get_the_terms( $post, $taxonomy->name ); - $data[ $base ] = $terms ? wp_list_pluck( $terms, 'term_id' ) : array(); + if ( ! empty( $schema['properties'][ $base ] ) ) { + $terms = get_the_terms( $post, $taxonomy->name ); + $data[ $base ] = $terms ? array_values( wp_list_pluck( $terms, 'term_id' ) ) : array(); + } } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; @@ -1197,6 +1355,19 @@ public function prepare_item_for_response( $post, $request ) { return apply_filters( "rest_prepare_{$this->post_type}", $response, $post, $request ); } + /** + * Overwrite the default protected title format. + * + * By default WordPress will show password protected posts with a title of + * "Protected: %s", as the REST API communicates the protected status of a post + * in a machine readable format, we remove the "Protected: " prefix. + * + * @return string + */ + public function protected_title_format() { + return '%s'; + } + /** * Prepare links for the request. * @@ -1204,9 +1375,9 @@ public function prepare_item_for_response( $post, $request ) { * @return array Links for the given post. */ protected function prepare_links( $post ) { - $base = sprintf( '/%s/%s', $this->namespace, $this->rest_base ); + $base = sprintf( '%s/%s', $this->namespace, $this->rest_base ); - // Entity meta + // Entity meta. $links = array( 'self' => array( 'href' => rest_url( trailingslashit( $base ) . $post->ID ), @@ -1215,20 +1386,20 @@ protected function prepare_links( $post ) { 'href' => rest_url( $base ), ), 'about' => array( - 'href' => rest_url( '/wp/v2/types/' . $this->post_type ), + 'href' => rest_url( 'wp/v2/types/' . $this->post_type ), ), ); - if ( ( in_array( $post->post_type, array( 'post', 'page' ) ) || post_type_supports( $post->post_type, 'author' ) ) + if ( ( in_array( $post->post_type, array( 'post', 'page' ), true ) || post_type_supports( $post->post_type, 'author' ) ) && ! empty( $post->post_author ) ) { $links['author'] = array( - 'href' => rest_url( '/wp/v2/users/' . $post->post_author ), + 'href' => rest_url( 'wp/v2/users/' . $post->post_author ), 'embeddable' => true, ); - }; + } - if ( in_array( $post->post_type, array( 'post', 'page' ) ) || post_type_supports( $post->post_type, 'comments' ) ) { - $replies_url = rest_url( '/wp/v2/comments' ); + if ( in_array( $post->post_type, array( 'post', 'page' ), true ) || post_type_supports( $post->post_type, 'comments' ) ) { + $replies_url = rest_url( 'wp/v2/comments' ); $replies_url = add_query_arg( 'post', $post->ID, $replies_url ); $links['replies'] = array( 'href' => $replies_url, @@ -1236,7 +1407,7 @@ protected function prepare_links( $post ) { ); } - if ( in_array( $post->post_type, array( 'post', 'page' ) ) || post_type_supports( $post->post_type, 'revisions' ) ) { + if ( in_array( $post->post_type, array( 'post', 'page' ), true ) || post_type_supports( $post->post_type, 'revisions' ) ) { $links['version-history'] = array( 'href' => rest_url( trailingslashit( $base ) . $post->ID . '/revisions' ), ); @@ -1257,7 +1428,7 @@ protected function prepare_links( $post ) { 'embeddable' => true, ); } - if ( ! in_array( $post->post_type, array( 'attachment', 'nav_menu_item', 'revision' ) ) ) { + if ( ! in_array( $post->post_type, array( 'attachment', 'nav_menu_item', 'revision' ), true ) ) { $attachments_url = rest_url( 'wp/v2/media' ); $attachments_url = add_query_arg( 'parent', $post->ID, $attachments_url ); $links['https://api.w.org/attachment'] = array( @@ -1331,11 +1502,13 @@ public function get_item_schema() { 'description' => __( 'GUID for the object, as it exists in the database.' ), 'type' => 'string', 'context' => array( 'edit' ), + 'readonly' => true, ), 'rendered' => array( 'description' => __( 'GUID for the object, transformed for display.' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), + 'readonly' => true, ), ), ), @@ -1366,17 +1539,12 @@ public function get_item_schema() { 'context' => array( 'view', 'edit' ), 'readonly' => true, ), - 'password' => array( - 'description' => __( 'A password to protect access to the post.' ), - 'type' => 'string', - 'context' => array( 'edit' ), - ), 'slug' => array( 'description' => __( 'An alphanumeric identifier for the object unique to its type.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'arg_options' => array( - 'sanitize_callback' => 'sanitize_title', + 'sanitize_callback' => array( $this, 'sanitize_slug' ), ), ), 'status' => array( @@ -1413,6 +1581,7 @@ public function get_item_schema() { 'revisions', 'page-attributes', 'post-formats', + 'custom-fields', ); $fixed_schemas = array( 'post' => array( @@ -1424,6 +1593,7 @@ public function get_item_schema() { 'comments', 'revisions', 'post-formats', + 'custom-fields', ), 'page' => array( 'title', @@ -1434,18 +1604,20 @@ public function get_item_schema() { 'comments', 'revisions', 'page-attributes', + 'custom-fields', ), 'attachment' => array( 'title', 'author', 'comments', 'revisions', + 'custom-fields', ), ); foreach ( $post_type_attributes as $attribute ) { - if ( isset( $fixed_schemas[ $this->post_type ] ) && ! in_array( $attribute, $fixed_schemas[ $this->post_type ] ) ) { + if ( isset( $fixed_schemas[ $this->post_type ] ) && ! in_array( $attribute, $fixed_schemas[ $this->post_type ], true ) ) { continue; - } elseif ( ! in_array( $this->post_type, array_keys( $fixed_schemas ) ) && ! post_type_supports( $this->post_type, $attribute ) ) { + } elseif ( ! isset( $fixed_schemas[ $this->post_type ] ) && ! post_type_supports( $this->post_type, $attribute ) ) { continue; } @@ -1466,6 +1638,7 @@ public function get_item_schema() { 'description' => __( 'HTML title for the object, transformed for display.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, ), ), ); @@ -1486,6 +1659,13 @@ public function get_item_schema() { 'description' => __( 'HTML content for the object, transformed for display.' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'protected' => array( + 'description' => __( 'Whether the content is protected with a password.' ), + 'type' => 'boolean', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, ), ), ); @@ -1514,6 +1694,13 @@ public function get_item_schema() { 'description' => __( 'HTML excerpt for the object, transformed for display.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'protected' => array( + 'description' => __( 'Whether the excerpt is protected with a password.' ), + 'type' => 'boolean', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, ), ), ); @@ -1559,6 +1746,10 @@ public function get_item_schema() { ); break; + case 'custom-fields': + $schema['properties']['meta'] = $this->meta->get_field_schema(); + break; + } } @@ -1568,6 +1759,12 @@ public function get_item_schema() { 'type' => 'boolean', 'context' => array( 'view', 'edit' ), ); + + $schema['properties']['password'] = array( + 'description' => __( 'A password to protect access to the content and excerpt.' ), + 'type' => 'string', + 'context' => array( 'edit' ), + ); } if ( 'page' === $this->post_type ) { @@ -1587,6 +1784,11 @@ public function get_item_schema() { 'type' => 'array', 'context' => array( 'view', 'edit' ), ); + $schema['properties'][ $base . '_exclude' ] = array( + 'description' => sprintf( __( 'The terms in the %s taxonomy that should not be assigned to the object.' ), $taxonomy->name ), + 'type' => 'array', + 'context' => array( 'view', 'edit' ), + ); } return $this->add_additional_fields_schema( $schema ); @@ -1614,14 +1816,12 @@ public function get_collection_params() { 'type' => 'array', 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', - 'validate_callback' => 'rest_validate_request_arg', ); $params['author_exclude'] = array( 'description' => __( 'Ensure result set excludes posts assigned to specific authors.' ), 'type' => 'array', 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', - 'validate_callback' => 'rest_validate_request_arg', ); } $params['before'] = array( @@ -1669,6 +1869,7 @@ public function get_collection_params() { 'default' => 'date', 'enum' => array( 'date', + 'relevance', 'id', 'include', 'title', @@ -1703,7 +1904,8 @@ public function get_collection_params() { ); $params['status'] = array( 'default' => 'publish', - 'description' => __( 'Limit result set to posts assigned a specific status.' ), + 'description' => __( 'Limit result set to posts assigned a specific status; can be comma-delimited list of status types.' ), + 'enum' => array_merge( array_keys( get_post_stati() ), array( 'any' ) ), 'sanitize_callback' => 'sanitize_key', 'type' => 'string', 'validate_callback' => array( $this, 'validate_user_can_query_private_statuses' ), @@ -1723,15 +1925,24 @@ public function get_collection_params() { 'default' => array(), ); } + + if ( 'post' === $this->post_type ) { + $params['sticky'] = array( + 'description' => __( 'Limit result set to items that are sticky.' ), + 'type' => 'boolean', + 'sanitize_callback' => 'rest_parse_request_arg', + ); + } + return $params; } /** - * Validate whether the user can query private statuses + * Validate whether the user can query private statuses. * - * @param mixed $value - * @param WP_REST_Request $request - * @param string $parameter + * @param mixed $value Post status. + * @param WP_REST_Request $request Full details about the request. + * @param string $parameter * @return WP_Error|boolean */ public function validate_user_can_query_private_statuses( $value, $request, $parameter ) { @@ -1742,7 +1953,6 @@ public function validate_user_can_query_private_statuses( $value, $request, $par if ( current_user_can( $post_type_obj->cap->edit_posts ) ) { return true; } - return new WP_Error( 'rest_forbidden_status', __( 'Status is forbidden' ), array( 'status' => rest_authorization_required_code() ) ); + return new WP_Error( 'rest_forbidden_status', __( 'Status is forbidden.' ), array( 'status' => rest_authorization_required_code() ) ); } - } diff --git a/lib/endpoints/class-wp-rest-revisions-controller.php b/lib/endpoints/class-wp-rest-revisions-controller.php index e4ada1c734..0fbdd73b8d 100755 --- a/lib/endpoints/class-wp-rest-revisions-controller.php +++ b/lib/endpoints/class-wp-rest-revisions-controller.php @@ -17,6 +17,8 @@ public function __construct( $parent_post_type ) { /** * Register routes for revisions based on post types supporting revisions + * + * @access public */ public function register_routes() { @@ -52,12 +54,14 @@ public function register_routes() { /** * Check if a given request has access to get revisions * + * @access public + * * @param WP_REST_Request $request Full data about the request. * @return WP_Error|boolean */ public function get_items_permissions_check( $request ) { - $parent = get_post( $request['parent'] ); + $parent = $this->get_post( $request['parent'] ); if ( ! $parent ) { return true; } @@ -72,12 +76,14 @@ public function get_items_permissions_check( $request ) { /** * Get a collection of revisions * + * @access public + * * @param WP_REST_Request $request Full data about the request. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { - $parent = get_post( $request['parent'] ); + $parent = $this->get_post( $request['parent'] ); if ( ! $request['parent'] || ! $parent || $this->parent_post_type !== $parent->post_type ) { return new WP_Error( 'rest_post_invalid_parent', __( 'Invalid post parent id.' ), array( 'status' => 404 ) ); } @@ -95,6 +101,8 @@ public function get_items( $request ) { /** * Check if a given request has access to get a specific revision * + * @access public + * * @param WP_REST_Request $request Full data about the request. * @return WP_Error|boolean */ @@ -105,17 +113,19 @@ public function get_item_permissions_check( $request ) { /** * Get one revision from the collection * + * @access public + * * @param WP_REST_Request $request Full data about the request. * @return WP_Error|array */ public function get_item( $request ) { - $parent = get_post( $request['parent'] ); + $parent = $this->get_post( $request['parent'] ); if ( ! $request['parent'] || ! $parent || $this->parent_post_type !== $parent->post_type ) { return new WP_Error( 'rest_post_invalid_parent', __( 'Invalid post parent id.' ), array( 'status' => 404 ) ); } - $revision = get_post( $request['id'] ); + $revision = $this->get_post( $request['id'] ); if ( ! $revision || 'revision' !== $revision->post_type ) { return new WP_Error( 'rest_post_invalid_id', __( 'Invalid revision id.' ), array( 'status' => 404 ) ); } @@ -127,6 +137,8 @@ public function get_item( $request ) { /** * Check if a given request has access to delete a revision * + * @access public + * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ @@ -137,7 +149,7 @@ public function delete_item_permissions_check( $request ) { return $response; } - $post = get_post( $request['id'] ); + $post = $this->get_post( $request['id'] ); if ( ! $post ) { return new WP_Error( 'rest_post_invalid_id', __( 'Invalid revision id.' ), array( 'status' => 404 ) ); } @@ -148,7 +160,9 @@ public function delete_item_permissions_check( $request ) { /** * Delete a single revision * - * @param WP_REST_Request $request Full details about the request + * @access public + * + * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function delete_item( $request ) { @@ -174,37 +188,79 @@ public function delete_item( $request ) { /** * Prepare the revision for the REST response * - * @param WP_Post $post Post revision object. + * @access public + * + * @param WP_Post $post Post revision object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response */ public function prepare_item_for_response( $post, $request ) { - // Base fields for every post - $data = array( - 'author' => $post->post_author, - 'date' => $this->prepare_date_response( $post->post_date_gmt, $post->post_date ), - 'date_gmt' => $this->prepare_date_response( $post->post_date_gmt ), - 'guid' => $post->guid, - 'id' => $post->ID, - 'modified' => $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified ), - 'modified_gmt' => $this->prepare_date_response( $post->post_modified_gmt ), - 'parent' => (int) $post->post_parent, - 'slug' => $post->post_name, - ); - $schema = $this->get_item_schema(); + $data = array(); + + if ( ! empty( $schema['properties']['author'] ) ) { + $data['author'] = $post->post_author; + } + + if ( ! empty( $schema['properties']['date'] ) ) { + $data['date'] = $this->prepare_date_response( $post->post_date_gmt, $post->post_date ); + } + + if ( ! empty( $schema['properties']['date_gmt'] ) ) { + $data['date_gmt'] = $this->prepare_date_response( $post->post_date_gmt ); + } + + if ( ! empty( $schema['properties']['id'] ) ) { + $data['id'] = $post->ID; + } + + if ( ! empty( $schema['properties']['modified'] ) ) { + $data['modified'] = $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified ); + } + + if ( ! empty( $schema['properties']['modified_gmt'] ) ) { + $data['modified_gmt'] = $this->prepare_date_response( $post->post_modified_gmt ); + } + + if ( ! empty( $schema['properties']['parent'] ) ) { + $data['parent'] = (int) $post->post_parent; + } + + if ( ! empty( $schema['properties']['slug'] ) ) { + $data['slug'] = $post->post_name; + } + + if ( ! empty( $schema['properties']['guid'] ) ) { + $data['guid'] = array( + /** This filter is documented in wp-includes/post-template.php */ + 'rendered' => apply_filters( 'get_the_guid', $post->guid ), + 'raw' => $post->guid, + ); + } + if ( ! empty( $schema['properties']['title'] ) ) { - $data['title'] = $post->post_title; + $data['title'] = array( + 'raw' => $post->post_title, + 'rendered' => get_the_title( $post->ID ), + ); } if ( ! empty( $schema['properties']['content'] ) ) { - $data['content'] = $post->post_content; + + $data['content'] = array( + 'raw' => $post->post_content, + /** This filter is documented in wp-includes/post-template.php */ + 'rendered' => apply_filters( 'the_content', $post->post_content ), + ); } if ( ! empty( $schema['properties']['excerpt'] ) ) { - $data['excerpt'] = $post->post_excerpt; + $data['excerpt'] = array( + 'raw' => $post->post_excerpt, + 'rendered' => $this->prepare_excerpt_response( $post->post_excerpt, $post ), + ); } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; @@ -213,7 +269,7 @@ public function prepare_item_for_response( $post, $request ) { $response = rest_ensure_response( $data ); if ( ! empty( $data['parent'] ) ) { - $response->add_link( 'parent', rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->parent_base, $data['parent'] ) ) ); + $response->add_link( 'parent', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->parent_base, $data['parent'] ) ) ); } /** @@ -232,8 +288,10 @@ public function prepare_item_for_response( $post, $request ) { * Check the post_date_gmt or modified_gmt and prepare any post or * modified date for single post output. * - * @param string $date_gmt - * @param string|null $date + * @access protected + * + * @param string $date_gmt GMT publication time. + * @param string|null $date Optional, default is null. Local publication time. * @return string|null ISO8601/RFC3339 formatted datetime. */ protected function prepare_date_response( $date_gmt, $date = null ) { @@ -251,6 +309,8 @@ protected function prepare_date_response( $date_gmt, $date = null ) { /** * Get the revision's schema, conforming to JSON Schema * + * @access public + * * @return array */ public function get_item_schema() { @@ -316,38 +376,17 @@ public function get_item_schema() { $parent_schema = $this->parent_controller->get_item_schema(); - foreach ( array( 'title', 'content', 'excerpt' ) as $property ) { - if ( empty( $parent_schema['properties'][ $property ] ) ) { - continue; - } - - switch ( $property ) { - - case 'title': - $schema['properties']['title'] = array( - 'description' => __( 'Title for the object, as it exists in the database.' ), - 'type' => 'string', - 'context' => array( 'view', 'edit', 'embed' ), - ); - break; - - case 'content': - $schema['properties']['content'] = array( - 'description' => __( 'Content for the object, as it exists in the database.' ), - 'type' => 'string', - 'context' => array( 'view', 'edit' ), - ); - break; - - case 'excerpt': - $schema['properties']['excerpt'] = array( - 'description' => __( 'Excerpt for the object, as it exists in the database.' ), - 'type' => 'string', - 'context' => array( 'view', 'edit', 'embed' ), - ); - break; - - } + if ( ! empty( $parent_schema['properties']['title'] ) ) { + $schema['properties']['title'] = $parent_schema['properties']['title']; + } + if ( ! empty( $parent_schema['properties']['content'] ) ) { + $schema['properties']['content'] = $parent_schema['properties']['content']; + } + if ( ! empty( $parent_schema['properties']['excerpt'] ) ) { + $schema['properties']['excerpt'] = $parent_schema['properties']['excerpt']; + } + if ( ! empty( $parent_schema['properties']['guid'] ) ) { + $schema['properties']['guid'] = $parent_schema['properties']['guid']; } return $this->add_additional_fields_schema( $schema ); @@ -356,6 +395,8 @@ public function get_item_schema() { /** * Get the query params for collections * + * @access public + * * @return array */ public function get_collection_params() { @@ -364,4 +405,24 @@ public function get_collection_params() { ); } + /** + * Check the post excerpt and prepare it for single post output. + * + * @access protected + * + * @param string $excerpt The post excerpt. + * @param WP_Post $post Post revision object. + * @return string|null $excerpt + */ + protected function prepare_excerpt_response( $excerpt, $post ) { + + /** This filter is documented in wp-includes/post-template.php */ + $excerpt = apply_filters( 'the_excerpt', $excerpt, $post ); + + if ( empty( $excerpt ) ) { + return ''; + } + + return $excerpt; + } } diff --git a/lib/endpoints/class-wp-rest-settings-controller.php b/lib/endpoints/class-wp-rest-settings-controller.php new file mode 100644 index 0000000000..474cd592fc --- /dev/null +++ b/lib/endpoints/class-wp-rest-settings-controller.php @@ -0,0 +1,220 @@ +namespace, '/' . $this->rest_base, array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'args' => array(), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_item' ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) ); + } + + /** + * Check if a given request has access to read and manage settings. + * + * @param WP_REST_Request $request Full details about the request. + * @return boolean + */ + public function get_item_permissions_check( $request ) { + return current_user_can( 'manage_options' ); + } + + /** + * Get the settings. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|array + */ + public function get_item( $request ) { + $options = $this->get_registered_options(); + $response = array(); + + foreach ( $options as $name => $args ) { + /** + * Filters the value of a setting recognized by the REST API. + * + * Allow hijacking the setting value and overriding the built-in behavior by returning a + * non-null value. The returned value will be presented as the setting value instead. + * + * @since 4.7.0 + * + * @param mixed $result Value to use for the requested setting. Can be a scalar + * matching the registered schema for the setting, or null to + * follow the default `get_option` behavior. + * @param string $name Setting name (as shown in REST API responses). + * @param array $args Arguments passed to `register_setting()` for this setting. + */ + $response[ $name ] = apply_filters( 'rest_pre_get_setting', null, $name, $args ); + + if ( is_null( $response[ $name ] ) ) { + // Default to a null value as "null" in the response means "not set". + $response[ $name ] = get_option( $args['option_name'], $args['schema']['default'] ); + } + + // Because get_option() is lossy, we have to + // cast values to the type they are registered with. + $response[ $name ] = $this->prepare_value( $response[ $name ], $args['schema'] ); + } + + return $response; + } + + /** + * Prepare a value for output based off a schema array. + * + * @param mixed $value + * @param array $schema + * @return mixed + */ + protected function prepare_value( $value, $schema ) { + switch ( $schema['type'] ) { + case 'string': + return (string) $value; + case 'number': + return (float) $value; + case 'boolean': + return (bool) $value; + default: + return null; + } + } + + /** + * Update settings for the settings object. + * + * @param WP_REST_Request $request Full detail about the request. + * @return WP_Error|array + */ + public function update_item( $request ) { + $options = $this->get_registered_options(); + $params = $request->get_params(); + + foreach ( $options as $name => $args ) { + if ( ! array_key_exists( $name, $params ) ) { + continue; + } + + /** + * Filters whether to preempt a setting value update. + * + * Allow hijacking the setting update logic and overriding the built-in behavior by + * returning true. + * + * @since 4.7.0 + * + * @param boolean $result Whether to override the default behavior for updating the + * value of a setting. + * @param string $name Setting name (as shown in REST API responses). + * @param mixed $value Updated setting value. + * @param array $args Arguments passed to `register_setting()` for this setting. + */ + $updated = apply_filters( 'rest_pre_update_setting', false, $name, $request[ $name ], $args ); + if ( $updated ) { + continue; + } + + // A null value means reset the option, which is essentially deleting it + // from the database and then relying on the default value. + if ( is_null( $request[ $name ] ) ) { + delete_option( $args['option_name'] ); + } else { + update_option( $args['option_name'], $request[ $name ] ); + } + } + + return $this->get_item( $request ); + } + + /** + * Get all the registered options for the Settings API + * + * @return array + */ + protected function get_registered_options() { + $rest_options = array(); + + foreach ( get_registered_settings() as $name => $args ) { + if ( empty( $args['show_in_rest'] ) ) { + continue; + } + + $rest_args = array(); + if ( is_array( $args['show_in_rest'] ) ) { + $rest_args = $args['show_in_rest']; + } + + $defaults = array( + 'name' => ! empty( $rest_args['name'] ) ? $rest_args['name'] : $name, + 'schema' => array(), + ); + $rest_args = array_merge( $defaults, $rest_args ); + + $default_schema = array( + 'type' => empty( $args['type'] ) ? null : $args['type'], + 'description' => empty( $args['description'] ) ? '' : $args['description'], + 'default' => isset( $args['default'] ) ? $args['default'] : null, + ); + + $rest_args['schema'] = array_merge( $default_schema, $rest_args['schema'] ); + $rest_args['option_name'] = $name; + + // Skip over settings that don't have a defined type in the schema. + if ( empty( $rest_args['schema']['type'] ) ) { + continue; + } + + // Whitelist the supported types for settings, as we don't want invalid types + // to be updated with arbitrary values that we can't do decent sanitizing for. + if ( ! in_array( $rest_args['schema']['type'], array( 'number', 'string', 'boolean' ), true ) ) { + continue; + } + + $rest_options[ $rest_args['name'] ] = $rest_args; + } + + return $rest_options; + } + + /** + * Get the site setting schema, conforming to JSON Schema. + * + * @return array + */ + public function get_item_schema() { + $options = $this->get_registered_options(); + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'settings', + 'type' => 'object', + 'properties' => array(), + ); + + foreach ( $options as $option_name => $option ) { + $schema['properties'][ $option_name ] = $option['schema']; + } + + return $this->add_additional_fields_schema( $schema ); + } +} diff --git a/lib/endpoints/class-wp-rest-taxonomies-controller.php b/lib/endpoints/class-wp-rest-taxonomies-controller.php index d6eaafb67f..010186ed8b 100755 --- a/lib/endpoints/class-wp-rest-taxonomies-controller.php +++ b/lib/endpoints/class-wp-rest-taxonomies-controller.php @@ -65,7 +65,11 @@ public function get_items_permissions_check( $request ) { * @return array */ public function get_items( $request ) { - if ( ! empty( $request['type'] ) ) { + + // Retrieve the list of registered collection query parameters. + $registered = $this->get_collection_params(); + + if ( isset( $registered['type'] ) && ! empty( $request['type'] ) ) { $taxonomies = get_object_taxonomies( $request['type'], 'objects' ); } else { $taxonomies = get_taxonomies( '', 'objects' ); @@ -79,6 +83,12 @@ public function get_items( $request ) { $tax = $this->prepare_response_for_collection( $tax ); $data[ $tax_type ] = $tax; } + + if ( empty( $data ) ) { + // Response should still be returned as a JSON object when it is empty. + $data = (object) $data; + } + return rest_ensure_response( $data ); } diff --git a/lib/endpoints/class-wp-rest-terms-controller.php b/lib/endpoints/class-wp-rest-terms-controller.php index 5f525e59a2..cf131adb67 100755 --- a/lib/endpoints/class-wp-rest-terms-controller.php +++ b/lib/endpoints/class-wp-rest-terms-controller.php @@ -1,24 +1,58 @@ taxonomy = $taxonomy; $this->namespace = 'wp/v2'; $tax_obj = get_taxonomy( $taxonomy ); $this->rest_base = ! empty( $tax_obj->rest_base ) ? $tax_obj->rest_base : $tax_obj->name; + + $this->meta = new WP_REST_Term_Meta_Fields( $taxonomy ); } /** - * Register the routes for the objects of the controller. + * Registers the routes for the objects of the controller. */ public function register_routes() { @@ -30,34 +64,34 @@ public function register_routes() { 'args' => $this->get_collection_params(), ), array( - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'create_item' ), + '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 ), + '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[\d]+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), 'args' => array( - 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), ), ), array( - 'methods' => WP_REST_Server::EDITABLE, - 'callback' => array( $this, 'update_item' ), + '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 ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), ), array( - 'methods' => WP_REST_Server::DELETABLE, - 'callback' => array( $this, 'delete_item' ), + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), 'permission_callback' => array( $this, 'delete_item_permissions_check' ), - 'args' => array( - 'force' => array( + 'args' => array( + 'force' => array( 'default' => false, 'description' => __( 'Required to be true, as resource does not support trashing.' ), ), @@ -68,7 +102,7 @@ public function register_routes() { } /** - * Check if a given request has access to read the terms. + * Checks if a request has access to read terms in the specified taxonomy. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean @@ -85,33 +119,51 @@ public function get_items_permissions_check( $request ) { } /** - * Get terms associated with a taxonomy + * Gets terms associated with a taxonomy. * - * @param WP_REST_Request $request Full details about the request + * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error */ public function get_items( $request ) { - $prepared_args = array( - 'exclude' => $request['exclude'], - 'include' => $request['include'], - 'order' => $request['order'], - 'orderby' => $request['orderby'], - 'post' => $request['post'], - 'hide_empty' => $request['hide_empty'], - 'number' => $request['per_page'], - 'search' => $request['search'], - 'slug' => $request['slug'], + + // Retrieve the list of registered collection query parameters. + $registered = $this->get_collection_params(); + + // This array defines mappings between public API query parameters whose + // values are accepted as-passed, and their internal WP_Query parameter + // name equivalents (some are the same). Only values which are also + // present in $registered will be set. + $parameter_mappings = array( + 'exclude' => 'exclude', + 'include' => 'include', + 'order' => 'order', + 'orderby' => 'orderby', + 'post' => 'post', + 'hide_empty' => 'hide_empty', + 'per_page' => 'number', + 'search' => 'search', + 'slug' => 'slug', ); - if ( ! empty( $request['offset'] ) ) { + $prepared_args = array(); + + // For each known parameter which is both registered and present in the request, + // set the parameter's value on the query $prepared_args. + foreach ( $parameter_mappings as $api_param => $wp_param ) { + if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) { + $prepared_args[ $wp_param ] = $request[ $api_param ]; + } + } + + if ( isset( $registered['offset'] ) && ! empty( $request['offset'] ) ) { $prepared_args['offset'] = $request['offset']; } else { - $prepared_args['offset'] = ( $request['page'] - 1 ) * $prepared_args['number']; + $prepared_args['offset'] = ( $request['page'] - 1 ) * $prepared_args['number']; } $taxonomy_obj = get_taxonomy( $this->taxonomy ); - if ( $taxonomy_obj->hierarchical && isset( $request['parent'] ) ) { + if ( $taxonomy_obj->hierarchical && isset( $registered['parent'], $request['parent'] ) ) { if ( 0 === $request['parent'] ) { // Only query top-level terms. $prepared_args['parent'] = 0; @@ -123,7 +175,7 @@ public function get_items( $request ) { } /** - * Filter the query arguments, before passing them to `get_terms()`. + * Filters the query arguments before passing them to get_terms(). * * Enables adding extra arguments or setting defaults for a terms * collection request. @@ -131,19 +183,11 @@ public function get_items( $request ) { * @see https://developer.wordpress.org/reference/functions/get_terms/ * * @param array $prepared_args Array of arguments to be - * passed to get_terms. + * passed to get_terms(). * @param WP_REST_Request $request The current request. */ $prepared_args = apply_filters( "rest_{$this->taxonomy}_query", $prepared_args, $request ); - // Can we use the cached call? - $use_cache = ! empty( $prepared_args['post'] ) - && empty( $prepared_args['include'] ) - && empty( $prepared_args['exclude'] ) - && empty( $prepared_args['hide_empty'] ) - && empty( $prepared_args['search'] ) - && empty( $prepared_args['slug'] ); - if ( ! empty( $prepared_args['post'] ) ) { $query_result = $this->get_terms_for_post( $prepared_args ); $total_terms = $this->total_terms; @@ -151,16 +195,9 @@ public function get_items( $request ) { $query_result = get_terms( $this->taxonomy, $prepared_args ); $count_args = $prepared_args; - unset( $count_args['number'] ); - unset( $count_args['offset'] ); + unset( $count_args['number'], $count_args['offset'] ); $total_terms = wp_count_terms( $this->taxonomy, $count_args ); - // Ensure we don't return results when offset is out of bounds - // see https://core.trac.wordpress.org/ticket/35935 - if ( $prepared_args['offset'] >= $total_terms ) { - $query_result = array(); - } - // wp_count_terms can return a falsy value when the term has no children if ( ! $total_terms ) { $total_terms = 0; @@ -174,7 +211,7 @@ public function get_items( $request ) { $response = rest_ensure_response( $response ); - // Store pagation values for headers then unset for count query. + // Store pagination values for headers. $per_page = (int) $prepared_args['number']; $page = ceil( ( ( (int) $prepared_args['offset'] ) / $per_page ) + 1 ); @@ -182,7 +219,7 @@ public function get_items( $request ) { $max_pages = ceil( $total_terms / $per_page ); $response->header( 'X-WP-TotalPages', (int) $max_pages ); - $base = add_query_arg( $request->get_query_params(), rest_url( '/' . $this->namespace . '/' . $this->rest_base ) ); + $base = add_query_arg( $request->get_query_params(), rest_url( $this->namespace . '/' . $this->rest_base ) ); if ( $page > 1 ) { $prev_page = $page - 1; if ( $prev_page > $max_pages ) { @@ -201,14 +238,14 @@ public function get_items( $request ) { } /** - * Get the terms attached to a post. + * Gets the terms attached to a post. * - * This is an alternative to `get_terms()` that uses `get_the_terms()` + * This is an alternative to get_terms() that uses get_the_terms() * instead, which hits the object cache. There are a few things not * supported, notably `include`, `exclude`. In `self::get_items()` these * are instead treated as a full query. * - * @param array $prepared_args Arguments for `get_terms()` + * @param array $prepared_args Arguments for get_terms(). * @return array List of term objects. (Total count in `$this->total_terms`) */ protected function get_terms_for_post( $prepared_args ) { @@ -218,9 +255,11 @@ protected function get_terms_for_post( $prepared_args ) { return array(); } - // get_items() verifies that we don't have `include` set, and default - // ordering is by `name` - if ( ! in_array( $prepared_args['orderby'], array( 'name', 'none', 'include' ) ) ) { + /* + * get_items() verifies that we don't have `include` set, and default + * ordering is by `name`. + */ + if ( ! in_array( $prepared_args['orderby'], array( 'name', 'none', 'include' ), true ) ) { switch ( $prepared_args['orderby'] ) { case 'id': $this->sort_column = 'term_id'; @@ -239,7 +278,7 @@ protected function get_terms_for_post( $prepared_args ) { $query_result = array_reverse( $query_result ); } - // Pagination + // Pagination. $this->total_terms = count( $query_result ); $query_result = array_slice( $query_result, $prepared_args['offset'], $prepared_args['number'] ); @@ -251,6 +290,8 @@ protected function get_terms_for_post( $prepared_args ) { * * Uses `$this->sort_column` to determine field to sort by. * + * @access protected + * * @param stdClass $left Term object. * @param stdClass $right Term object. * @return int <0 if left is higher "priority" than right, 0 if equal, >0 if right is higher "priority" than left. @@ -268,7 +309,7 @@ protected function compare_terms( $left, $right ) { } /** - * Check if a given request has access to read a term. + * Checks if a request has access to read the specified term. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean @@ -285,7 +326,7 @@ public function get_item_permissions_check( $request ) { } /** - * Get a single term from a taxonomy + * Gets a single term from a taxonomy. * * @param WP_REST_Request $request Full details about the request * @return WP_REST_Request|WP_Error @@ -306,7 +347,7 @@ public function get_item( $request ) { } /** - * Check if a given request has access to create a term + * Checks if a request has access to create a term. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean @@ -326,7 +367,7 @@ public function create_item_permissions_check( $request ) { } /** - * Create a single term for a taxonomy + * Creates a single term in a taxonomy. * * @param WP_REST_Request $request Full details about the request * @return WP_REST_Request|WP_Error @@ -349,10 +390,11 @@ public function create_item( $request ) { $term = wp_insert_term( $prepared_term->name, $this->taxonomy, $prepared_term ); if ( is_wp_error( $term ) ) { - // If we're going to inform the client that the term exists, give them the identifier - // they can actually use. - - if ( ( $term_id = $term->get_error_data( 'term_exists' ) ) ) { + /* + * If we're going to inform the client that the term already exists, + * give them the identifier for future use. + */ + if ( $term_id = $term->get_error_data( 'term_exists' ) ) { $existing_term = get_term( $term_id, $this->taxonomy ); $term->add_data( $existing_term->term_id, 'term_exists' ); } @@ -366,22 +408,34 @@ public function create_item( $request ) { * Fires after a single term is created or updated via the REST API. * * @param WP_Term $term Inserted Term object. - * @param WP_REST_Request $request Request object. - * @param boolean $creating True when creating term, false when updating. + * @param WP_REST_Request $request Request object. + * @param boolean $creating True when creating term, false when updating. */ do_action( "rest_insert_{$this->taxonomy}", $term, $request, true ); - $this->update_additional_fields_for_object( $term, $request ); + $schema = $this->get_item_schema(); + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], (int) $request['id'] ); + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $term, $request ); + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + $request->set_param( 'context', 'view' ); $response = $this->prepare_item_for_response( $term, $request ); $response = rest_ensure_response( $response ); $response->set_status( 201 ); - $response->header( 'Location', rest_url( '/' . $this->namespace . '/' . $this->rest_base . '/' . $term->term_id ) ); + $response->header( 'Location', rest_url( $this->namespace . '/' . $this->rest_base . '/' . $term->term_id ) ); return $response; } /** - * Check if a given request has access to update a term + * Checks if a request has access to update the specified term. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean @@ -406,7 +460,7 @@ public function update_item_permissions_check( $request ) { } /** - * Update a single term from a taxonomy + * Updates a single term from a taxonomy. * * @param WP_REST_Request $request Full details about the request * @return WP_REST_Request|WP_Error @@ -441,14 +495,26 @@ public function update_item( $request ) { /* This action is documented in lib/endpoints/class-wp-rest-terms-controller.php */ do_action( "rest_insert_{$this->taxonomy}", $term, $request, false ); - $this->update_additional_fields_for_object( $term, $request ); + $schema = $this->get_item_schema(); + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], (int) $request['id'] ); + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $term, $request ); + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + $request->set_param( 'context', 'view' ); $response = $this->prepare_item_for_response( $term, $request ); return rest_ensure_response( $response ); } /** - * Check if a given request has access to delete a term + * Checks if a request has access to delete the specified term. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean @@ -469,7 +535,7 @@ public function delete_item_permissions_check( $request ) { } /** - * Delete a single term from a taxonomy + * Deletes a single term from a taxonomy. * * @param WP_REST_Request $request Full details about the request * @return WP_REST_Response|WP_Error @@ -478,7 +544,7 @@ public function delete_item( $request ) { $force = isset( $request['force'] ) ? (bool) $request['force'] : false; - // We don't support trashing for this type, error out + // We don't support trashing for this resource type. if ( ! $force ) { return new WP_Error( 'rest_trash_not_supported', __( 'Resource does not support trashing.' ), array( 'status' => 501 ) ); } @@ -505,7 +571,7 @@ public function delete_item( $request ) { } /** - * Prepare a single term for create or update + * Prepares a single term for create or update. * * @param WP_REST_Request $request Request object. * @return object $prepared_term Term object. @@ -513,23 +579,24 @@ public function delete_item( $request ) { public function prepare_item_for_database( $request ) { $prepared_term = new stdClass; - if ( isset( $request['name'] ) ) { + $schema = $this->get_item_schema(); + if ( isset( $request['name'] ) && ! empty( $schema['properties']['name'] ) ) { $prepared_term->name = $request['name']; } - if ( isset( $request['slug'] ) ) { + if ( isset( $request['slug'] ) && ! empty( $schema['properties']['slug'] ) ) { $prepared_term->slug = $request['slug']; } - if ( isset( $request['taxonomy'] ) ) { + if ( isset( $request['taxonomy'] ) && ! empty( $schema['properties']['taxonomy'] ) ) { $prepared_term->taxonomy = $request['taxonomy']; } - if ( isset( $request['description'] ) ) { + if ( isset( $request['description'] ) && ! empty( $schema['properties']['description'] ) ) { $prepared_term->description = $request['description']; } - if ( isset( $request['parent'] ) ) { + if ( isset( $request['parent'] ) && ! empty( $schema['properties']['parent'] ) ) { $parent_term_id = 0; $parent_term = get_term( (int) $request['parent'], $this->taxonomy ); @@ -541,7 +608,7 @@ public function prepare_item_for_database( $request ) { } /** - * Filter term data before inserting term via the REST API. + * Filters term data before inserting term via the REST API. * * @param object $prepared_term Term object. * @param WP_REST_Request $request Request object. @@ -550,27 +617,43 @@ public function prepare_item_for_database( $request ) { } /** - * Prepare a single term output for response + * Prepares a single term output for response. * - * @param obj $item Term object - * @param WP_REST_Request $request + * @param obj $item Term object. + * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response */ public function prepare_item_for_response( $item, $request ) { - $data = array( - 'id' => (int) $item->term_id, - 'count' => (int) $item->count, - 'description' => $item->description, - 'link' => get_term_link( $item ), - 'name' => $item->name, - 'slug' => $item->slug, - 'taxonomy' => $item->taxonomy, - ); $schema = $this->get_item_schema(); + $data = array(); + if ( ! empty( $schema['properties']['id'] ) ) { + $data['id'] = (int) $item->term_id; + } + if ( ! empty( $schema['properties']['count'] ) ) { + $data['count'] = (int) $item->count; + } + if ( ! empty( $schema['properties']['description'] ) ) { + $data['description'] = $item->description; + } + if ( ! empty( $schema['properties']['link'] ) ) { + $data['link'] = get_term_link( $item ); + } + if ( ! empty( $schema['properties']['name'] ) ) { + $data['name'] = $item->name; + } + if ( ! empty( $schema['properties']['slug'] ) ) { + $data['slug'] = $item->slug; + } + if ( ! empty( $schema['properties']['taxonomy'] ) ) { + $data['taxonomy'] = $item->taxonomy; + } if ( ! empty( $schema['properties']['parent'] ) ) { $data['parent'] = (int) $item->parent; } + if ( ! empty( $schema['properties']['meta'] ) ) { + $data['meta'] = $this->meta->get_value( $item->term_id, $request ); + } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); @@ -581,7 +664,7 @@ public function prepare_item_for_response( $item, $request ) { $response->add_links( $this->prepare_links( $item ) ); /** - * Filter a term item returned from the API. + * Filters a term item returned from the API. * * Allows modification of the term data right before it is returned. * @@ -593,22 +676,22 @@ public function prepare_item_for_response( $item, $request ) { } /** - * Prepare links for the request. + * Prepares links for the request. * * @param object $term Term object. * @return array Links for the given term. */ protected function prepare_links( $term ) { - $base = '/' . $this->namespace . '/' . $this->rest_base; + $base = $this->namespace . '/' . $this->rest_base; $links = array( 'self' => array( - 'href' => rest_url( trailingslashit( $base ) . $term->term_id ), + 'href' => rest_url( trailingslashit( $base ) . $term->term_id ), ), 'collection' => array( - 'href' => rest_url( $base ), + 'href' => rest_url( $base ), ), 'about' => array( - 'href' => rest_url( sprintf( 'wp/v2/taxonomies/%s', $this->taxonomy ) ), + 'href' => rest_url( sprintf( 'wp/v2/taxonomies/%s', $this->taxonomy ) ), ), ); @@ -646,29 +729,29 @@ protected function prepare_links( $term ) { } /** - * Get the Term's schema, conforming to JSON Schema + * Gets the term's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( - '$schema' => 'http://json-schema.org/draft-04/schema#', - 'title' => 'post_tag' === $this->taxonomy ? 'tag' : $this->taxonomy, - 'type' => 'object', - 'properties' => array( - 'id' => array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'post_tag' === $this->taxonomy ? 'tag' : $this->taxonomy, + 'type' => 'object', + 'properties' => array( + 'id' => array( 'description' => __( 'Unique identifier for the resource.' ), 'type' => 'integer', 'context' => array( 'view', 'embed', 'edit' ), 'readonly' => true, ), - 'count' => array( + 'count' => array( 'description' => __( 'Number of published posts for the resource.' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), - 'description' => array( + 'description' => array( 'description' => __( 'HTML description of the resource.' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), @@ -676,14 +759,14 @@ public function get_item_schema() { 'sanitize_callback' => 'wp_filter_post_kses', ), ), - 'link' => array( + 'link' => array( 'description' => __( 'URL to the resource.' ), 'type' => 'string', 'format' => 'uri', 'context' => array( 'view', 'embed', 'edit' ), 'readonly' => true, ), - 'name' => array( + 'name' => array( 'description' => __( 'HTML title for the resource.' ), 'type' => 'string', 'context' => array( 'view', 'embed', 'edit' ), @@ -692,15 +775,15 @@ public function get_item_schema() { ), 'required' => true, ), - 'slug' => array( + 'slug' => array( 'description' => __( 'An alphanumeric identifier for the resource unique to its type.' ), 'type' => 'string', 'context' => array( 'view', 'embed', 'edit' ), 'arg_options' => array( - 'sanitize_callback' => 'sanitize_title', + 'sanitize_callback' => array( $this, 'sanitize_slug' ), ), ), - 'taxonomy' => array( + 'taxonomy' => array( 'description' => __( 'Type attribution for the resource.' ), 'type' => 'string', 'enum' => array_keys( get_taxonomies() ), @@ -717,11 +800,13 @@ public function get_item_schema() { 'context' => array( 'view', 'edit' ), ); } + + $schema['properties']['meta'] = $this->meta->get_field_schema(); return $this->add_additional_fields_schema( $schema ); } /** - * Get the query params for collections + * Gets the query params for collections. * * @return array */ @@ -732,42 +817,42 @@ public function get_collection_params() { $query_params['context']['default'] = 'view'; $query_params['exclude'] = array( - 'description' => __( 'Ensure result set excludes specific ids.' ), - 'type' => 'array', - 'default' => array(), - 'sanitize_callback' => 'wp_parse_id_list', + 'description' => __( 'Ensure result set excludes specific ids.' ), + 'type' => 'array', + 'default' => array(), + 'sanitize_callback' => 'wp_parse_id_list', ); $query_params['include'] = array( - 'description' => __( 'Limit result set to specific ids.' ), - 'type' => 'array', - 'default' => array(), - 'sanitize_callback' => 'wp_parse_id_list', + 'description' => __( 'Limit result set to specific ids.' ), + 'type' => 'array', + 'default' => array(), + 'sanitize_callback' => 'wp_parse_id_list', ); if ( ! $taxonomy->hierarchical ) { $query_params['offset'] = array( - 'description' => __( 'Offset the result set by a specific number of items.' ), - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', + 'description' => __( 'Offset the result set by a specific number of items.' ), + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', ); } - $query_params['order'] = array( - 'description' => __( 'Order sort attribute ascending or descending.' ), - 'type' => 'string', - 'sanitize_callback' => 'sanitize_key', - 'default' => 'asc', - 'enum' => array( + $query_params['order'] = array( + 'description' => __( 'Order sort attribute ascending or descending.' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + 'default' => 'asc', + 'enum' => array( 'asc', 'desc', ), - 'validate_callback' => 'rest_validate_request_arg', + 'validate_callback' => 'rest_validate_request_arg', ); - $query_params['orderby'] = array( - 'description' => __( 'Sort collection by resource attribute.' ), - 'type' => 'string', - 'sanitize_callback' => 'sanitize_key', - 'default' => 'name', - 'enum' => array( + $query_params['orderby'] = array( + 'description' => __( 'Sort collection by resource attribute.' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + 'default' => 'name', + 'enum' => array( 'id', 'include', 'name', @@ -776,40 +861,40 @@ public function get_collection_params() { 'description', 'count', ), - 'validate_callback' => 'rest_validate_request_arg', + 'validate_callback' => 'rest_validate_request_arg', ); $query_params['hide_empty'] = array( - 'description' => __( 'Whether to hide resources not assigned to any posts.' ), - 'type' => 'boolean', - 'default' => false, - 'validate_callback' => 'rest_validate_request_arg', + 'description' => __( 'Whether to hide resources not assigned to any posts.' ), + 'type' => 'boolean', + 'default' => false, + 'validate_callback' => 'rest_validate_request_arg', ); if ( $taxonomy->hierarchical ) { $query_params['parent'] = array( - 'description' => __( 'Limit result set to resources assigned to a specific parent.' ), - 'type' => 'integer', - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', + 'description' => __( 'Limit result set to resources assigned to a specific parent.' ), + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', ); } $query_params['post'] = array( - 'description' => __( 'Limit result set to resources assigned to a specific post.' ), - 'type' => 'integer', - 'default' => null, - 'validate_callback' => 'rest_validate_request_arg', + 'description' => __( 'Limit result set to resources assigned to a specific post.' ), + 'type' => 'integer', + 'default' => null, + 'validate_callback' => 'rest_validate_request_arg', ); - $query_params['slug'] = array( - 'description' => __( 'Limit result set to resources with a specific slug.' ), - 'type' => 'string', - 'validate_callback' => 'rest_validate_request_arg', + $query_params['slug'] = array( + 'description' => __( 'Limit result set to resources with a specific slug.' ), + 'type' => 'string', + 'validate_callback' => 'rest_validate_request_arg', ); return $query_params; } /** - * Check that the taxonomy is valid + * Checks that the taxonomy is valid. * - * @param string + * @param string $taxonomy Taxonomy to check. * @return WP_Error|boolean */ protected function check_is_taxonomy_allowed( $taxonomy ) { diff --git a/lib/endpoints/class-wp-rest-users-controller.php b/lib/endpoints/class-wp-rest-users-controller.php index 5ab45c31b6..12a13055a2 100755 --- a/lib/endpoints/class-wp-rest-users-controller.php +++ b/lib/endpoints/class-wp-rest-users-controller.php @@ -5,9 +5,19 @@ */ class WP_REST_Users_Controller extends WP_REST_Controller { + /** + * Instance of a user meta fields object. + * + * @access protected + * @var WP_REST_User_Meta_Fields + */ + protected $meta; + public function __construct() { $this->namespace = 'wp/v2'; $this->rest_base = 'users'; + + $this->meta = new WP_REST_User_Meta_Fields(); } /** @@ -82,6 +92,14 @@ public function get_items_permissions_check( $request ) { return new WP_Error( 'rest_user_cannot_view', __( 'Sorry, you cannot filter by role.' ), array( 'status' => rest_authorization_required_code() ) ); } + if ( 'edit' === $request['context'] && ! current_user_can( 'list_users' ) ) { + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you cannot view this resource with edit context.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + if ( in_array( $request['orderby'], array( 'email', 'registered_date' ), true ) && ! current_user_can( 'list_users' ) ) { + return new WP_Error( 'rest_forbidden_orderby', __( 'Sorry, you cannot order by this parameter.' ), array( 'status' => rest_authorization_required_code() ) ); + } + return true; } @@ -93,35 +111,60 @@ public function get_items_permissions_check( $request ) { */ public function get_items( $request ) { + // Retrieve the list of registered collection query parameters. + $registered = $this->get_collection_params(); + + // This array defines mappings between public API query parameters whose + // values are accepted as-passed, and their internal WP_Query parameter + // name equivalents (some are the same). Only values which are also + // present in $registered will be set. + $parameter_mappings = array( + 'exclude' => 'exclude', + 'include' => 'include', + 'order' => 'order', + 'per_page' => 'number', + 'search' => 'search', + 'roles' => 'role__in', + ); + $prepared_args = array(); - $prepared_args['exclude'] = $request['exclude']; - $prepared_args['include'] = $request['include']; - $prepared_args['order'] = $request['order']; - $prepared_args['number'] = $request['per_page']; - if ( ! empty( $request['offset'] ) ) { + + // For each known parameter which is both registered and present in the request, + // set the parameter's value on the query $prepared_args. + foreach ( $parameter_mappings as $api_param => $wp_param ) { + if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) { + $prepared_args[ $wp_param ] = $request[ $api_param ]; + } + } + + if ( isset( $registered['offset'] ) && ! empty( $request['offset'] ) ) { $prepared_args['offset'] = $request['offset']; } else { - $prepared_args['offset'] = ( $request['page'] - 1 ) * $prepared_args['number']; + $prepared_args['offset'] = ( $request['page'] - 1 ) * $prepared_args['number']; + } + + if ( isset( $registered['orderby'] ) ) { + $orderby_possibles = array( + 'id' => 'ID', + 'include' => 'include', + 'name' => 'display_name', + 'registered_date' => 'registered', + 'slug' => 'user_nicename', + 'email' => 'user_email', + 'url' => 'user_url', + ); + $prepared_args['orderby'] = $orderby_possibles[ $request['orderby'] ]; } - $orderby_possibles = array( - 'id' => 'ID', - 'include' => 'include', - 'name' => 'display_name', - 'registered_date' => 'registered', - ); - $prepared_args['orderby'] = $orderby_possibles[ $request['orderby'] ]; - $prepared_args['search'] = $request['search']; - $prepared_args['role__in'] = $request['roles']; if ( ! current_user_can( 'list_users' ) ) { $prepared_args['has_published_posts'] = true; } - if ( '' !== $prepared_args['search'] ) { + if ( ! empty( $prepared_args['search'] ) ) { $prepared_args['search'] = '*' . $prepared_args['search'] . '*'; } - if ( ! empty( $request['slug'] ) ) { + if ( isset( $registered['slug'] ) && ! empty( $request['slug'] ) ) { $prepared_args['search'] = $request['slug']; $prepared_args['search_columns'] = array( 'user_nicename' ); } @@ -146,7 +189,7 @@ public function get_items( $request ) { $response = rest_ensure_response( $users ); - // Store pagation values for headers then unset for count query. + // Store pagination values for headers then unset for count query. $per_page = (int) $prepared_args['number']; $page = ceil( ( ( (int) $prepared_args['offset'] ) / $per_page ) + 1 ); @@ -155,8 +198,7 @@ public function get_items( $request ) { $total_users = $query->get_total(); if ( $total_users < 1 ) { // Out-of-bounds, run the query again without LIMIT for total count - unset( $prepared_args['number'] ); - unset( $prepared_args['offset'] ); + unset( $prepared_args['number'], $prepared_args['offset'] ); $count_query = new WP_User_Query( $prepared_args ); $total_users = $count_query->get_total(); } @@ -164,7 +206,7 @@ public function get_items( $request ) { $max_pages = ceil( $total_users / $per_page ); $response->header( 'X-WP-TotalPages', (int) $max_pages ); - $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) ); + $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); if ( $page > 1 ) { $prev_page = $page - 1; if ( $prev_page > $max_pages ) { @@ -192,7 +234,7 @@ public function get_item_permissions_check( $request ) { $id = (int) $request['id']; $user = get_userdata( $id ); - $types = get_post_types( array( 'public' => true ), 'names' ); + $types = get_post_types( array( 'show_in_rest' => true ), 'names' ); if ( empty( $id ) || empty( $user->ID ) ) { return new WP_Error( 'rest_user_invalid_id', __( 'Invalid resource id.' ), array( 'status' => 404 ) ); @@ -202,9 +244,9 @@ public function get_item_permissions_check( $request ) { return true; } - if ( 'edit' === $request['context'] && ! current_user_can( 'list_users' ) && ! current_user_can( 'edit_user', $id ) ) { - return new WP_Error( 'rest_user_cannot_view', __( 'Sorry, you cannot view this resource with view context.' ), array( 'status' => rest_authorization_required_code() ) ); - } else if ( ! count_user_posts( $id, $types ) && ! current_user_can( 'edit_user', $id ) && ! current_user_can( 'list_users' ) ) { + if ( 'edit' === $request['context'] && ! current_user_can( 'list_users' ) ) { + return new WP_Error( 'rest_user_cannot_view', __( 'Sorry, you cannot view this resource with edit context.' ), array( 'status' => rest_authorization_required_code() ) ); + } elseif ( ! count_user_posts( $id, $types ) && ! current_user_can( 'edit_user', $id ) && ! current_user_can( 'list_users' ) ) { return new WP_Error( 'rest_user_cannot_view', __( 'Sorry, you cannot view this resource.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -246,7 +288,7 @@ public function get_current_item( $request ) { $user = wp_get_current_user(); $response = $this->prepare_item_for_response( $user, $request ); $response = rest_ensure_response( $response ); - $response->header( 'Location', rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $current_user_id ) ) ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $current_user_id ) ) ); $response->set_status( 302 ); return $response; @@ -256,7 +298,7 @@ public function get_current_item( $request ) { * Check if a given request has access create users * * @param WP_REST_Request $request Full details about the request. - * @return boolean + * @return WP_Error|boolean */ public function create_item_permissions_check( $request ) { @@ -278,7 +320,9 @@ public function create_item( $request ) { return new WP_Error( 'rest_user_exists', __( 'Cannot create existing resource.' ), array( 'status' => 400 ) ); } - if ( ! empty( $request['roles'] ) ) { + $schema = $this->get_item_schema(); + + if ( ! empty( $request['roles'] ) && ! empty( $schema['properties']['roles'] ) ) { $check_permission = $this->check_role_update( $request['id'], $request['roles'] ); if ( is_wp_error( $check_permission ) ) { return $check_permission; @@ -312,11 +356,21 @@ public function create_item( $request ) { } $user = get_user_by( 'id', $user_id ); - if ( ! empty( $request['roles'] ) ) { + if ( ! empty( $request['roles'] ) && ! empty( $schema['properties']['roles'] ) ) { array_map( array( $user, 'add_role' ), $request['roles'] ); } - $this->update_additional_fields_for_object( $user, $request ); + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], $user_id ); + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $user, $request ); + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } /** * Fires after a user is created or updated via the REST API. @@ -331,7 +385,7 @@ public function create_item( $request ) { $response = $this->prepare_item_for_response( $user, $request ); $response = rest_ensure_response( $response ); $response->set_status( 201 ); - $response->header( 'Location', rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $user_id ) ) ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $user_id ) ) ); return $response; } @@ -340,7 +394,7 @@ public function create_item( $request ) { * Check if a given request has access update a user * * @param WP_REST_Request $request Full details about the request. - * @return boolean + * @return WP_Error|boolean */ public function update_item_permissions_check( $request ) { @@ -368,7 +422,7 @@ public function update_item( $request ) { $user = get_userdata( $id ); if ( ! $user ) { - return new WP_Error( 'rest_user_invalid_id', __( 'Invalid resource id.' ), array( 'status' => 400 ) ); + return new WP_Error( 'rest_user_invalid_id', __( 'Invalid resource id.' ), array( 'status' => 404 ) ); } if ( email_exists( $request['email'] ) && $request['email'] !== $user->user_email ) { @@ -376,7 +430,7 @@ public function update_item( $request ) { } if ( ! empty( $request['username'] ) && $request['username'] !== $user->user_login ) { - return new WP_Error( 'rest_user_invalid_argument', __( "Username isn't editable" ), array( 'status' => 400 ) ); + return new WP_Error( 'rest_user_invalid_argument', __( "Username isn't editable." ), array( 'status' => 400 ) ); } if ( ! empty( $request['slug'] ) && $request['slug'] !== $user->user_nicename && get_user_by( 'slug', $request['slug'] ) ) { @@ -405,7 +459,18 @@ public function update_item( $request ) { array_map( array( $user, 'add_role' ), $request['roles'] ); } - $this->update_additional_fields_for_object( $user, $request ); + $schema = $this->get_item_schema(); + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], $id ); + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $user, $request ); + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } /* This action is documented in lib/endpoints/class-wp-rest-users-controller.php */ do_action( 'rest_insert_user', $user, $request, false ); @@ -420,7 +485,7 @@ public function update_item( $request ) { * Check if a given request has access delete a user * * @param WP_REST_Request $request Full details about the request. - * @return boolean + * @return WP_Error|boolean */ public function delete_item_permissions_check( $request ) { @@ -451,7 +516,7 @@ public function delete_item( $request ) { $user = get_userdata( $id ); if ( ! $user ) { - return new WP_Error( 'rest_user_invalid_id', __( 'Invalid resource id.' ), array( 'status' => 400 ) ); + return new WP_Error( 'rest_user_invalid_id', __( 'Invalid resource id.' ), array( 'status' => 404 ) ); } if ( ! empty( $reassign ) ) { @@ -492,31 +557,80 @@ public function delete_item( $request ) { * @return WP_REST_Response $response Response data. */ public function prepare_item_for_response( $user, $request ) { - $data = array( - 'id' => $user->ID, - 'username' => $user->user_login, - 'name' => $user->display_name, - 'first_name' => $user->first_name, - 'last_name' => $user->last_name, - 'email' => $user->user_email, - 'url' => $user->user_url, - 'description' => $user->description, - 'link' => get_author_posts_url( $user->ID ), - 'nickname' => $user->nickname, - 'slug' => $user->user_nicename, - 'registered_date' => date( 'c', strtotime( $user->user_registered ) ), - 'roles' => $user->roles, - 'capabilities' => $user->allcaps, - 'extra_capabilities' => $user->caps, - ); + $data = array(); $schema = $this->get_item_schema(); + if ( ! empty( $schema['properties']['id'] ) ) { + $data['id'] = $user->ID; + } + + if ( ! empty( $schema['properties']['username'] ) ) { + $data['username'] = $user->user_login; + } + + if ( ! empty( $schema['properties']['name'] ) ) { + $data['name'] = $user->display_name; + } + + if ( ! empty( $schema['properties']['first_name'] ) ) { + $data['first_name'] = $user->first_name; + } + + if ( ! empty( $schema['properties']['last_name'] ) ) { + $data['last_name'] = $user->last_name; + } + + if ( ! empty( $schema['properties']['email'] ) ) { + $data['email'] = $user->user_email; + } + + if ( ! empty( $schema['properties']['url'] ) ) { + $data['url'] = $user->user_url; + } + + if ( ! empty( $schema['properties']['description'] ) ) { + $data['description'] = $user->description; + } + + if ( ! empty( $schema['properties']['link'] ) ) { + $data['link'] = get_author_posts_url( $user->ID, $user->user_nicename ); + } + + if ( ! empty( $schema['properties']['nickname'] ) ) { + $data['nickname'] = $user->nickname; + } + + if ( ! empty( $schema['properties']['slug'] ) ) { + $data['slug'] = $user->user_nicename; + } + + if ( ! empty( $schema['properties']['roles'] ) ) { + // Defensively call array_values() to ensure an array is returned. + $data['roles'] = array_values( $user->roles ); + } + + if ( ! empty( $schema['properties']['registered_date'] ) ) { + $data['registered_date'] = date( 'c', strtotime( $user->user_registered ) ); + } + + if ( ! empty( $schema['properties']['capabilities'] ) ) { + $data['capabilities'] = (object) $user->allcaps; + } + + if ( ! empty( $schema['properties']['extra_capabilities'] ) ) { + $data['extra_capabilities'] = (object) $user->caps; + } if ( ! empty( $schema['properties']['avatar_urls'] ) ) { $data['avatar_urls'] = rest_get_avatar_urls( $user->user_email ); } + if ( ! empty( $schema['properties']['meta'] ) ) { + $data['meta'] = $this->meta->get_value( $user->ID, $request ); + } + $context = ! empty( $request['context'] ) ? $request['context'] : 'embed'; + $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); @@ -544,10 +658,10 @@ public function prepare_item_for_response( $user, $request ) { protected function prepare_links( $user ) { $links = array( 'self' => array( - 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $user->ID ) ), + 'href' => rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $user->ID ) ), ), 'collection' => array( - 'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ), + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), ), ); @@ -563,14 +677,16 @@ protected function prepare_links( $user ) { protected function prepare_item_for_database( $request ) { $prepared_user = new stdClass; + $schema = $this->get_item_schema(); + // required arguments. - if ( isset( $request['email'] ) ) { + if ( isset( $request['email'] ) && ! empty( $schema['properties']['email'] ) ) { $prepared_user->user_email = $request['email']; } - if ( isset( $request['username'] ) ) { + if ( isset( $request['username'] ) && ! empty( $schema['properties']['username'] ) ) { $prepared_user->user_login = $request['username']; } - if ( isset( $request['password'] ) ) { + if ( isset( $request['password'] ) && ! empty( $schema['properties']['password'] ) ) { $prepared_user->user_pass = $request['password']; } @@ -578,26 +694,26 @@ protected function prepare_item_for_database( $request ) { if ( isset( $request['id'] ) ) { $prepared_user->ID = absint( $request['id'] ); } - if ( isset( $request['name'] ) ) { + if ( isset( $request['name'] ) && ! empty( $schema['properties']['name'] ) ) { $prepared_user->display_name = $request['name']; } - if ( isset( $request['first_name'] ) ) { + if ( isset( $request['first_name'] ) && ! empty( $schema['properties']['first_name'] ) ) { $prepared_user->first_name = $request['first_name']; } - if ( isset( $request['last_name'] ) ) { + if ( isset( $request['last_name'] ) && ! empty( $schema['properties']['last_name'] ) ) { $prepared_user->last_name = $request['last_name']; } - if ( isset( $request['nickname'] ) ) { + if ( isset( $request['nickname'] ) && ! empty( $schema['properties']['nickname'] ) ) { $prepared_user->nickname = $request['nickname']; } - if ( isset( $request['slug'] ) ) { + if ( isset( $request['slug'] ) && ! empty( $schema['properties']['slug'] ) ) { $prepared_user->user_nicename = $request['slug']; } - if ( isset( $request['description'] ) ) { + if ( isset( $request['description'] ) && ! empty( $schema['properties']['description'] ) ) { $prepared_user->description = $request['description']; } - if ( isset( $request['url'] ) ) { + if ( isset( $request['url'] ) && ! empty( $schema['properties']['url'] ) ) { $prepared_user->user_url = $request['url']; } @@ -618,8 +734,8 @@ protected function prepare_item_for_database( $request ) { /** * Determine if the current user is allowed to make the desired roles change. * - * @param integer $user_id - * @param array $roles + * @param integer $user_id User ID. + * @param array $roles New user roles. * @return WP_Error|boolean */ protected function check_role_update( $user_id, $roles ) { @@ -744,12 +860,13 @@ public function get_item_schema() { 'type' => 'string', 'context' => array( 'embed', 'view', 'edit' ), 'arg_options' => array( - 'sanitize_callback' => 'sanitize_title', + 'sanitize_callback' => array( $this, 'sanitize_slug' ), ), ), 'registered_date' => array( 'description' => __( 'Registration date for the resource.' ), - 'type' => 'date-time', + 'type' => 'string', + 'format' => 'date-time', 'context' => array( 'edit' ), 'readonly' => true, ), @@ -768,6 +885,7 @@ public function get_item_schema() { 'description' => __( 'All capabilities assigned to the resource.' ), 'type' => 'object', 'context' => array( 'edit' ), + 'readonly' => true, ), 'extra_capabilities' => array( 'description' => __( 'Any extra capabilities assigned to the resource.' ), @@ -798,9 +916,10 @@ public function get_item_schema() { 'readonly' => true, 'properties' => $avatar_properties, ); - } + $schema['properties']['meta'] = $this->meta->get_field_schema(); + return $this->add_additional_fields_schema( $schema ); } @@ -848,6 +967,9 @@ public function get_collection_params() { 'include', 'name', 'registered_date', + 'slug', + 'email', + 'url', ), 'sanitize_callback' => 'sanitize_key', 'type' => 'string', diff --git a/lib/fields/class-wp-rest-comment-meta-fields.php b/lib/fields/class-wp-rest-comment-meta-fields.php new file mode 100644 index 0000000000..15d89dafd4 --- /dev/null +++ b/lib/fields/class-wp-rest-comment-meta-fields.php @@ -0,0 +1,21 @@ +get_rest_field_type(), 'meta', array( + 'get_callback' => array( $this, 'get_value' ), + 'update_callback' => array( $this, 'update_value' ), + 'schema' => $this->get_field_schema(), + )); + } + + /** + * Get the `meta` field value. + * + * @param int $object_id Object ID to fetch meta for. + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|object + */ + public function get_value( $object_id, $request ) { + $fields = $this->get_registered_fields(); + $response = array(); + + foreach ( $fields as $name => $args ) { + $all_values = get_metadata( $this->get_meta_type(), $object_id, $name, false ); + if ( $args['single'] ) { + if ( empty( $all_values ) ) { + $value = $args['schema']['default']; + } else { + $value = $all_values[0]; + } + $value = $this->prepare_value_for_response( $value, $request, $args ); + } else { + $value = array(); + foreach ( $all_values as $row ) { + $value[] = $this->prepare_value_for_response( $row, $request, $args ); + } + } + + $response[ $name ] = $value; + } + + return (object) $response; + } + + /** + * Prepare value for response. + * + * This is required because some native types cannot be stored correctly in + * the database, such as booleans. We need to cast back to the relevant + * type before passing back to JSON. + * + * @param mixed $value Value to prepare. + * @param WP_REST_Request $request Current request object. + * @param array $args Options for the field. + * @return mixed Prepared value. + */ + protected function prepare_value_for_response( $value, $request, $args ) { + if ( ! empty( $args['prepare_callback'] ) ) { + $value = call_user_func( $args['prepare_callback'], $value, $request, $args ); + } + + return $value; + } + + /** + * Update meta values. + * + * @param WP_REST_Request $request Full details about the request. + * @param int $object_id Object ID to fetch meta for. + * @return WP_Error|null Error if one occurs, null on success. + */ + public function update_value( $request, $object_id ) { + $fields = $this->get_registered_fields(); + + foreach ( $fields as $name => $args ) { + if ( ! array_key_exists( $name, $request ) ) { + continue; + } + + // A null value means reset the field, which is essentially deleting it + // from the database and then relying on the default value. + if ( is_null( $request[ $name ] ) ) { + $result = $this->delete_meta_value( $object_id, $name ); + } elseif ( $args['single'] ) { + $result = $this->update_meta_value( $object_id, $name, $request[ $name ] ); + } else { + $result = $this->update_multi_meta_value( $object_id, $name, $request[ $name ] ); + } + + if ( is_wp_error( $result ) ) { + return $result; + } + } + + return null; + } + + /** + * Delete meta value for an object. + * + * @param int $object_id Object ID the field belongs to. + * @param string $name Key for the field. + * @return bool|WP_Error True if meta field is deleted, error otherwise. + */ + protected function delete_meta_value( $object_id, $name ) { + if ( ! current_user_can( 'delete_post_meta', $object_id, $name ) ) { + return new WP_Error( + 'rest_cannot_delete', + sprintf( __( 'You do not have permission to edit the %s custom field.' ), $name ), + array( 'key' => $name, 'status' => rest_authorization_required_code() ) + ); + } + + if ( ! delete_metadata( $this->get_meta_type(), $object_id, wp_slash( $name ) ) ) { + return new WP_Error( + 'rest_meta_database_error', + __( 'Could not delete meta value from database.' ), + array( 'key' => $name, 'status' => WP_Http::INTERNAL_SERVER_ERROR ) + ); + } + + return true; + } + + /** + * Update multiple meta values for an object. + * + * Alters the list of values in the database to match the list of provided values. + * + * @param int $object_id Object ID to update. + * @param string $name Key for the custom field. + * @param array $values List of values to update to. + * @return bool|WP_Error True if meta fields are updated, error otherwise. + */ + protected function update_multi_meta_value( $object_id, $name, $values ) { + if ( ! current_user_can( 'edit_post_meta', $object_id, $name ) ) { + return new WP_Error( + 'rest_cannot_update', + sprintf( __( 'You do not have permission to edit the %s custom field.' ), $name ), + array( 'key' => $name, 'status' => rest_authorization_required_code() ) + ); + } + + $current = get_metadata( $this->get_meta_type(), $object_id, $name, false ); + + $to_remove = $current; + $to_add = $values; + foreach ( $to_add as $add_key => $value ) { + $remove_keys = array_keys( $to_remove, $value, true ); + if ( empty( $remove_keys ) ) { + continue; + } + + if ( count( $remove_keys ) > 1 ) { + // To remove, we need to remove first, then add, so don't touch. + continue; + } + + $remove_key = $remove_keys[0]; + unset( $to_remove[ $remove_key ] ); + unset( $to_add[ $add_key ] ); + } + + // `delete_metadata` removes _all_ instances of the value, so only call + // once. + $to_remove = array_unique( $to_remove ); + foreach ( $to_remove as $value ) { + if ( ! delete_metadata( $this->get_meta_type(), $object_id, wp_slash( $name ), wp_slash( $value ) ) ) { + return new WP_Error( + 'rest_meta_database_error', + __( 'Could not update meta value in database.' ), + array( 'key' => $name, 'status' => WP_Http::INTERNAL_SERVER_ERROR ) + ); + } + } + foreach ( $to_add as $value ) { + if ( ! add_metadata( $this->get_meta_type(), $object_id, wp_slash( $name ), wp_slash( $value ) ) ) { + return new WP_Error( + 'rest_meta_database_error', + __( 'Could not update meta value in database.' ), + array( 'key' => $name, 'status' => WP_Http::INTERNAL_SERVER_ERROR ) + ); + } + } + + return true; + } + + /** + * Update meta value for an object. + * + * @param int $object_id Object ID to update. + * @param string $name Key for the custom field. + * @param mixed $value Updated value. + * @return bool|WP_Error True if meta field is updated, error otherwise. + */ + protected function update_meta_value( $object_id, $name, $value ) { + if ( ! current_user_can( 'edit_post_meta', $object_id, $name ) ) { + return new WP_Error( + 'rest_cannot_update', + sprintf( __( 'You do not have permission to edit the %s custom field.' ), $name ), + array( 'key' => $name, 'status' => rest_authorization_required_code() ) + ); + } + + $meta_type = $this->get_meta_type(); + $meta_key = wp_slash( $name ); + $meta_value = wp_slash( $value ); + + // Do the exact same check for a duplicate value as in update_metadata() to avoid update_metadata() returning false. + $old_value = get_metadata( $meta_type, $object_id, $meta_key ); + if ( 1 === count( $old_value ) ) { + if ( $old_value[0] === $meta_value ) { + return true; + } + } + + if ( ! update_metadata( $meta_type, $object_id, $meta_key, $meta_value ) ) { + return new WP_Error( + 'rest_meta_database_error', + __( 'Could not update meta value in database.' ), + array( 'key' => $name, 'status' => WP_Http::INTERNAL_SERVER_ERROR ) + ); + } + + return true; + } + + /** + * Get all the registered meta fields. + * + * @return array + */ + protected function get_registered_fields() { + $registered = array(); + + foreach ( get_registered_meta_keys( $this->get_meta_type() ) as $name => $args ) { + if ( empty( $args['show_in_rest'] ) ) { + continue; + } + + $rest_args = array(); + if ( is_array( $args['show_in_rest'] ) ) { + $rest_args = $args['show_in_rest']; + } + + $default_args = array( + 'name' => $name, + 'single' => $args['single'], + 'schema' => array(), + 'prepare_callback' => array( $this, 'prepare_value' ), + ); + $default_schema = array( + 'type' => null, + 'description' => empty( $args['description'] ) ? '' : $args['description'], + 'default' => isset( $args['default'] ) ? $args['default'] : null, + ); + $rest_args = array_merge( $default_args, $rest_args ); + $rest_args['schema'] = array_merge( $default_schema, $rest_args['schema'] ); + + if ( empty( $rest_args['schema']['type'] ) ) { + // Skip over meta fields that don't have a defined type. + if ( empty( $args['type'] ) ) { + continue; + } + + if ( $rest_args['single'] ) { + $rest_args['schema']['type'] = $args['type']; + } else { + $rest_args['schema']['type'] = 'array'; + $rest_args['schema']['items'] = array( + 'type' => $args['type'], + ); + } + } + + $registered[ $rest_args['name'] ] = $rest_args; + } // End foreach(). + + return $registered; + } + + /** + * Get the object's `meta` schema, conforming to JSON Schema. + * + * @return array + */ + public function get_field_schema() { + $fields = $this->get_registered_fields(); + + $schema = array( + 'description' => __( 'Meta fields.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'properties' => array(), + ); + + foreach ( $fields as $key => $args ) { + $schema['properties'][ $key ] = $args['schema']; + } + + return $schema; + } + + /** + * Prepare a meta value for output. + * + * Default preparation for meta fields. Override by passing the + * `prepare_callback` in your `show_in_rest` options. + * + * @param mixed $value Meta value from the database. + * @param WP_REST_Request $request Request object. + * @param array $args REST-specific options for the meta key. + * @return mixed Value prepared for output. + */ + public static function prepare_value( $value, $request, $args ) { + $type = $args['schema']['type']; + + // For multi-value fields, check the item type instead. + if ( 'array' === $type && ! empty( $args['schema']['items']['type'] ) ) { + $type = $args['schema']['items']['type']; + } + + switch ( $type ) { + case 'string': + $value = (string) $value; + break; + case 'number': + $value = (float) $value; + break; + case 'boolean': + $value = (bool) $value; + break; + } + + // Don't allow objects to be output. + if ( is_object( $value ) && ! ( $value instanceof JsonSerializable ) ) { + return null; + } + + return $value; + } +} diff --git a/lib/fields/class-wp-rest-post-meta-fields.php b/lib/fields/class-wp-rest-post-meta-fields.php new file mode 100644 index 0000000000..c16460374f --- /dev/null +++ b/lib/fields/class-wp-rest-post-meta-fields.php @@ -0,0 +1,37 @@ +post_type = $post_type; + } + + /** + * Get the object type for meta. + * + * @return string + */ + protected function get_meta_type() { + return 'post'; + } + + /** + * Get the type for `register_rest_field`. + * + * @return string Custom post type slug. + */ + public function get_rest_field_type() { + return $this->post_type; + } +} diff --git a/lib/fields/class-wp-rest-term-meta-fields.php b/lib/fields/class-wp-rest-term-meta-fields.php new file mode 100644 index 0000000000..3a36ee3154 --- /dev/null +++ b/lib/fields/class-wp-rest-term-meta-fields.php @@ -0,0 +1,39 @@ +taxonomy = $taxonomy; + } + + /** + * Get the object type for meta. + * + * @return string + */ + protected function get_meta_type() { + return 'term'; + } + + /** + * Get the type for `register_rest_field`. + * + * @return string + */ + public function get_rest_field_type() { + return 'post_tag' === $this->taxonomy ? 'tag' : $this->taxonomy; + } +} diff --git a/lib/fields/class-wp-rest-user-meta-fields.php b/lib/fields/class-wp-rest-user-meta-fields.php new file mode 100644 index 0000000000..2e25c7e4e4 --- /dev/null +++ b/lib/fields/class-wp-rest-user-meta-fields.php @@ -0,0 +1,21 @@ +=' ) ) { + return; +} + /** * WP_REST_Controller class. */ @@ -79,6 +89,48 @@ require_once dirname( __FILE__ ) . '/lib/endpoints/class-wp-rest-comments-controller.php'; } +/** + * WP_REST_Settings_Controller class. + */ +if ( ! class_exists( 'WP_REST_Settings_Controller' ) ) { + require_once dirname( __FILE__ ) . '/lib/endpoints/class-wp-rest-settings-controller.php'; +} + +/** + * WP_REST_Meta_Fields class. + */ +if ( ! class_exists( 'WP_REST_Meta_Fields' ) ) { + require_once dirname( __FILE__ ) . '/lib/fields/class-wp-rest-meta-fields.php'; +} + +/** + * WP_REST_Comment_Meta_Fields class. + */ +if ( ! class_exists( 'WP_REST_Comment_Meta_Fields' ) ) { + require_once dirname( __FILE__ ) . '/lib/fields/class-wp-rest-comment-meta-fields.php'; +} + +/** + * WP_REST_Post_Meta_Fields class. + */ +if ( ! class_exists( 'WP_REST_Post_Meta_Fields' ) ) { + require_once dirname( __FILE__ ) . '/lib/fields/class-wp-rest-post-meta-fields.php'; +} + +/** + * WP_REST_Term_Meta_Fields class. + */ +if ( ! class_exists( 'WP_REST_Term_Meta_Fields' ) ) { + require_once dirname( __FILE__ ) . '/lib/fields/class-wp-rest-term-meta-fields.php'; +} + +/** + * WP_REST_User_Meta_Fields class. + */ +if ( ! class_exists( 'WP_REST_User_Meta_Fields' ) ) { + require_once dirname( __FILE__ ) . '/lib/fields/class-wp-rest-user-meta-fields.php'; +} + /** * REST extras. */ @@ -87,7 +139,8 @@ add_filter( 'init', '_add_extra_api_post_type_arguments', 11 ); add_action( 'init', '_add_extra_api_taxonomy_arguments', 11 ); -add_action( 'rest_api_init', 'create_initial_rest_routes', 0 ); +add_action( 'rest_api_init', 'rest_register_settings', 10 ); +add_action( 'rest_api_init', 'create_initial_rest_routes', 99 ); /** * Adds extra post type registration arguments. @@ -145,6 +198,121 @@ function _add_extra_api_taxonomy_arguments() { } } + +/** + * Register the settings to be used in the REST API. + * + * This is required are WordPress Core does not internally register + * it's settings via `register_rest_setting()`. This should be removed + * once / if core starts to register settings internally. + */ +function rest_register_settings() { + global $wp_version; + if ( version_compare( $wp_version, '4.7-alpha', '<' ) ) { + return; + } + + register_setting( 'general', 'blogname', array( + 'show_in_rest' => array( + 'name' => 'title', + ), + 'type' => 'string', + 'description' => __( 'Site title.' ), + ) ); + + register_setting( 'general', 'blogdescription', array( + 'show_in_rest' => array( + 'name' => 'description', + ), + 'type' => 'string', + 'description' => __( 'Site description.' ), + ) ); + + register_setting( 'general', 'siteurl', array( + 'show_in_rest' => array( + 'name' => 'url', + 'schema' => array( + 'format' => 'uri', + ), + ), + 'type' => 'string', + 'description' => __( 'Site URL.' ), + ) ); + + register_setting( 'general', 'admin_email', array( + 'show_in_rest' => array( + 'name' => 'email', + 'schema' => array( + 'format' => 'email', + ), + ), + 'type' => 'string', + 'description' => __( 'This address is used for admin purposes. If you change this we will send you an email at your new address to confirm it. The new address will not become active until confirmed.' ), + ) ); + + register_setting( 'general', 'timezone_string', array( + 'show_in_rest' => array( + 'name' => 'timezone', + ), + 'type' => 'string', + 'description' => __( 'A city in the same timezone as you.' ), + ) ); + + register_setting( 'general', 'date_format', array( + 'show_in_rest' => true, + 'type' => 'string', + 'description' => __( 'A date format for all date strings.' ), + ) ); + + register_setting( 'general', 'time_format', array( + 'show_in_rest' => true, + 'type' => 'string', + 'description' => __( 'A time format for all time strings.' ), + ) ); + + register_setting( 'general', 'start_of_week', array( + 'show_in_rest' => true, + 'type' => 'number', + 'description' => __( 'A day number of the week that the week should start on.' ), + ) ); + + register_setting( 'general', 'WPLANG', array( + 'show_in_rest' => array( + 'name' => 'language', + ), + 'type' => 'string', + 'description' => __( 'WordPress locale code.' ), + 'default' => 'en_US', + ) ); + + register_setting( 'writing', 'use_smilies', array( + 'show_in_rest' => true, + 'type' => 'boolean', + 'description' => __( 'Convert emoticons like :-) and :-P to graphics on display.' ), + 'default' => true, + ) ); + + register_setting( 'writing', 'default_category', array( + 'show_in_rest' => true, + 'type' => 'number', + 'description' => __( 'Default category.' ), + ) ); + + register_setting( 'writing', 'default_post_format', array( + 'show_in_rest' => true, + 'type' => 'string', + 'description' => __( 'Default post format.' ), + ) ); + + register_setting( 'reading', 'posts_per_page', array( + 'show_in_rest' => true, + 'type' => 'number', + 'description' => __( 'Blog pages show at most.' ), + 'default' => 10, + ) ); +} + + if ( ! function_exists( 'create_initial_rest_routes' ) ) { /** * Registers default REST API routes. @@ -206,6 +374,13 @@ function create_initial_rest_routes() { // Comments. $controller = new WP_REST_Comments_Controller; $controller->register_routes(); + + // Settings. 4.7+ only. + global $wp_version; + if ( version_compare( $wp_version, '4.7-alpha', '>=' ) ) { + $controller = new WP_REST_Settings_Controller; + $controller->register_routes(); + } } } @@ -290,17 +465,21 @@ function rest_validate_request_arg( $value, $request, $param ) { $args = $attributes['args'][ $param ]; if ( ! empty( $args['enum'] ) ) { - if ( ! in_array( $value, $args['enum'] ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s is not one of %s' ), $param, implode( ', ', $args['enum'] ) ) ); + if ( ! in_array( $value, $args['enum'], true ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: list of valid values */ __( '%1$s is not one of %2$s.' ), $param, implode( ', ', $args['enum'] ) ) ); } } if ( 'integer' === $args['type'] && ! is_numeric( $value ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s is not of type %s' ), $param, 'integer' ) ); + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: type name */ __( '%1$s is not of type %2$s.' ), $param, 'integer' ) ); + } + + if ( 'boolean' === $args['type'] && ! rest_is_boolean( $value ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: type name */ __( '%1$s is not of type %2$s.' ), $value, 'boolean' ) ); } if ( 'string' === $args['type'] && ! is_string( $value ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s is not of type %s' ), $param, 'string' ) ); + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: type name */ __( '%1$s is not of type %2$s.' ), $param, 'string' ) ); } if ( isset( $args['format'] ) ) { @@ -316,38 +495,43 @@ function rest_validate_request_arg( $value, $request, $param ) { return new WP_Error( 'rest_invalid_email', __( 'The email address you provided is invalid.' ) ); } break; + case 'ipv4' : + if ( ! rest_is_ip_address( $value ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( __( '%s is not a valid IP address.' ), $value ) ); + } + break; } } - if ( in_array( $args['type'], array( 'numeric', 'integer' ) ) && ( isset( $args['minimum'] ) || isset( $args['maximum'] ) ) ) { + if ( in_array( $args['type'], array( 'numeric', 'integer' ), true ) && ( isset( $args['minimum'] ) || isset( $args['maximum'] ) ) ) { if ( isset( $args['minimum'] ) && ! isset( $args['maximum'] ) ) { if ( ! empty( $args['exclusiveMinimum'] ) && $value <= $args['minimum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s must be greater than %d (exclusive)' ), $param, $args['minimum'] ) ); - } else if ( empty( $args['exclusiveMinimum'] ) && $value < $args['minimum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s must be greater than %d (inclusive)' ), $param, $args['minimum'] ) ); + return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s must be greater than %2$d (exclusive)' ), $param, $args['minimum'] ) ); + } elseif ( empty( $args['exclusiveMinimum'] ) && $value < $args['minimum'] ) { + return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s must be greater than %2$d (inclusive)' ), $param, $args['minimum'] ) ); } - } else if ( isset( $args['maximum'] ) && ! isset( $args['minimum'] ) ) { + } elseif ( isset( $args['maximum'] ) && ! isset( $args['minimum'] ) ) { if ( ! empty( $args['exclusiveMaximum'] ) && $value >= $args['maximum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s must be less than %d (exclusive)' ), $param, $args['maximum'] ) ); - } else if ( empty( $args['exclusiveMaximum'] ) && $value > $args['maximum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s must be less than %d (inclusive)' ), $param, $args['maximum'] ) ); + return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s must be less than %2$d (exclusive)' ), $param, $args['maximum'] ) ); + } elseif ( empty( $args['exclusiveMaximum'] ) && $value > $args['maximum'] ) { + return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s must be less than %2$d (inclusive)' ), $param, $args['maximum'] ) ); } - } else if ( isset( $args['maximum'] ) && isset( $args['minimum'] ) ) { + } elseif ( isset( $args['maximum'] ) && isset( $args['minimum'] ) ) { if ( ! empty( $args['exclusiveMinimum'] ) && ! empty( $args['exclusiveMaximum'] ) ) { if ( $value >= $args['maximum'] || $value <= $args['minimum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s must be between %d (exclusive) and %d (exclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: minimum number, 3: maximum number */ __( '%1$s must be between %2$d (exclusive) and %3$d (exclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); } - } else if ( empty( $args['exclusiveMinimum'] ) && ! empty( $args['exclusiveMaximum'] ) ) { + } elseif ( empty( $args['exclusiveMinimum'] ) && ! empty( $args['exclusiveMaximum'] ) ) { if ( $value >= $args['maximum'] || $value < $args['minimum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s must be between %d (inclusive) and %d (exclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: minimum number, 3: maximum number */ __( '%1$s must be between %2$d (inclusive) and %3$d (exclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); } - } else if ( ! empty( $args['exclusiveMinimum'] ) && empty( $args['exclusiveMaximum'] ) ) { + } elseif ( ! empty( $args['exclusiveMinimum'] ) && empty( $args['exclusiveMaximum'] ) ) { if ( $value > $args['maximum'] || $value <= $args['minimum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s must be between %d (exclusive) and %d (inclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: minimum number, 3: maximum number */ __( '%1$s must be between %2$d (exclusive) and %3$d (inclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); } - } else if ( empty( $args['exclusiveMinimum'] ) && empty( $args['exclusiveMaximum'] ) ) { + } elseif ( empty( $args['exclusiveMinimum'] ) && empty( $args['exclusiveMaximum'] ) ) { if ( $value > $args['maximum'] || $value < $args['minimum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s must be between %d (inclusive) and %d (inclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: minimum number, 3: maximum number */ __( '%1$s must be between %2$d (inclusive) and %3$d (inclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); } } } @@ -378,6 +562,10 @@ function rest_sanitize_request_arg( $value, $request, $param ) { return (int) $value; } + if ( 'boolean' === $args['type'] ) { + return rest_sanitize_boolean( $value ); + } + if ( isset( $args['format'] ) ) { switch ( $args['format'] ) { case 'date-time' : @@ -391,10 +579,113 @@ function rest_sanitize_request_arg( $value, $request, $param ) { case 'uri' : return esc_url_raw( $value ); + + case 'ipv4' : + return sanitize_text_field( $value ); } } return $value; } +} + +if ( ! function_exists( 'rest_parse_request_arg' ) ) { + /** + * Parse a request argument based on details registered to the route. + * + * Runs a validation check and sanitizes the value, primarily to be used via + * the `sanitize_callback` arguments in the endpoint args registration. + * + * @param mixed $value + * @param WP_REST_Request $request + * @param string $param + * @return mixed + */ + function rest_parse_request_arg( $value, $request, $param ) { + + $is_valid = rest_validate_request_arg( $value, $request, $param ); + + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } + + $value = rest_sanitize_request_arg( $value, $request, $param ); + + return $value; + } +} + +if ( ! function_exists( 'rest_is_ip_address' ) ) { + /** + * Determines if a IPv4 address is valid. + * + * Does not handle IPv6 addresses. + * + * @param string $ipv4 IP 32-bit address. + * @return string|false The valid IPv4 address, otherwise false. + */ + function rest_is_ip_address( $ipv4 ) { + $pattern = '/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/'; + + if ( ! preg_match( $pattern, $ipv4 ) ) { + return false; + } + + return $ipv4; + } +} + +/** + * Changes a boolean-like value into the proper boolean value. + * + * @param bool|string|int $value The value being evaluated. + * @return boolean Returns the proper associated boolean value. + */ +if ( ! function_exists( 'rest_sanitize_boolean' ) ) { + function rest_sanitize_boolean( $value ) { + // String values are translated to `true`; make sure 'false' is false. + if ( is_string( $value ) ) { + $value = strtolower( $value ); + if ( in_array( $value, array( 'false', '0' ), true ) ) { + $value = false; + } + } + + // Everything else will map nicely to boolean. + return (boolean) $value; + } +} + +/** + * Determines if a given value is boolean-like. + * + * @param bool|string $maybe_bool The value being evaluated. + * @return boolean True if a boolean, otherwise false. + */ +if ( ! function_exists( 'rest_is_boolean' ) ) { + function rest_is_boolean( $maybe_bool ) { + if ( is_bool( $maybe_bool ) ) { + return true; + } + + if ( is_string( $maybe_bool ) ) { + $maybe_bool = strtolower( $maybe_bool ); + + $valid_boolean_values = array( + 'false', + 'true', + '0', + '1', + ); + + return in_array( $maybe_bool, $valid_boolean_values, true ); + } + + if ( is_int( $maybe_bool ) ) { + return in_array( $maybe_bool, array( 0, 1 ), true ); + } + + return false; + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index febc586515..d1bd4fa3b4 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -19,13 +19,13 @@ */ if ( false !== getenv( 'WP_DEVELOP_DIR' ) ) { $test_root = getenv( 'WP_DEVELOP_DIR' ) . '/tests/phpunit'; -} else if ( false !== getenv( 'WP_TESTS_DIR' ) ) { +} elseif ( false !== getenv( 'WP_TESTS_DIR' ) ) { $test_root = getenv( 'WP_TESTS_DIR' ); -} else if ( false !== getenv( 'WP_ROOT_DIR' ) ) { +} elseif ( false !== getenv( 'WP_ROOT_DIR' ) ) { $test_root = getenv( 'WP_ROOT_DIR' ) . '/tests/phpunit'; -} else if ( file_exists( '../../../../tests/phpunit/includes/bootstrap.php' ) ) { +} elseif ( file_exists( '../../../../tests/phpunit/includes/bootstrap.php' ) ) { $test_root = '../../../../tests/phpunit'; -} else if ( file_exists( '/tmp/wordpress-tests-lib/includes/bootstrap.php' ) ) { +} elseif ( file_exists( '/tmp/wordpress-tests-lib/includes/bootstrap.php' ) ) { $test_root = '/tmp/wordpress-tests-lib'; } @@ -45,6 +45,24 @@ function _manually_load_plugin() { if ( ! class_exists( 'WP_Test_REST_TestCase' ) ) { require_once dirname( __FILE__ ) . '/class-wp-test-rest-testcase.php'; } +function test_rest_expand_compact_links( $links ) { + if ( empty( $links['curies'] ) ) { + return $links; + } + foreach ( $links as $rel => $links_array ) { + if ( ! strpos( $rel, ':' ) ) { + continue; + } + + $name = explode( ':', $rel ); + + $curie = wp_list_filter( $links['curies'], array( 'name' => $name[0] ) ); + $full_uri = str_replace( '{rel}', $name[1], $curie[0]['href'] ); + $links[ $full_uri ] = $links_array; + unset( $links[ $rel ] ); + } + return $links; +} require_once dirname( __FILE__ ) . '/class-wp-test-rest-controller-testcase.php'; require_once dirname( __FILE__ ) . '/class-wp-test-rest-post-type-controller-testcase.php'; diff --git a/tests/class-wp-test-rest-controller-testcase.php b/tests/class-wp-test-rest-controller-testcase.php index ce3de4cfdb..28092554ec 100644 --- a/tests/class-wp-test-rest-controller-testcase.php +++ b/tests/class-wp-test-rest-controller-testcase.php @@ -6,7 +6,7 @@ abstract class WP_Test_REST_Controller_Testcase extends WP_Test_REST_TestCase { public function setUp() { parent::setUp(); - + add_filter( 'rest_url', array( $this, 'filter_rest_url_for_leading_slash' ), 10, 2 ); /** @var WP_REST_Server $wp_rest_server */ global $wp_rest_server; $this->server = $wp_rest_server = new WP_Test_Spy_REST_Server; @@ -15,7 +15,7 @@ public function setUp() { public function tearDown() { parent::tearDown(); - + remove_filter( 'rest_url', array( $this, 'test_rest_url_for_leading_slash' ), 10, 2 ); /** @var WP_REST_Server $wp_rest_server */ global $wp_rest_server; $wp_rest_server = null; @@ -39,4 +39,10 @@ abstract public function test_prepare_item(); abstract public function test_get_item_schema(); + public function filter_rest_url_for_leading_slash( $url, $path ) { + // Make sure path for rest_url has a leading slash for proper resolution. + $this->assertTrue( 0 === strpos( $path, '/' ) ); + + return $url; + } } diff --git a/tests/class-wp-test-rest-post-type-controller-testcase.php b/tests/class-wp-test-rest-post-type-controller-testcase.php index a1686fd531..0a13d81586 100644 --- a/tests/class-wp-test-rest-post-type-controller-testcase.php +++ b/tests/class-wp-test-rest-post-type-controller-testcase.php @@ -63,6 +63,10 @@ protected function check_post_data( $post, $data, $context, $links ) { $this->assertEquals( is_sticky( $post->ID ), $data['sticky'] ); } + if ( 'post' === $post->post_type && 'edit' === $context ) { + $this->assertEquals( $post->post_password, $data['password'] ); + } + if ( 'page' === $post->post_type ) { $this->assertEquals( get_page_template_slug( $post->ID ), $data['template'] ); } @@ -87,7 +91,9 @@ protected function check_post_data( $post, $data, $context, $links ) { // Check filtered values. if ( post_type_supports( $post->post_type, 'title' ) ) { + add_filter( 'protected_title_format', array( $this, 'protected_title_format' ) ); $this->assertEquals( get_the_title( $post->ID ), $data['title']['rendered'] ); + remove_filter( 'protected_title_format', array( $this, 'protected_title_format' ) ); if ( 'edit' === $context ) { $this->assertEquals( $post->post_title, $data['title']['raw'] ); } else { @@ -99,7 +105,10 @@ protected function check_post_data( $post, $data, $context, $links ) { if ( post_type_supports( $post->post_type, 'editor' ) ) { // TODO: apply content filter for more accurate testing. - $this->assertEquals( wpautop( $post->post_content ), $data['content']['rendered'] ); + if ( ! $post->post_password ) { + $this->assertEquals( wpautop( $post->post_content ), $data['content']['rendered'] ); + } + if ( 'edit' === $context ) { $this->assertEquals( $post->post_content, $data['content']['raw'] ); } else { @@ -114,7 +123,7 @@ protected function check_post_data( $post, $data, $context, $links ) { // TODO: apply excerpt filter for more accurate testing. $this->assertEquals( wpautop( $post->post_excerpt ), $data['excerpt']['rendered'] ); } else { - $this->assertEquals( 'There is no excerpt because this is a protected post.', $data['excerpt']['rendered'] ); + // TODO: better testing for excerpts for password protected posts. } if ( 'edit' === $context ) { $this->assertEquals( $post->post_excerpt, $data['excerpt']['raw'] ); @@ -130,7 +139,6 @@ protected function check_post_data( $post, $data, $context, $links ) { if ( 'edit' === $context ) { $this->assertEquals( $post->guid, $data['guid']['raw'] ); $this->assertEquals( $post->post_status, $data['status'] ); - $this->assertEquals( $post->post_password, $data['password'] ); if ( '0000-00-00 00:00:00' === $post->post_date_gmt ) { $this->assertNull( $data['date_gmt'] ); @@ -156,6 +164,8 @@ protected function check_post_data( $post, $data, $context, $links ) { // test links if ( $links ) { + + $links = test_rest_expand_compact_links( $links ); $post_type = get_post_type_object( $data['type'] ); $this->assertEquals( $links['self'][0]['href'], rest_url( 'wp/v2/' . $post_type->rest_base . '/' . $data['id'] ) ); $this->assertEquals( $links['collection'][0]['href'], rest_url( 'wp/v2/' . $post_type->rest_base ) ); @@ -177,7 +187,7 @@ protected function check_post_data( $post, $data, $context, $links ) { $this->assertEquals( $links['up'][0]['href'], rest_url( 'wp/v2/' . $post_type->rest_base . '/' . $data['parent'] ) ); } - if ( ! in_array( $data['type'], array( 'attachment', 'nav_menu_item', 'revision' ) ) ) { + if ( ! in_array( $data['type'], array( 'attachment', 'nav_menu_item', 'revision' ), true ) ) { $this->assertEquals( $links['https://api.w.org/attachment'][0]['href'], add_query_arg( 'parent', $data['id'], rest_url( 'wp/v2/media' ) ) ); } @@ -212,7 +222,7 @@ protected function check_get_posts_response( $response, $context = 'view' ) { $links = $data['_links']; foreach ( $links as &$links_array ) { foreach ( $links_array as &$link ) { - $attributes = array_diff_key( $link, array( 'href' => 1 ) ); + $attributes = array_diff_key( $link, array( 'href' => 1, 'name' => 1 ) ); $link = array_diff_key( $link, $attributes ); $link['attributes'] = $attributes; } @@ -287,4 +297,16 @@ protected function set_raw_post_data( $args = array() ) { ) ) ); } + /** + * Overwrite the default protected title format. + * + * By default WordPress will show password protected posts with a title of + * "Protected: %s", as the REST API communicates the protected status of a post + * in a machine readable format, we remove the "Protected: " prefix. + * + * @return string + */ + public function protected_title_format() { + return '%s'; + } } diff --git a/tests/test-rest-attachments-controller.php b/tests/test-rest-attachments-controller.php index f490769a16..7b464b0bc9 100644 --- a/tests/test-rest-attachments-controller.php +++ b/tests/test-rest-attachments-controller.php @@ -21,6 +21,16 @@ public function setUp() { 'role' => 'contributor', ) ); + // Add an uploader role to test upload capabilities. + add_role( 'uploader', 'File upload role' ); + $role = get_role( 'uploader' ); + $role->add_cap( 'upload_files' ); + $role->add_cap( 'read' ); + $role->add_cap( 'level_0' ); + $this->uploader_id = $this->factory->user->create( array( + 'role' => 'uploader', + ) ); + $orig_file = dirname( __FILE__ ) . '/data/canola.jpg'; $this->test_file = '/tmp/canola.jpg'; copy( $orig_file, $this->test_file ); @@ -159,9 +169,9 @@ public function test_get_items() { $data = $response->get_data(); $this->assertCount( 2, $data ); $ids = wp_list_pluck( $data, 'id' ); - $this->assertTrue( in_array( $id1, $ids ) ); - $this->assertFalse( in_array( $id2, $ids ) ); - $this->assertTrue( in_array( $id3, $ids ) ); + $this->assertTrue( in_array( $id1, $ids, true ) ); + $this->assertFalse( in_array( $id2, $ids, true ) ); + $this->assertTrue( in_array( $id3, $ids, true ) ); $this->check_get_posts_response( $response ); } @@ -188,9 +198,9 @@ public function test_get_items_logged_in_editor() { $data = $response->get_data(); $this->assertCount( 3, $data ); $ids = wp_list_pluck( $data, 'id' ); - $this->assertTrue( in_array( $id1, $ids ) ); - $this->assertTrue( in_array( $id2, $ids ) ); - $this->assertTrue( in_array( $id3, $ids ) ); + $this->assertTrue( in_array( $id1, $ids, true ) ); + $this->assertTrue( in_array( $id2, $ids, true ) ); + $this->assertTrue( in_array( $id3, $ids, true ) ); } public function test_get_items_media_type() { @@ -450,6 +460,22 @@ public function test_create_item_with_files() { $this->assertEquals( 201, $response->get_status() ); } + public function test_create_item_with_upload_files_role() { + wp_set_current_user( $this->uploader_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_file_params( array( + 'file' => array( + 'file' => file_get_contents( $this->test_file ), + 'name' => 'canola.jpg', + 'size' => filesize( $this->test_file ), + 'tmp_name' => $this->test_file, + ), + ) ); + $request->set_header( 'Content-MD5', md5_file( $this->test_file ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 201, $response->get_status() ); + } + public function test_create_item_empty_body() { wp_set_current_user( $this->author_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); @@ -517,6 +543,15 @@ public function test_create_item_invalid_edit_permissions() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } + public function test_create_item_invalid_upload_permissions() { + $post_id = $this->factory->post->create( array( 'post_author' => $this->editor_id ) ); + wp_set_current_user( $this->uploader_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'post', $post_id ); + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + public function test_create_item_invalid_post_type() { $attachment_id = $this->factory->post->create( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_parent' => 0 ) ); wp_set_current_user( $this->editor_id ); @@ -700,11 +735,11 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'id', $properties ); $this->assertArrayHasKey( 'link', $properties ); $this->assertArrayHasKey( 'media_type', $properties ); + $this->assertArrayHasKey( 'meta', $properties ); $this->assertArrayHasKey( 'mime_type', $properties ); $this->assertArrayHasKey( 'media_details', $properties ); $this->assertArrayHasKey( 'modified', $properties ); $this->assertArrayHasKey( 'modified_gmt', $properties ); - $this->assertArrayHasKey( 'password', $properties ); $this->assertArrayHasKey( 'post', $properties ); $this->assertArrayHasKey( 'ping_status', $properties ); $this->assertArrayHasKey( 'status', $properties ); @@ -749,10 +784,50 @@ public function test_get_additional_field_registration() { $wp_rest_additional_fields = array(); } + public function test_additional_field_update_errors() { + $schema = array( + 'type' => 'integer', + 'description' => 'Some integer of mine', + 'enum' => array( 1, 2, 3, 4 ), + 'context' => array( 'view', 'edit' ), + ); + + register_rest_field( 'attachment', 'my_custom_int', array( + 'schema' => $schema, + 'get_callback' => array( $this, 'additional_field_get_callback' ), + 'update_callback' => array( $this, 'additional_field_update_callback' ), + ) ); + + wp_set_current_user( $this->editor_id ); + $attachment_id = $this->factory->attachment->create_object( $this->test_file, 0, array( + 'post_mime_type' => 'image/jpeg', + 'post_excerpt' => 'A sample caption', + 'post_author' => $this->editor_id, + ) ); + // Check for error on update. + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/media/%d', $attachment_id ) ); + $request->set_body_params(array( + 'my_custom_int' => 'returnError', + )); + + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + + global $wp_rest_additional_fields; + $wp_rest_additional_fields = array(); + } + public function additional_field_get_callback( $object, $request ) { return 123; } + public function additional_field_update_callback( $value, $attachment ) { + if ( 'returnError' === $value ) { + return new WP_Error( 'rest_invalid_param', 'Testing an error.', array( 'status' => 400 ) ); + } + } + public function tearDown() { parent::tearDown(); if ( file_exists( $this->test_file ) ) { diff --git a/tests/test-rest-categories-controller.php b/tests/test-rest-categories-controller.php index 77baecc8f5..e2f93898e3 100644 --- a/tests/test-rest-categories-controller.php +++ b/tests/test-rest-categories-controller.php @@ -198,13 +198,13 @@ public function test_get_items_exclude_query() { $request = new WP_REST_Request( 'GET', '/wp/v2/categories' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); - $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ) ) ); - $this->assertTrue( in_array( $id2, wp_list_pluck( $data, 'id' ) ) ); + $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ), true ) ); + $this->assertTrue( in_array( $id2, wp_list_pluck( $data, 'id' ), true ) ); $request->set_param( 'exclude', array( $id2 ) ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); - $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ) ) ); - $this->assertFalse( in_array( $id2, wp_list_pluck( $data, 'id' ) ) ); + $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ), true ) ); + $this->assertFalse( in_array( $id2, wp_list_pluck( $data, 'id' ), true ) ); } public function test_get_items_orderby_args() { @@ -443,7 +443,7 @@ public function test_get_terms_pagination_headers() { $this->assertCount( 10, $response->get_data() ); $next_link = add_query_arg( array( 'page' => 2, - ), rest_url( '/wp/v2/categories' ) ); + ), rest_url( 'wp/v2/categories' ) ); $this->assertFalse( stripos( $headers['Link'], 'rel="prev"' ) ); $this->assertContains( '<' . $next_link . '>; rel="next"', $headers['Link'] ); // 3rd page @@ -459,11 +459,11 @@ public function test_get_terms_pagination_headers() { $this->assertCount( 10, $response->get_data() ); $prev_link = add_query_arg( array( 'page' => 2, - ), rest_url( '/wp/v2/categories' ) ); + ), rest_url( 'wp/v2/categories' ) ); $this->assertContains( '<' . $prev_link . '>; rel="prev"', $headers['Link'] ); $next_link = add_query_arg( array( 'page' => 4, - ), rest_url( '/wp/v2/categories' ) ); + ), rest_url( 'wp/v2/categories' ) ); $this->assertContains( '<' . $next_link . '>; rel="next"', $headers['Link'] ); // Last page $request = new WP_REST_Request( 'GET', '/wp/v2/categories' ); @@ -475,7 +475,7 @@ public function test_get_terms_pagination_headers() { $this->assertCount( 1, $response->get_data() ); $prev_link = add_query_arg( array( 'page' => 5, - ), rest_url( '/wp/v2/categories' ) ); + ), rest_url( 'wp/v2/categories' ) ); $this->assertContains( '<' . $prev_link . '>; rel="prev"', $headers['Link'] ); $this->assertFalse( stripos( $headers['Link'], 'rel="next"' ) ); // Out of bounds @@ -488,7 +488,7 @@ public function test_get_terms_pagination_headers() { $this->assertCount( 0, $response->get_data() ); $prev_link = add_query_arg( array( 'page' => 6, - ), rest_url( '/wp/v2/categories' ) ); + ), rest_url( 'wp/v2/categories' ) ); $this->assertContains( '<' . $prev_link . '>; rel="prev"', $headers['Link'] ); $this->assertFalse( stripos( $headers['Link'], 'rel="next"' ) ); } @@ -759,7 +759,7 @@ public function test_prepare_taxonomy_term_child() { $this->assertEquals( 1, $data['parent'] ); $links = $response->get_links(); - $this->assertEquals( rest_url( '/wp/v2/categories/1' ), $links['up'][0]['href'] ); + $this->assertEquals( rest_url( 'wp/v2/categories/1' ), $links['up'][0]['href'] ); } public function test_get_item_schema() { @@ -767,11 +767,12 @@ public function test_get_item_schema() { $response = $this->server->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertEquals( 8, count( $properties ) ); + $this->assertEquals( 9, count( $properties ) ); $this->assertArrayHasKey( 'id', $properties ); $this->assertArrayHasKey( 'count', $properties ); $this->assertArrayHasKey( 'description', $properties ); $this->assertArrayHasKey( 'link', $properties ); + $this->assertArrayHasKey( 'meta', $properties ); $this->assertArrayHasKey( 'name', $properties ); $this->assertArrayHasKey( 'parent', $properties ); $this->assertArrayHasKey( 'slug', $properties ); diff --git a/tests/test-rest-comments-controller.php b/tests/test-rest-comments-controller.php index b88d5e86a5..5a8caabfbe 100644 --- a/tests/test-rest-comments-controller.php +++ b/tests/test-rest-comments-controller.php @@ -12,6 +12,9 @@ class WP_Test_REST_Comments_Controller extends WP_Test_REST_Controller_Testcase protected $subscriber_id; protected $post_id; + protected $private_id; + protected $draft_id; + protected $trash_id; protected $approved_id; protected $hold_id; @@ -28,10 +31,24 @@ public function setUp() { 'role' => 'subscriber', )); $this->author_id = $this->factory->user->create( array( - 'role' => 'author', + 'role' => 'author', + 'display_name' => 'Sea Captain', + 'first_name' => 'Horatio', + 'last_name' => 'McCallister', + 'user_email' => 'captain@thefryingdutchman.com', + 'user_url' => 'http://thefryingdutchman.com', )); $this->post_id = $this->factory->post->create(); + $this->private_id = $this->factory->post->create( array( + 'post_status' => 'private', + )); + $this->draft_id = $this->factory->post->create( array( + 'post_status' => 'draft', + )); + $this->trash_id = $this->factory->post->create( array( + 'post_status' => 'trash', + )); $this->approved_id = $this->factory->comment->create( array( 'comment_approved' => 1, @@ -118,6 +135,80 @@ public function test_get_items() { $this->assertCount( 7, $comments ); } + public function test_get_items_without_private_post_permission() { + wp_set_current_user( 0 ); + + $args = array( + 'comment_approved' => 1, + 'comment_post_ID' => $this->private_id, + ); + $private_comment = $this->factory->comment->create( $args ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $collection_data = $response->get_data(); + $this->assertFalse( in_array( $private_comment, wp_list_pluck( $collection_data, 'id' ), true ) ); + } + + public function test_get_items_with_private_post_permission() { + wp_set_current_user( $this->admin_id ); + + $args = array( + 'comment_approved' => 1, + 'comment_post_ID' => $this->private_id, + ); + $private_comment = $this->factory->comment->create( $args ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $collection_data = $response->get_data(); + $this->assertTrue( in_array( $private_comment, wp_list_pluck( $collection_data, 'id' ), true ) ); + } + + public function test_get_items_with_invalid_post() { + wp_set_current_user( 0 ); + + $comment_id = $this->factory->comment->create( array( + 'comment_approved' => 1, + 'comment_post_ID' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER, + )); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $collection_data = $response->get_data(); + $this->assertFalse( in_array( $comment_id, wp_list_pluck( $collection_data, 'id' ), true ) ); + + wp_delete_comment( $comment_id ); + } + + public function test_get_items_with_invalid_post_permission() { + wp_set_current_user( $this->admin_id ); + + $comment_id = $this->factory->comment->create( array( + 'comment_approved' => 1, + 'comment_post_ID' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER, + )); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $collection_data = $response->get_data(); + $this->assertTrue( in_array( $comment_id, wp_list_pluck( $collection_data, 'id' ), true ) ); + + wp_delete_comment( $comment_id ); + } + public function test_get_items_no_permission_for_context() { wp_set_current_user( 0 ); $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); @@ -179,7 +270,8 @@ public function test_get_items_include_query() { $this->factory->comment->create( $args ); $id3 = $this->factory->comment->create( $args ); $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); - // Orderby=>desc + // Order=>asc + $request->set_param( 'order', 'asc' ); $request->set_param( 'include', array( $id3, $id1 ) ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); @@ -204,13 +296,13 @@ public function test_get_items_exclude_query() { $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); - $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ) ) ); - $this->assertTrue( in_array( $id2, wp_list_pluck( $data, 'id' ) ) ); + $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ), true ) ); + $this->assertTrue( in_array( $id2, wp_list_pluck( $data, 'id' ), true ) ); $request->set_param( 'exclude', array( $id2 ) ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); - $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ) ) ); - $this->assertFalse( in_array( $id2, wp_list_pluck( $data, 'id' ) ) ); + $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ), true ) ); + $this->assertFalse( in_array( $id2, wp_list_pluck( $data, 'id' ), true ) ); } public function test_get_items_offset_query() { @@ -236,6 +328,27 @@ public function test_get_items_offset_query() { $this->assertCount( 2, $response->get_data() ); } + public function test_get_items_order_query() { + wp_set_current_user( $this->admin_id ); + $args = array( + 'comment_approved' => 1, + 'comment_post_ID' => $this->post_id, + ); + $this->factory->comment->create( $args ); + $this->factory->comment->create( $args ); + $id3 = $this->factory->comment->create( $args ); + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + // order defaults to 'desc' + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( $id3, $data[0]['id'] ); + // order=>asc + $request->set_param( 'order', 'asc' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( $this->approved_id, $data[0]['id'] ); + } + public function test_get_items_private_post_no_permissions() { wp_set_current_user( 0 ); $post_id = $this->factory->post->create( array( 'post_status' => 'private' ) ); @@ -545,6 +658,19 @@ public function test_get_comment_invalid_context() { } public function test_get_comment_invalid_post_id() { + wp_set_current_user( 0 ); + $comment_id = $this->factory->comment->create( array( + 'comment_approved' => 1, + 'comment_post_ID' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER, + )); + $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $comment_id ); + + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); + } + + public function test_get_comment_invalid_post_id_as_admin() { + wp_set_current_user( $this->admin_id ); $comment_id = $this->factory->comment->create( array( 'comment_approved' => 1, 'comment_post_ID' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER, @@ -573,6 +699,39 @@ public function test_get_comment_not_approved_same_user() { $this->assertEquals( 200, $response->get_status() ); } + public function test_get_comment_with_children_link() { + $comment_id_1 = $this->factory->comment->create( array( + 'comment_approved' => 1, + 'comment_post_ID' => $this->post_id, + 'user_id' => $this->subscriber_id, + ) ); + + $child_comment = $this->factory->comment->create( array( + 'comment_approved' => 1, + 'comment_parent' => $comment_id_1, + 'comment_post_ID' => $this->post_id, + 'user_id' => $this->subscriber_id, + ) ); + + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/comments/%s', $comment_id_1 ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'children', $response->get_links() ); + } + + public function test_get_comment_without_children_link() { + $comment_id_1 = $this->factory->comment->create( array( + 'comment_approved' => 1, + 'comment_post_ID' => $this->post_id, + 'user_id' => $this->subscriber_id, + ) ); + + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/comments/%s', $comment_id_1 ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayNotHasKey( 'children', $response->get_links() ); + } + public function test_create_item() { wp_set_current_user( 0 ); @@ -599,6 +758,106 @@ public function test_create_item() { $this->assertEquals( $this->post_id, $data['post'] ); } + public function test_create_item_using_accepted_content_raw_value() { + wp_set_current_user( 0 ); + + $params = array( + 'post' => $this->post_id, + 'author_name' => 'Reverend Lovejoy', + 'author_email' => 'lovejoy@example.com', + 'author_url' => 'http://timothylovejoy.jr', + 'content' => array( + 'raw' => 'Once something has been approved by the government, it\'s no longer immoral.', + ), + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 201, $response->get_status() ); + + $data = $response->get_data(); + $new_comment = get_comment( $data['id'] ); + $this->assertEquals( $params['content']['raw'], $new_comment->comment_content ); + } + + public function test_create_comment_missing_required_author_name_and_email_per_option_value() { + update_option( 'require_name_email', 1 ); + + $params = array( + 'post' => $this->post_id, + 'content' => 'Now, I don\'t want you to worry class. These tests will have no affect on your grades. They merely determine your future social status and financial success. If any.', + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_author_data_required', $response, 400 ); + + update_option( 'require_name_email', 0 ); + } + + public function test_create_comment_missing_required_author_name_per_option_value() { + update_option( 'require_name_email', 1 ); + + $params = array( + 'post' => $this->post_id, + 'author_email' => 'ekrabappel@springfield-elementary.edu', + 'content' => 'Now, I don\'t want you to worry class. These tests will have no affect on your grades. They merely determine your future social status and financial success. If any.', + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_author_required', $response, 400 ); + + update_option( 'require_name_email', 0 ); + } + + public function test_create_comment_missing_required_author_email_per_option_value() { + update_option( 'require_name_email', 1 ); + + $params = array( + 'post' => $this->post_id, + 'author_name' => 'Edna Krabappel', + 'content' => 'Now, I don\'t want you to worry class. These tests will have no affect on your grades. They merely determine your future social status and financial success. If any.', + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_author_email_required', $response, 400 ); + + update_option( 'require_name_email', 0 ); + } + + public function test_create_item_invalid_blank_content() { + wp_set_current_user( 0 ); + + $params = array( + 'post' => $this->post_id, + 'author_name' => 'Reverend Lovejoy', + 'author_email' => 'lovejoy@example.com', + 'author_url' => 'http://timothylovejoy.jr', + 'content' => '', + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_content_invalid', $response, 400 ); + } + public function test_create_item_invalid_date() { wp_set_current_user( 0 ); @@ -607,7 +866,7 @@ public function test_create_item_invalid_date() { 'author_name' => 'Reverend Lovejoy', 'author_email' => 'lovejoy@example.com', 'author_url' => 'http://timothylovejoy.jr', - 'content' => "It\'s all over\, people! We don\'t have a prayer!", + 'content' => 'It\'s all over\, people! We don\'t have a prayer!', 'date' => rand_str(), ); @@ -619,6 +878,7 @@ public function test_create_item_invalid_date() { $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); } + public function test_create_item_assign_different_user() { $subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber', @@ -643,6 +903,7 @@ public function test_create_item_assign_different_user() { $data = $response->get_data(); $this->assertEquals( $subscriber_id, $data['author'] ); + $this->assertEquals( '127.0.0.1', $data['author_ip'] ); } public function test_create_comment_without_type() { @@ -722,7 +983,7 @@ public function test_create_comment_other_user() { 'author_email' => 'chunkylover53@aol.com', 'author_url' => 'http://compuglobalhypermeganet.com', 'content' => 'Here\’s to alcohol: the cause of, and solution to, all of life\’s problems.', - 'author' => 0, + 'author' => $this->subscriber_id, ); $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); @@ -732,7 +993,10 @@ public function test_create_comment_other_user() { $this->assertEquals( 201, $response->get_status() ); $data = $response->get_data(); - $this->assertEquals( 0, $data['author'] ); + $this->assertEquals( $this->subscriber_id, $data['author'] ); + $this->assertEquals( 'Homer Jay Simpson', $data['author_name'] ); + $this->assertEquals( 'chunkylover53@aol.com', $data['author_email'] ); + $this->assertEquals( 'http://compuglobalhypermeganet.com', $data['author_url'] ); } public function test_create_comment_other_user_without_permission() { @@ -797,7 +1061,7 @@ public function test_create_comment_status_without_permission() { $this->assertErrorResponse( 'rest_comment_invalid_status', $response, 403 ); } - public function test_create_comment_with_status() { + public function test_create_comment_with_status_and_IP() { $post_id = $this->factory->post->create(); wp_set_current_user( $this->admin_id ); @@ -805,6 +1069,7 @@ public function test_create_comment_with_status() { 'post' => $post_id, 'author_name' => 'Comic Book Guy', 'author_email' => 'cbg@androidsdungeon.com', + 'author_ip' => '139.130.4.5', 'author_url' => 'http://androidsdungeon.com', 'content' => 'Worst Comment Ever!', 'status' => 'approved', @@ -819,6 +1084,27 @@ public function test_create_comment_with_status() { $data = $response->get_data(); $this->assertEquals( 'approved', $data['status'] ); + $this->assertEquals( '139.130.4.5', $data['author_ip'] ); + } + + public function test_create_comment_invalid_author_IP() { + wp_set_current_user( $this->admin_id ); + + $params = array( + 'author_name' => 'Comic Book Guy', + 'author_email' => 'cbg@androidsdungeon.com', + 'author_url' => 'http://androidsdungeon.com', + 'author_ip' => '867.5309', + 'content' => 'Worst Comment Ever!', + 'status' => 'approved', + ); + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); } public function test_create_comment_no_post_id() { @@ -839,8 +1125,91 @@ public function test_create_comment_no_post_id() { $this->assertEquals( 201, $response->get_status() ); } + public function test_create_comment_no_post_id_no_permission() { + wp_set_current_user( $this->subscriber_id ); + + $params = array( + 'author_name' => 'Homer Jay Simpson', + 'author_email' => 'chunkylover53@aol.com', + 'author_url' => 'http://compuglobalhypermeganet.com', + 'content' => 'Here\’s to alcohol: the cause of, and solution to, all of life\’s problems.', + 'author' => $this->subscriber_id, + ); + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_invalid_post_id', $response, 403 ); + } + + public function test_create_comment_draft_post() { + wp_set_current_user( $this->subscriber_id ); + + $params = array( + 'post' => $this->draft_id, + 'author_name' => 'Ishmael', + 'author_email' => 'herman-melville@earthlink.net', + 'author_url' => 'https://en.wikipedia.org/wiki/Herman_Melville', + 'content' => 'Call me Ishmael.', + 'author' => $this->subscriber_id, + ); + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_draft_post', $response, 403 ); + } + + public function test_create_comment_trash_post() { + wp_set_current_user( $this->subscriber_id ); + + $params = array( + 'post' => $this->trash_id, + 'author_name' => 'Ishmael', + 'author_email' => 'herman-melville@earthlink.net', + 'author_url' => 'https://en.wikipedia.org/wiki/Herman_Melville', + 'content' => 'Call me Ishmael.', + 'author' => $this->subscriber_id, + ); + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_trash_post', $response, 403 ); + } + + public function test_create_comment_private_post_invalid_permission() { + wp_set_current_user( $this->subscriber_id ); + + $params = array( + 'post' => $this->private_id, + 'author_name' => 'Homer Jay Simpson', + 'author_email' => 'chunkylover53@aol.com', + 'author_url' => 'http://compuglobalhypermeganet.com', + 'content' => 'I\’d be a vegetarian if bacon grew on trees.', + 'author' => $this->subscriber_id, + ); + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_read_post', $response, 403 ); + } + public function test_create_item_duplicate() { - $this->markTestSkipped( 'Needs to be revisited after wp_die handling is added' ); + global $wp_version; + if ( version_compare( $wp_version, '4.7-alpha', '<' ) ) { + return $this->markTestSkipped( 'WordPress version not supported.' ); + } + $this->factory->comment->create( array( 'comment_post_ID' => $this->post_id, @@ -895,9 +1264,51 @@ public function test_create_comment_require_login() { $this->assertEquals( 'rest_comment_login_required', $data['code'] ); } - public function test_create_comment_two_times() { + public function test_create_item_invalid_author() { + wp_set_current_user( $this->admin_id ); - $this->markTestSkipped( 'Needs to be revisited after wp_die handling is added' ); + $params = array( + 'post' => $this->post_id, + 'author' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER, + 'content' => 'It\'s all over\, people! We don\'t have a prayer!', + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_author_invalid', $response, 400 ); + } + + public function test_create_item_pull_author_info() { + wp_set_current_user( $this->admin_id ); + + $author = new WP_User( $this->author_id ); + $params = array( + 'post' => $this->post_id, + 'author' => $this->author_id, + 'content' => 'It\'s all over\, people! We don\'t have a prayer!', + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + + $result = $response->get_data(); + $this->assertSame( $this->author_id, $result['author'] ); + $this->assertSame( 'Sea Captain', $result['author_name'] ); + $this->assertSame( 'captain@thefryingdutchman.com', $result['author_email'] ); + $this->assertSame( 'http://thefryingdutchman.com', $result['author_url'] ); + } + + public function test_create_comment_two_times() { + global $wp_version; + if ( version_compare( $wp_version, '4.7-alpha', '<' ) ) { + return $this->markTestSkipped( 'WordPress version not supported.' ); + } wp_set_current_user( 0 ); @@ -938,11 +1349,12 @@ public function test_update_item() { wp_set_current_user( $this->admin_id ); $params = array( - 'content' => "Disco Stu doesn't advertise.", 'author' => $this->subscriber_id, 'author_name' => 'Disco Stu', 'author_url' => 'http://stusdisco.com', 'author_email' => 'stu@stusdisco.com', + 'author_ip' => '4.4.4.4', + 'content' => 'Testing.', 'date' => '2014-11-07T10:14:25', 'karma' => 100, 'post' => $post_id, @@ -961,6 +1373,7 @@ public function test_update_item() { $this->assertEquals( $params['author_name'], $comment['author_name'] ); $this->assertEquals( $params['author_url'], $comment['author_url'] ); $this->assertEquals( $params['author_email'], $comment['author_email'] ); + $this->assertEquals( $params['author_ip'], $comment['author_ip'] ); $this->assertEquals( $params['post'], $comment['post'] ); $this->assertEquals( $params['karma'], $comment['karma'] ); @@ -1023,6 +1436,7 @@ public function test_update_comment_date_gmt() { $params = array( 'date_gmt' => '2015-05-07T10:14:25', + 'content' => 'I\'ll be deep in the cold, cold ground before I recognize Missouri.', ); $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/comments/%d', $this->approved_id ) ); $request->add_header( 'content-type', 'application/json' ); @@ -1051,6 +1465,27 @@ public function test_update_comment_invalid_type() { $this->assertErrorResponse( 'rest_comment_invalid_type', $response, 404 ); } + public function test_update_comment_with_raw_property() { + wp_set_current_user( $this->admin_id ); + + $params = array( + 'content' => array( + 'raw' => 'What the heck kind of name is Persephone?', + ), + ); + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/comments/%d', $this->approved_id ) ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $comment = $response->get_data(); + $updated = get_comment( $this->approved_id ); + $this->assertEquals( $params['content']['raw'], $updated->comment_content ); + } + public function test_update_item_invalid_date() { wp_set_current_user( $this->admin_id ); @@ -1111,6 +1546,60 @@ public function test_update_comment_invalid_permission() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); } + public function test_update_comment_private_post_invalid_permission() { + $private_comment_id = $this->factory->comment->create( array( + 'comment_approved' => 1, + 'comment_post_ID' => $this->private_id, + 'user_id' => 0, + )); + + wp_set_current_user( $this->subscriber_id ); + + $params = array( + 'content' => 'Disco Stu likes disco music.', + ); + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/comments/%d', $private_comment_id ) ); + $request->add_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + public function test_update_comment_with_children_link() { + wp_set_current_user( $this->admin_id ); + $comment_id_1 = $this->factory->comment->create( array( + 'comment_approved' => 1, + 'comment_post_ID' => $this->post_id, + 'user_id' => $this->subscriber_id, + ) ); + + $child_comment = $this->factory->comment->create( array( + 'comment_approved' => 1, + 'comment_post_ID' => $this->post_id, + 'user_id' => $this->subscriber_id, + ) ); + + // Check if comment 1 does not have the child link. + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/comments/%s', $comment_id_1 ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayNotHasKey( 'children', $response->get_links() ); + + // Change the comment parent. + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/comments/%s', $child_comment ) ); + $request->set_param( 'parent', $comment_id_1 ); + $request->set_param( 'content', rand_str() ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + // Check if comment 1 now has the child link. + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/comments/%s', $comment_id_1 ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'children', $response->get_links() ); + } + public function test_delete_item() { wp_set_current_user( $this->admin_id ); @@ -1178,12 +1667,38 @@ public function test_delete_comment_without_permission() { $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); } + public function test_delete_child_comment_link() { + wp_set_current_user( $this->admin_id ); + $comment_id_1 = $this->factory->comment->create( array( + 'comment_approved' => 1, + 'comment_post_ID' => $this->post_id, + 'user_id' => $this->subscriber_id, + ) ); + + $child_comment = $this->factory->comment->create( array( + 'comment_approved' => 1, + 'comment_parent' => $comment_id_1, + 'comment_post_ID' => $this->post_id, + 'user_id' => $this->subscriber_id, + ) ); + + $request = new WP_REST_Request( 'DELETE', sprintf( '/wp/v2/comments/%s', $child_comment ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + // Verify children link is gone. + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/comments/%s', $comment_id_1 ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayNotHasKey( 'children', $response->get_links() ); + } + public function test_get_item_schema() { $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comments' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertEquals( 17, count( $properties ) ); + $this->assertEquals( 18, count( $properties ) ); $this->assertArrayHasKey( 'id', $properties ); $this->assertArrayHasKey( 'author', $properties ); $this->assertArrayHasKey( 'author_avatar_urls', $properties ); @@ -1197,6 +1712,7 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'date_gmt', $properties ); $this->assertArrayHasKey( 'karma', $properties ); $this->assertArrayHasKey( 'link', $properties ); + $this->assertArrayHasKey( 'meta', $properties ); $this->assertArrayHasKey( 'parent', $properties ); $this->assertArrayHasKey( 'post', $properties ); $this->assertArrayHasKey( 'status', $properties ); @@ -1244,7 +1760,7 @@ public function test_get_additional_field_registration() { $request = new WP_REST_Request( 'POST', '/wp/v2/comments/' . $this->approved_id ); $request->set_body_params(array( 'my_custom_int' => 123, - 'content' => 'abc', + 'content' => 'abc', )); wp_set_current_user( 1 ); @@ -1254,8 +1770,9 @@ public function test_get_additional_field_registration() { $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); $request->set_body_params(array( 'my_custom_int' => 123, - 'title' => 'hello', - 'post' => $this->post_id, + 'title' => 'hello', + 'content' => 'goodbye', + 'post' => $this->post_id, )); $response = $this->server->dispatch( $request ); @@ -1266,11 +1783,45 @@ public function test_get_additional_field_registration() { $wp_rest_additional_fields = array(); } + public function test_additional_field_update_errors() { + $schema = array( + 'type' => 'integer', + 'description' => 'Some integer of mine', + 'enum' => array( 1, 2, 3, 4 ), + 'context' => array( 'view', 'edit' ), + ); + + register_rest_field( 'comment', 'my_custom_int', array( + 'schema' => $schema, + 'get_callback' => array( $this, 'additional_field_get_callback' ), + 'update_callback' => array( $this, 'additional_field_update_callback' ), + ) ); + + wp_set_current_user( $this->admin_id ); + + // Check for error on update. + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/comments/%d', $this->approved_id ) ); + $request->set_body_params(array( + 'my_custom_int' => 'returnError', + 'content' => 'abc', + )); + + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + + global $wp_rest_additional_fields; + $wp_rest_additional_fields = array(); + } + public function additional_field_get_callback( $object ) { return get_comment_meta( $object['id'], 'my_custom_int', true ); } public function additional_field_update_callback( $value, $comment ) { + if ( 'returnError' === $value ) { + return new WP_Error( 'rest_invalid_param', 'Testing an error.', array( 'status' => 400 ) ); + } update_comment_meta( $comment->comment_ID, 'my_custom_int', $value ); } diff --git a/tests/test-rest-controller.php b/tests/test-rest-controller.php index 75910b576c..79fbca7032 100644 --- a/tests/test-rest-controller.php +++ b/tests/test-rest-controller.php @@ -9,6 +9,9 @@ public function setUp() { 'someinteger' => array( 'type' => 'integer', ), + 'someboolean' => array( + 'type' => 'boolean', + ), 'somestring' => array( 'type' => 'string', ), @@ -40,6 +43,72 @@ public function test_validate_schema_type_integer() { ); } + public function test_validate_schema_type_boolean() { + + $this->assertTrue( + rest_validate_request_arg( true, $this->request, 'someboolean' ) + ); + $this->assertTrue( + rest_validate_request_arg( false, $this->request, 'someboolean' ) + ); + + $this->assertTrue( + rest_validate_request_arg( 'true', $this->request, 'someboolean' ) + ); + $this->assertTrue( + rest_validate_request_arg( 'TRUE', $this->request, 'someboolean' ) + ); + $this->assertTrue( + rest_validate_request_arg( 'false', $this->request, 'someboolean' ) + ); + $this->assertTrue( + rest_validate_request_arg( 'False', $this->request, 'someboolean' ) + ); + $this->assertTrue( + rest_validate_request_arg( '1', $this->request, 'someboolean' ) + ); + $this->assertTrue( + rest_validate_request_arg( '0', $this->request, 'someboolean' ) + ); + $this->assertTrue( + rest_validate_request_arg( 1, $this->request, 'someboolean' ) + ); + $this->assertTrue( + rest_validate_request_arg( 0, $this->request, 'someboolean' ) + ); + + // Check sanitize testing. + $this->assertEquals( false, + rest_sanitize_request_arg( 'false', $this->request, 'someboolean' ) + ); + $this->assertEquals( false, + rest_sanitize_request_arg( '0', $this->request, 'someboolean' ) + ); + $this->assertEquals( false, + rest_sanitize_request_arg( 0, $this->request, 'someboolean' ) + ); + $this->assertEquals( false, + rest_sanitize_request_arg( 'FALSE', $this->request, 'someboolean' ) + ); + $this->assertEquals( true, + rest_sanitize_request_arg( 'true', $this->request, 'someboolean' ) + ); + $this->assertEquals( true, + rest_sanitize_request_arg( '1', $this->request, 'someboolean' ) + ); + $this->assertEquals( true, + rest_sanitize_request_arg( 1, $this->request, 'someboolean' ) + ); + $this->assertEquals( true, + rest_sanitize_request_arg( 'TRUE', $this->request, 'someboolean' ) + ); + + $this->assertErrorResponse( + 'rest_invalid_param', + rest_validate_request_arg( '123', $this->request, 'someboolean' ) + ); + } + public function test_validate_schema_type_string() { $this->assertTrue( @@ -112,4 +181,28 @@ public function test_get_endpoint_args_for_item_schema_default_value() { $this->assertEquals( 'a', $args['somedefault']['default'] ); } + + public $rest_the_post_filter_apply_count = 0; + + public function test_get_post() { + $post_id = $this->factory()->post->create( array( 'post_title' => 'Original' ) ); + $controller = new WP_REST_Test_Controller(); + + $post = $controller->get_post( $post_id ); + $this->assertEquals( 'Original', $post->post_title ); + + $filter_apply_count = $this->rest_the_post_filter_apply_count; + add_filter( 'rest_the_post', array( $this, 'filter_rest_the_post_for_test_get_post' ), 10, 2 ); + $post = $controller->get_post( $post_id ); + $this->assertEquals( 'Overridden', $post->post_title ); + $this->assertEquals( 1 + $filter_apply_count, $this->rest_the_post_filter_apply_count ); + } + + public function filter_rest_the_post_for_test_get_post( $post, $post_id ) { + $this->assertInstanceOf( 'WP_Post', $post ); + $this->assertInternalType( 'int', $post_id ); + $post->post_title = 'Overridden'; + $this->rest_the_post_filter_apply_count += 1; + return $post; + } } diff --git a/tests/test-rest-pages-controller.php b/tests/test-rest-pages-controller.php index 985fccf297..1b43b6cf74 100644 --- a/tests/test-rest-pages-controller.php +++ b/tests/test-rest-pages-controller.php @@ -371,10 +371,10 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'id', $properties ); $this->assertArrayHasKey( 'link', $properties ); $this->assertArrayHasKey( 'menu_order', $properties ); + $this->assertArrayHasKey( 'meta', $properties ); $this->assertArrayHasKey( 'modified', $properties ); $this->assertArrayHasKey( 'modified_gmt', $properties ); $this->assertArrayHasKey( 'parent', $properties ); - $this->assertArrayHasKey( 'password', $properties ); $this->assertArrayHasKey( 'ping_status', $properties ); $this->assertArrayHasKey( 'slug', $properties ); $this->assertArrayHasKey( 'status', $properties ); diff --git a/tests/test-rest-post-meta-fields.php b/tests/test-rest-post-meta-fields.php new file mode 100644 index 0000000000..e6103015d6 --- /dev/null +++ b/tests/test-rest-post-meta-fields.php @@ -0,0 +1,633 @@ + true, + 'single' => true, + )); + register_meta( 'post', 'test_multi', array( + 'show_in_rest' => true, + 'single' => false, + )); + register_meta( 'post', 'test_bad_auth', array( + 'show_in_rest' => true, + 'single' => true, + 'auth_callback' => '__return_false', + )); + register_meta( 'post', 'test_bad_auth_multi', array( + 'show_in_rest' => true, + 'single' => false, + 'auth_callback' => '__return_false', + )); + register_meta( 'post', 'test_no_rest', array() ); + register_meta( 'post', 'test_rest_disabled', array( + 'show_in_rest' => false, + )); + register_meta( 'post', 'test_custom_schema', array( + 'single' => true, + 'type' => 'integer', + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'number', + ), + ), + )); + register_meta( 'post', 'test_invalid_type', array( + 'single' => true, + 'type' => false, + 'show_in_rest' => true, + )); + + /** @var WP_REST_Server $wp_rest_server */ + global $wp_rest_server; + $this->server = $wp_rest_server = new WP_Test_Spy_REST_Server; + do_action( 'rest_api_init' ); + + $this->post_id = $this->factory->post->create(); + } + + protected function grant_write_permission() { + // Ensure we have write permission. + $user = $this->factory->user->create( array( + 'role' => 'editor', + )); + wp_set_current_user( $user ); + } + + public function test_get_value() { + add_post_meta( $this->post_id, 'test_single', 'testvalue' ); + + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'meta', $data ); + + $meta = (array) $data['meta']; + $this->assertArrayHasKey( 'test_single', $meta ); + $this->assertEquals( 'testvalue', $meta['test_single'] ); + } + + /** + * @depends test_get_value + */ + public function test_get_multi_value() { + add_post_meta( $this->post_id, 'test_multi', 'value1' ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $meta = (array) $data['meta']; + $this->assertArrayHasKey( 'test_multi', $meta ); + $this->assertInternalType( 'array', $meta['test_multi'] ); + $this->assertContains( 'value1', $meta['test_multi'] ); + + // Check after an update. + add_post_meta( $this->post_id, 'test_multi', 'value2' ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $meta = (array) $data['meta']; + $this->assertContains( 'value1', $meta['test_multi'] ); + $this->assertContains( 'value2', $meta['test_multi'] ); + } + + /** + * @depends test_get_value + */ + public function test_get_unregistered() { + add_post_meta( $this->post_id, 'test_unregistered', 'value1' ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $meta = (array) $data['meta']; + $this->assertArrayNotHasKey( 'test_unregistered', $meta ); + } + + /** + * @depends test_get_value + */ + public function test_get_registered_no_api_access() { + add_post_meta( $this->post_id, 'test_no_rest', 'for_the_wicked' ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $meta = (array) $data['meta']; + $this->assertArrayNotHasKey( 'test_no_rest', $meta ); + } + + /** + * @depends test_get_value + */ + public function test_get_registered_api_disabled() { + add_post_meta( $this->post_id, 'test_rest_disabled', 'sleepless_nights' ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $meta = (array) $data['meta']; + $this->assertArrayNotHasKey( 'test_rest_disabled', $meta ); + } + + public function test_get_value_types() { + register_meta( 'post', 'test_string', array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + )); + register_meta( 'post', 'test_number', array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'number', + )); + register_meta( 'post', 'test_bool', array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'boolean', + )); + + /** @var WP_REST_Server $wp_rest_server */ + global $wp_rest_server; + $this->server = $wp_rest_server = new WP_Test_Spy_REST_Server; + do_action( 'rest_api_init' ); + + add_post_meta( $this->post_id, 'test_string', 42 ); + add_post_meta( $this->post_id, 'test_number', '42' ); + add_post_meta( $this->post_id, 'test_bool', 1 ); + + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $meta = (array) $data['meta']; + + $this->assertArrayHasKey( 'test_string', $meta ); + $this->assertInternalType( 'string', $meta['test_string'] ); + $this->assertSame( '42', $meta['test_string'] ); + + $this->assertArrayHasKey( 'test_number', $meta ); + $this->assertInternalType( 'float', $meta['test_number'] ); + $this->assertSame( 42.0, $meta['test_number'] ); + + $this->assertArrayHasKey( 'test_bool', $meta ); + $this->assertInternalType( 'boolean', $meta['test_bool'] ); + $this->assertSame( true, $meta['test_bool'] ); + } + + /** + * @depends test_get_value + */ + public function test_set_value() { + // Ensure no data exists currently. + $values = get_post_meta( $this->post_id, 'test_single', false ); + $this->assertEmpty( $values ); + + $this->grant_write_permission(); + + $data = array( + 'meta' => array( + 'test_single' => 'test_value', + ), + ); + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $meta = get_post_meta( $this->post_id, 'test_single', false ); + $this->assertNotEmpty( $meta ); + $this->assertCount( 1, $meta ); + $this->assertEquals( 'test_value', $meta[0] ); + + $data = $response->get_data(); + $meta = (array) $data['meta']; + $this->assertArrayHasKey( 'test_single', $meta ); + $this->assertEquals( 'test_value', $meta['test_single'] ); + } + + /** + * @depends test_get_value + */ + public function test_set_duplicate_single_value() { + // Start with an existing metakey and value. + $values = update_post_meta( $this->post_id, 'test_single', 'test_value' ); + $this->assertEquals( 'test_value', get_post_meta( $this->post_id, 'test_single', true ) ); + + $this->grant_write_permission(); + + $data = array( + 'meta' => array( + 'test_single' => 'test_value', + ), + ); + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $meta = get_post_meta( $this->post_id, 'test_single', true ); + $this->assertNotEmpty( $meta ); + $this->assertEquals( 'test_value', $meta ); + + $data = $response->get_data(); + $meta = (array) $data['meta']; + $this->assertArrayHasKey( 'test_single', $meta ); + $this->assertEquals( 'test_value', $meta['test_single'] ); + } + + /** + * @depends test_set_value + */ + public function test_set_value_unauthenticated() { + $data = array( + 'meta' => array( + 'test_single' => 'test_value', + ), + ); + + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); + + // Check that the value wasn't actually updated. + $this->assertEmpty( get_post_meta( $this->post_id, 'test_single', false ) ); + } + + /** + * @depends test_set_value + */ + public function test_set_value_blocked() { + $data = array( + 'meta' => array( + 'test_bad_auth' => 'test_value', + ), + ); + + $this->grant_write_permission(); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_update', $response, 403 ); + $this->assertEmpty( get_post_meta( $this->post_id, 'test_bad_auth', false ) ); + } + + /** + * @depends test_set_value + */ + public function test_set_value_db_error() { + $data = array( + 'meta' => array( + 'test_single' => 'test_value', + ), + ); + + $this->grant_write_permission(); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + + /** + * Disable showing error as the below is going to intentionally + * trigger a DB error. + */ + global $wpdb; + $wpdb->suppress_errors = true; + add_filter( 'query', array( $this, 'error_insert_query' ) ); + + $response = $this->server->dispatch( $request ); + remove_filter( 'query', array( $this, 'error_insert_query' ) ); + $wpdb->show_errors = true; + } + + public function test_set_value_multiple() { + // Ensure no data exists currently. + $values = get_post_meta( $this->post_id, 'test_multi', false ); + $this->assertEmpty( $values ); + + $this->grant_write_permission(); + + $data = array( + 'meta' => array( + 'test_multi' => array( 'val1' ), + ), + ); + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $meta = get_post_meta( $this->post_id, 'test_multi', false ); + $this->assertNotEmpty( $meta ); + $this->assertCount( 1, $meta ); + $this->assertEquals( 'val1', $meta[0] ); + + // Add another value. + $data = array( + 'meta' => array( + 'test_multi' => array( 'val1', 'val2' ), + ), + ); + $request->set_body_params( $data ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $meta = get_post_meta( $this->post_id, 'test_multi', false ); + $this->assertNotEmpty( $meta ); + $this->assertCount( 2, $meta ); + $this->assertContains( 'val1', $meta ); + $this->assertContains( 'val2', $meta ); + } + + /** + * Test removing only one item with duplicate items. + */ + public function test_set_value_remove_one() { + add_post_meta( $this->post_id, 'test_multi', 'c' ); + add_post_meta( $this->post_id, 'test_multi', 'n' ); + add_post_meta( $this->post_id, 'test_multi', 'n' ); + + $this->grant_write_permission(); + + $data = array( + 'meta' => array( + 'test_multi' => array( 'c', 'n' ), + ), + ); + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $meta = get_post_meta( $this->post_id, 'test_multi', false ); + $this->assertNotEmpty( $meta ); + $this->assertCount( 2, $meta ); + $this->assertContains( 'c', $meta ); + $this->assertContains( 'n', $meta ); + } + + /** + * @depends test_set_value_multiple + */ + public function test_set_value_multiple_unauthenticated() { + // Ensure no data exists currently. + $values = get_post_meta( $this->post_id, 'test_multi', false ); + $this->assertEmpty( $values ); + + wp_set_current_user( 0 ); + + $data = array( + 'meta' => array( + 'test_multi' => array( 'val1' ), + ), + ); + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); + + $meta = get_post_meta( $this->post_id, 'test_multi', false ); + $this->assertEmpty( $meta ); + } + + /** + * @depends test_set_value_multiple + */ + public function test_set_value_multiple_blocked() { + $data = array( + 'meta' => array( + 'test_bad_auth_multi' => array( 'test_value' ), + ), + ); + + $this->grant_write_permission(); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_update', $response, 403 ); + $this->assertEmpty( get_post_meta( $this->post_id, 'test_bad_auth_multi', false ) ); + } + + public function test_add_multi_value_db_error() { + // Ensure no data exists currently. + $values = get_post_meta( $this->post_id, 'test_multi', false ); + $this->assertEmpty( $values ); + + $this->grant_write_permission(); + + $data = array( + 'meta' => array( + 'test_multi' => array( 'val1' ), + ), + ); + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + + /** + * Disable showing error as the below is going to intentionally + * trigger a DB error. + */ + global $wpdb; + $wpdb->suppress_errors = true; + add_filter( 'query', array( $this, 'error_insert_query' ) ); + + $response = $this->server->dispatch( $request ); + remove_filter( 'query', array( $this, 'error_insert_query' ) ); + $wpdb->show_errors = true; + + $this->assertErrorResponse( 'rest_meta_database_error', $response, 500 ); + } + + public function test_remove_multi_value_db_error() { + add_post_meta( $this->post_id, 'test_multi', 'val1' ); + $values = get_post_meta( $this->post_id, 'test_multi', false ); + $this->assertEquals( array( 'val1' ), $values ); + + $this->grant_write_permission(); + + $data = array( + 'meta' => array( + 'test_multi' => array(), + ), + ); + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + + /** + * Disable showing error as the below is going to intentionally + * trigger a DB error. + */ + global $wpdb; + $wpdb->suppress_errors = true; + add_filter( 'query', array( $this, 'error_delete_query' ) ); + + $response = $this->server->dispatch( $request ); + remove_filter( 'query', array( $this, 'error_delete_query' ) ); + $wpdb->show_errors = true; + + $this->assertErrorResponse( 'rest_meta_database_error', $response, 500 ); + } + + public function test_delete_value() { + add_post_meta( $this->post_id, 'test_single', 'val1' ); + $current = get_post_meta( $this->post_id, 'test_single', true ); + $this->assertEquals( 'val1', $current ); + + $this->grant_write_permission(); + + $data = array( + 'meta' => array( + 'test_single' => null, + ), + ); + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $meta = get_post_meta( $this->post_id, 'test_single', false ); + $this->assertEmpty( $meta ); + } + + /** + * @depends test_delete_value + */ + public function test_delete_value_blocked() { + add_post_meta( $this->post_id, 'test_bad_auth', 'val1' ); + $current = get_post_meta( $this->post_id, 'test_bad_auth', true ); + $this->assertEquals( 'val1', $current ); + + $this->grant_write_permission(); + + $data = array( + 'meta' => array( + 'test_bad_auth' => null, + ), + ); + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); + + $meta = get_post_meta( $this->post_id, 'test_bad_auth', true ); + $this->assertEquals( 'val1', $meta ); + } + + /** + * @depends test_delete_value + */ + public function test_delete_value_db_error() { + add_post_meta( $this->post_id, 'test_single', 'val1' ); + $current = get_post_meta( $this->post_id, 'test_single', true ); + $this->assertEquals( 'val1', $current ); + + $this->grant_write_permission(); + + $data = array( + 'meta' => array( + 'test_single' => null, + ), + ); + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( $data ); + /** + * Disable showing error as the below is going to intentionally + * trigger a DB error. + */ + global $wpdb; + $wpdb->suppress_errors = true; + add_filter( 'query', array( $this, 'error_delete_query' ) ); + + $response = $this->server->dispatch( $request ); + remove_filter( 'query', array( $this, 'error_delete_query' ) ); + $wpdb->show_errors = true; + + $this->assertErrorResponse( 'rest_meta_database_error', $response, 500 ); + } + + public function test_get_schema() { + $request = new WP_REST_Request( 'OPTIONS', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + $schema = $data['schema']; + + $this->assertArrayHasKey( 'meta', $schema['properties'] ); + $meta_schema = $schema['properties']['meta']['properties']; + + $this->assertArrayHasKey( 'test_single', $meta_schema ); + $this->assertEquals( 'string', $meta_schema['test_single']['type'] ); + + $this->assertArrayHasKey( 'test_multi', $meta_schema ); + $this->assertEquals( 'array', $meta_schema['test_multi']['type'] ); + $this->assertArrayHasKey( 'items', $meta_schema['test_multi'] ); + $this->assertEquals( 'string', $meta_schema['test_multi']['items']['type'] ); + + $this->assertArrayHasKey( 'test_custom_schema', $meta_schema ); + $this->assertEquals( 'number', $meta_schema['test_custom_schema']['type'] ); + + $this->assertArrayNotHasKey( 'test_no_rest', $meta_schema ); + $this->assertArrayNotHasKey( 'test_rest_disabled', $meta_schema ); + $this->assertArrayNotHasKey( 'test_invalid_type', $meta_schema ); + } + + /** + * Internal function used to disable an insert query which + * will trigger a wpdb error for testing purposes. + */ + public function error_insert_query( $query ) { + if ( strpos( $query, 'INSERT' ) === 0 ) { + $query = '],'; + } + return $query; + } + + /** + * Internal function used to disable an insert query which + * will trigger a wpdb error for testing purposes. + */ + public function error_delete_query( $query ) { + if ( strpos( $query, 'DELETE' ) === 0 ) { + $query = '],'; + } + return $query; + } +} diff --git a/tests/test-rest-post-types-controller.php b/tests/test-rest-post-types-controller.php index bbe5e739bf..8cc7b2c2f6 100644 --- a/tests/test-rest-post-types-controller.php +++ b/tests/test-rest-post-types-controller.php @@ -150,6 +150,8 @@ protected function check_post_type_obj( $context, $post_type_obj, $data, $links $this->assertEquals( $post_type_obj->name, $data['slug'] ); $this->assertEquals( $post_type_obj->description, $data['description'] ); $this->assertEquals( $post_type_obj->hierarchical, $data['hierarchical'] ); + + $links = test_rest_expand_compact_links( $links ); $this->assertEquals( rest_url( 'wp/v2/types' ), $links['collection'][0]['href'] ); $this->assertArrayHasKey( 'https://api.w.org/items', $links ); if ( 'edit' === $context ) { diff --git a/tests/test-rest-posts-controller.php b/tests/test-rest-posts-controller.php index f5bf647c1f..099584e1ae 100644 --- a/tests/test-rest-posts-controller.php +++ b/tests/test-rest-posts-controller.php @@ -74,6 +74,7 @@ public function test_registered_query_params() { 'search', 'slug', 'status', + 'sticky', 'tags', ), $keys ); } @@ -179,13 +180,13 @@ public function test_get_items_exclude_query() { $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); - $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ) ) ); - $this->assertTrue( in_array( $id2, wp_list_pluck( $data, 'id' ) ) ); + $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ), true ) ); + $this->assertTrue( in_array( $id2, wp_list_pluck( $data, 'id' ), true ) ); $request->set_param( 'exclude', array( $id2 ) ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); - $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ) ) ); - $this->assertFalse( in_array( $id2, wp_list_pluck( $data, 'id' ) ) ); + $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ), true ) ); + $this->assertFalse( in_array( $id2, wp_list_pluck( $data, 'id' ), true ) ); } public function test_get_items_search_query() { @@ -265,13 +266,23 @@ public function test_get_items_order_and_orderby() { $response = $this->server->dispatch( $request ); $data = $response->get_data(); $this->assertEquals( 'Apple Sauce', $data[0]['title']['rendered'] ); - // order=>desc + // order=>asc $request->set_param( 'order', 'asc' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); $this->assertEquals( 'Apple Cobbler', $data[0]['title']['rendered'] ); } + public function test_get_items_with_orderby_relevance() { + $this->factory->post->create( array( 'post_title' => 'Title is more relevant', 'post_content' => 'Content is', 'post_status' => 'publish' ) ); + $this->factory->post->create( array( 'post_title' => 'Title is', 'post_content' => 'Content is less relevant', 'post_status' => 'publish' ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( 'orderby', 'relevance' ); + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_no_search_term_defined', $response, 400 ); + } + public function test_get_items_ignore_sticky_posts_by_default() { $this->markTestSkipped( 'Broken, see https://github.com/WP-API/WP-API/issues/2210' ); $post_id1 = $this->factory->post->create( array( 'post_status' => 'publish', 'post_date' => '2015-01-01 12:00:00', 'post_date_gmt' => '2015-01-01 12:00:00' ) ); @@ -324,7 +335,28 @@ public function test_get_items_tags_query() { $request->set_param( 'tags', array( $tag['term_id'] ) ); $response = $this->server->dispatch( $request ); - $this->assertCount( 1, $response->get_data() ); + $data = $response->get_data(); + $this->assertCount( 1, $data ); + $this->assertEquals( $id1, $data[0]['id'] ); + } + + public function test_get_items_tags_exclude_query() { + $id1 = $this->post_id; + $id2 = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $id3 = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $id4 = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $tag = wp_insert_term( 'My Tag', 'post_tag' ); + + wp_set_object_terms( $id1, array( $tag['term_id'] ), 'post_tag' ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( 'tags_exclude', array( $tag['term_id'] ) ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertCount( 3, $data ); + $this->assertEquals( $id4, $data[0]['id'] ); + $this->assertEquals( $id3, $data[1]['id'] ); + $this->assertEquals( $id2, $data[2]['id'] ); } public function test_get_items_tags_and_categories_query() { @@ -347,6 +379,110 @@ public function test_get_items_tags_and_categories_query() { $this->assertCount( 1, $response->get_data() ); } + public function test_get_items_tags_and_categories_exclude_query() { + $id1 = $this->post_id; + $id2 = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $id3 = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $id4 = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $tag = wp_insert_term( 'My Tag', 'post_tag' ); + $category = wp_insert_term( 'My Category', 'category' ); + + wp_set_object_terms( $id1, array( $tag['term_id'] ), 'post_tag' ); + wp_set_object_terms( $id2, array( $tag['term_id'] ), 'post_tag' ); + wp_set_object_terms( $id1, array( $category['term_id'] ), 'category' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( 'tags', array( $tag['term_id'] ) ); + $request->set_param( 'categories_exclude', array( $category['term_id'] ) ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertCount( 1, $data ); + $this->assertEquals( $id2, $data[0]['id'] ); + } + + public function test_get_items_sticky_query() { + $id1 = $this->post_id; + $id2 = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + + update_option( 'sticky_posts', array( $id2 ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( 'sticky', true ); + + $response = $this->server->dispatch( $request ); + $this->assertCount( 1, $response->get_data() ); + + $posts = $response->get_data(); + $post = $posts[0]; + $this->assertEquals( $id2, $post['id'] ); + } + + public function test_get_items_sticky_with_post__in_query() { + $id1 = $this->post_id; + $id2 = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $id3 = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + + update_option( 'sticky_posts', array( $id2 ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( 'sticky', true ); + $request->set_param( 'include', array( $id1 ) ); + + $response = $this->server->dispatch( $request ); + $this->assertCount( 0, $response->get_data() ); + + update_option( 'sticky_posts', array( $id1, $id2 ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( 'sticky', true ); + $request->set_param( 'include', array( $id1 ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertCount( 1, $response->get_data() ); + + $posts = $response->get_data(); + $post = $posts[0]; + $this->assertEquals( $id1, $post['id'] ); + } + + public function test_get_items_not_sticky_query() { + $id1 = $this->post_id; + $id2 = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + + update_option( 'sticky_posts', array( $id2 ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( 'sticky', false ); + + $response = $this->server->dispatch( $request ); + $this->assertCount( 1, $response->get_data() ); + + $posts = $response->get_data(); + $post = $posts[0]; + $this->assertEquals( $id1, $post['id'] ); + } + + public function test_get_items_sticky_with_post__not_in_query() { + $id1 = $this->post_id; + $id2 = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $id3 = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + + update_option( 'sticky_posts', array( $id2 ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( 'sticky', false ); + $request->set_param( 'exclude', array( $id3 ) ); + + $response = $this->server->dispatch( $request ); + $this->assertCount( 1, $response->get_data() ); + + $posts = $response->get_data(); + $post = $posts[0]; + $this->assertEquals( $id1, $post['id'] ); + } + /** * @group test */ @@ -410,19 +546,21 @@ public function test_get_items_pagination_headers() { $this->assertContains( '<' . $prev_link . '>; rel="prev"', $headers['Link'] ); $this->assertFalse( stripos( $headers['Link'], 'rel="next"' ) ); - // With filter params. + // With query params. $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); - $request->set_query_params( array( 'filter' => array( 'posts_per_page' => 5, 'paged' => 2 ) ) ); + $request->set_query_params( array( 'per_page' => 5, 'page' => 2 ) ); $response = $this->server->dispatch( $request ); $headers = $response->get_headers(); $this->assertEquals( 51, $headers['X-WP-Total'] ); $this->assertEquals( 11, $headers['X-WP-TotalPages'] ); $prev_link = add_query_arg( array( - 'page' => 1, + 'per_page' => 5, + 'page' => 1, ), rest_url( '/wp/v2/posts' ) ); $this->assertContains( '<' . $prev_link . '>; rel="prev"', $headers['Link'] ); $next_link = add_query_arg( array( - 'page' => 3, + 'per_page' => 5, + 'page' => 3, ), rest_url( '/wp/v2/posts' ) ); $this->assertContains( '<' . $next_link . '>; rel="next"', $headers['Link'] ); } @@ -445,6 +583,25 @@ public function test_get_items_private_filter_query_var() { $this->assertEquals( $draft_id, $data[0]['id'] ); } + public function test_get_items_invalid_per_page() { + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_query_params( array( 'per_page' => -1 ) ); + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + public function test_get_items_invalid_posts_per_page_ignored() { + // This test ensures that filter[posts_per_page] is ignored, and that -1 + // cannot be used to sidestep per_page's valid range to retrieve all posts + for ( $i = 0; $i < 20; $i++ ) { + $this->factory->post->create( array( 'post_status' => 'publish' ) ); + } + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_query_params( array( 'filter' => array( 'posts_per_page' => -1 ) ) ); + $response = $this->server->dispatch( $request ); + $this->assertCount( 10, $response->get_data() ); + } + public function test_get_items_invalid_context() { $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); $request->set_param( 'context', 'banana' ); @@ -509,7 +666,7 @@ public function test_get_item_links() { $tag_link = $link; } elseif ( 'category' === $link['attributes']['taxonomy'] ) { $cat_link = $link; - } else if ( 'post_format' === $link['attributes']['taxonomy'] ) { + } elseif ( 'post_format' === $link['attributes']['taxonomy'] ) { $format_link = $link; } } @@ -595,22 +752,66 @@ public function test_get_post_with_password() { 'post_password' => '$inthebananastand', ) ); - wp_set_current_user( $this->editor_id ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $post_id ) ); + $response = $this->server->dispatch( $request ); + $this->check_get_post_response( $response, 'view' ); + + $data = $response->get_data(); + $this->assertTrue( $data['content']['protected'] ); + $this->assertTrue( $data['excerpt']['protected'] ); + } + + public function test_get_post_with_password_using_password() { + global $wp_version; + if ( version_compare( $wp_version, '4.7-alpha', '<' ) ) { + return $this->markTestSkipped( 'WordPress < 4.6 does not support filtering passwords for posts.' ); + } + + $post_id = $this->factory->post->create( array( + 'post_password' => '$inthebananastand', + 'post_content' => 'Some secret content.', + 'post_excerpt' => 'Some secret excerpt.', + ) ); + + $post = get_post( $post_id ); $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $post_id ) ); + $request->set_param( 'password', '$inthebananastand' ); $response = $this->server->dispatch( $request ); $this->check_get_post_response( $response, 'view' ); + + $data = $response->get_data(); + $this->assertEquals( wpautop( $post->post_content ), $data['content']['rendered'] ); + $this->assertEquals( wpautop( $post->post_excerpt ), $data['excerpt']['rendered'] ); + } + + public function test_get_post_with_password_using_incorrect_password() { + $post_id = $this->factory->post->create( array( + 'post_password' => '$inthebananastand', + ) ); + + $post = get_post( $post_id ); + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $post_id ) ); + $request->set_param( 'password', 'wrongpassword' ); + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_incorrect_password', $response, 403 ); } public function test_get_post_with_password_without_permission() { $post_id = $this->factory->post->create( array( 'post_password' => '$inthebananastand', + 'post_content' => 'Some secret content.', + 'post_excerpt' => 'Some secret excerpt.', ) ); $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $post_id ) ); $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->check_get_post_response( $response, 'view' ); + $this->assertEquals( '', $data['content']['rendered'] ); + $this->assertEquals( '', $data['excerpt']['rendered'] ); - $this->assertErrorResponse( 'rest_forbidden', $response, 403 ); } public function test_get_item_read_permission_custom_post_status() { @@ -930,26 +1131,6 @@ public function test_create_post_with_password() { $this->assertEquals( 'testing', $data['password'] ); } - public function test_create_post_with_password_without_permission() { - wp_set_current_user( $this->author_id ); - $user = wp_get_current_user(); - $user->add_cap( 'publish_posts', false ); - // Flush capabilities, https://core.trac.wordpress.org/ticket/28374 - $user->get_role_caps(); - $user->update_user_level_from_caps(); - - $request = new WP_REST_Request( 'POST', '/wp/v2/posts' ); - $params = $this->set_post_data( array( - 'password' => 'testing', - 'author' => $this->author_id, - 'status' => 'draft', - ) ); - $request->set_body_params( $params ); - $response = $this->server->dispatch( $request ); - - $this->assertErrorResponse( 'rest_cannot_publish', $response, 403 ); - } - public function test_create_post_with_falsy_password() { wp_set_current_user( $this->editor_id ); @@ -1241,7 +1422,7 @@ public function test_update_post_invalid_id() { $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/posts/%d', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) ); $response = $this->server->dispatch( $request ); - $this->assertErrorResponse( 'rest_post_invalid_id', $response, 400 ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); } public function test_update_post_invalid_route() { @@ -1250,7 +1431,7 @@ public function test_update_post_invalid_route() { $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/pages/%d', $this->post_id ) ); $response = $this->server->dispatch( $request ); - $this->assertErrorResponse( 'rest_post_invalid_id', $response, 400 ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); } public function test_update_post_with_format() { @@ -1350,6 +1531,22 @@ public function test_update_post_slug() { $this->assertEquals( 'sample-slug', $post->post_name ); } + public function test_update_post_slug_accented_chars() { + wp_set_current_user( $this->editor_id ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $params = $this->set_post_data( array( + 'slug' => 'tęst-acceńted-chäræcters', + ) ); + $request->set_body_params( $params ); + $response = $this->server->dispatch( $request ); + + $new_data = $response->get_data(); + $this->assertEquals( 'test-accented-charaecters', $new_data['slug'] ); + $post = get_post( $new_data['id'] ); + $this->assertEquals( 'test-accented-charaecters', $post->post_name ); + } + public function test_update_post_sticky() { wp_set_current_user( $this->editor_id ); @@ -1619,7 +1816,7 @@ public function test_get_item_schema() { $response = $this->server->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertEquals( 22, count( $properties ) ); + $this->assertEquals( 25, count( $properties ) ); $this->assertArrayHasKey( 'author', $properties ); $this->assertArrayHasKey( 'comment_status', $properties ); $this->assertArrayHasKey( 'content', $properties ); @@ -1631,6 +1828,7 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'format', $properties ); $this->assertArrayHasKey( 'id', $properties ); $this->assertArrayHasKey( 'link', $properties ); + $this->assertArrayHasKey( 'meta', $properties ); $this->assertArrayHasKey( 'modified', $properties ); $this->assertArrayHasKey( 'modified_gmt', $properties ); $this->assertArrayHasKey( 'password', $properties ); @@ -1641,7 +1839,9 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'title', $properties ); $this->assertArrayHasKey( 'type', $properties ); $this->assertArrayHasKey( 'tags', $properties ); + $this->assertArrayHasKey( 'tags_exclude', $properties ); $this->assertArrayHasKey( 'categories', $properties ); + $this->assertArrayHasKey( 'categories_exclude', $properties ); } public function test_get_additional_field_registration() { @@ -1698,11 +1898,43 @@ public function test_get_additional_field_registration() { $wp_rest_additional_fields = array(); } + public function test_additional_field_update_errors() { + $schema = array( + 'type' => 'integer', + 'description' => 'Some integer of mine', + 'enum' => array( 1, 2, 3, 4 ), + 'context' => array( 'view', 'edit' ), + ); + + register_rest_field( 'post', 'my_custom_int', array( + 'schema' => $schema, + 'get_callback' => array( $this, 'additional_field_get_callback' ), + 'update_callback' => array( $this, 'additional_field_update_callback' ), + ) ); + + wp_set_current_user( $this->editor_id ); + // Check for error on update. + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->post_id ) ); + $request->set_body_params( array( + 'my_custom_int' => 'returnError', + ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + + global $wp_rest_additional_fields; + $wp_rest_additional_fields = array(); + } + public function additional_field_get_callback( $object ) { return get_post_meta( $object['id'], 'my_custom_int', true ); } public function additional_field_update_callback( $value, $post ) { + if ( 'returnError' === $value ) { + return new WP_Error( 'rest_invalid_param', 'Testing an error.', array( 'status' => 400 ) ); + } update_post_meta( $post->ID, 'my_custom_int', $value ); } diff --git a/tests/test-rest-revisions-controller.php b/tests/test-rest-revisions-controller.php index 73b7b60b44..ff453a3cc2 100644 --- a/tests/test-rest-revisions-controller.php +++ b/tests/test-rest-revisions-controller.php @@ -267,16 +267,26 @@ protected function check_get_revision_response( $response, $revision ) { } $this->assertEquals( $revision->post_author, $response['author'] ); - $this->assertEquals( $revision->post_content, $response['content'] ); + + $rendered_content = apply_filters( 'the_content', $revision->post_content ); + $this->assertEquals( $rendered_content, $response['content']['rendered'] ); + $this->assertEquals( mysql_to_rfc3339( $revision->post_date ), $response['date'] ); $this->assertEquals( mysql_to_rfc3339( $revision->post_date_gmt ), $response['date_gmt'] ); - $this->assertEquals( $revision->post_excerpt, $response['excerpt'] ); - $this->assertEquals( $revision->guid, $response['guid'] ); + + $rendered_excerpt = apply_filters( 'the_excerpt', apply_filters( 'get_the_excerpt', $revision->post_excerpt, $revision ) ); + $this->assertEquals( $rendered_excerpt, $response['excerpt']['rendered'] ); + + $rendered_guid = apply_filters( 'get_the_guid', $revision->guid ); + $this->assertEquals( $rendered_guid, $response['guid']['rendered'] ); + $this->assertEquals( $revision->ID, $response['id'] ); $this->assertEquals( mysql_to_rfc3339( $revision->post_modified ), $response['modified'] ); $this->assertEquals( mysql_to_rfc3339( $revision->post_modified_gmt ), $response['modified_gmt'] ); $this->assertEquals( $revision->post_name, $response['slug'] ); - $this->assertEquals( $revision->post_title, $response['title'] ); + + $rendered_title = get_the_title( $revision->ID ); + $this->assertEquals( $rendered_title, $response['title']['rendered'] ); $parent = get_post( $revision->post_parent ); $parent_controller = new WP_REST_Posts_Controller( $parent->post_type ); diff --git a/tests/test-rest-settings-controller.php b/tests/test-rest-settings-controller.php new file mode 100644 index 0000000000..6d4d0a2da0 --- /dev/null +++ b/tests/test-rest-settings-controller.php @@ -0,0 +1,240 @@ +markTestSkipped( 'WordPress version not supported.' ); + } + parent::setUp(); + $this->administrator = $this->factory->user->create( array( + 'role' => 'administrator', + ) ); + $this->endpoint = new WP_REST_Settings_Controller(); + } + + public function test_register_routes() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/wp/v2/settings', $routes ); + } + + public function test_get_items() { + } + + public function test_context_param() { + } + + public function test_get_item_is_not_public() { + $request = new WP_REST_Request( 'GET', '/wp/v2/settings' ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 403, $response->get_status() ); + } + + public function test_get_item() { + wp_set_current_user( $this->administrator ); + $request = new WP_REST_Request( 'GET', '/wp/v2/settings' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( array( + 'title', + 'description', + 'url', + 'email', + 'timezone', + 'date_format', + 'time_format', + 'start_of_week', + 'language', + 'use_smilies', + 'default_category', + 'default_post_format', + 'posts_per_page', + ), array_keys( $data ) ); + } + + public function test_get_item_value_is_cast_to_type() { + wp_set_current_user( $this->administrator ); + update_option( 'posts_per_page', 'invalid_number' ); // this is cast to (int) 1 + $request = new WP_REST_Request( 'GET', '/wp/v2/settings' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 1, $data['posts_per_page'] ); + } + + public function test_get_item_with_custom_setting() { + wp_set_current_user( $this->administrator ); + + register_setting( 'somegroup', 'mycustomsetting', array( + 'show_in_rest' => array( + 'name' => 'mycustomsettinginrest', + 'schema' => array( + 'enum' => array( 'validvalue1', 'validvalue2' ), + 'default' => 'validvalue1', + ), + ), + 'type' => 'string', + ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/settings' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'mycustomsettinginrest', $data ); + $this->assertEquals( 'validvalue1', $data['mycustomsettinginrest'] ); + + update_option( 'mycustomsetting', 'validvalue2' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/settings' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 'validvalue2', $data['mycustomsettinginrest'] ); + + unregister_setting( 'somegroup', 'mycustomsetting' ); + } + + public function get_setting_custom_callback( $result, $name, $args ) { + switch ( $name ) { + case 'mycustomsetting1': + return 'filtered1'; + } + return $result; + } + + public function test_get_item_with_filter() { + wp_set_current_user( $this->administrator ); + + add_filter( 'rest_pre_get_setting', array( $this, 'get_setting_custom_callback' ), 10, 3 ); + + register_setting( 'somegroup', 'mycustomsetting1', array( + 'show_in_rest' => array( + 'name' => 'mycustomsettinginrest1', + ), + 'type' => 'string', + ) ); + + register_setting( 'somegroup', 'mycustomsetting2', array( + 'show_in_rest' => array( + 'name' => 'mycustomsettinginrest2', + ), + 'type' => 'string', + ) ); + + update_option( 'mycustomsetting1', 'unfiltered1' ); + update_option( 'mycustomsetting2', 'unfiltered2' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/settings' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + + $this->assertArrayHasKey( 'mycustomsettinginrest1', $data ); + $this->assertEquals( 'unfiltered1', $data['mycustomsettinginrest1'] ); + + $this->assertArrayHasKey( 'mycustomsettinginrest2', $data ); + $this->assertEquals( 'unfiltered2', $data['mycustomsettinginrest2'] ); + + unregister_setting( 'somegroup', 'mycustomsetting' ); + remove_all_filters( 'rest_pre_get_setting' ); + } + + public function test_create_item() { + } + + public function test_update_item() { + wp_set_current_user( $this->administrator ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/settings' ); + $request->set_param( 'title', 'The new title!' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'The new title!', $data['title'] ); + $this->assertEquals( get_option( 'blogname' ), $data['title'] ); + } + + public function update_setting_custom_callback( $result, $name, $value, $args ) { + if ( 'title' === $name && 'The new title!' === $value ) { + // Do not allow changing the title in this case + return true; + } + + return false; + } + + public function test_update_item_with_filter() { + wp_set_current_user( $this->administrator ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/settings' ); + $request->set_param( 'title', 'The old title!' ); + $request->set_param( 'description', 'The old description!' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'The old title!', $data['title'] ); + $this->assertEquals( 'The old description!', $data['description'] ); + $this->assertEquals( get_option( 'blogname' ), $data['title'] ); + $this->assertEquals( get_option( 'blogdescription' ), $data['description'] ); + + add_filter( 'rest_pre_update_setting', array( $this, 'update_setting_custom_callback' ), 10, 4 ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/settings' ); + $request->set_param( 'title', 'The new title!' ); + $request->set_param( 'description', 'The new description!' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'The old title!', $data['title'] ); + $this->assertEquals( 'The new description!', $data['description'] ); + $this->assertEquals( get_option( 'blogname' ), $data['title'] ); + $this->assertEquals( get_option( 'blogdescription' ), $data['description'] ); + + remove_all_filters( 'rest_pre_update_setting' ); + } + + public function test_update_item_with_invalid_type() { + wp_set_current_user( $this->administrator ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/settings' ); + $request->set_param( 'title', array( 'rendered' => 'This should fail.' ) ); + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Setting an item to "null" will essentially restore it to it's default value. + */ + public function test_update_item_with_null() { + update_option( 'posts_per_page', 9 ); + + wp_set_current_user( $this->administrator ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/settings' ); + $request->set_param( 'posts_per_page', null ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 10, $data['posts_per_page'] ); + } + + public function test_delete_item() { + } + + public function test_prepare_item() { + } + + public function test_get_item_schema() { + } +} diff --git a/tests/test-rest-tags-controller.php b/tests/test-rest-tags-controller.php index 5ae51e2608..1a9bc92945 100644 --- a/tests/test-rest-tags-controller.php +++ b/tests/test-rest-tags-controller.php @@ -116,13 +116,13 @@ public function test_get_items_exclude_query() { $request = new WP_REST_Request( 'GET', '/wp/v2/tags' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); - $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ) ) ); - $this->assertTrue( in_array( $id2, wp_list_pluck( $data, 'id' ) ) ); + $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ), true ) ); + $this->assertTrue( in_array( $id2, wp_list_pluck( $data, 'id' ), true ) ); $request->set_param( 'exclude', array( $id2 ) ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); - $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ) ) ); - $this->assertFalse( in_array( $id2, wp_list_pluck( $data, 'id' ) ) ); + $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ), true ) ); + $this->assertFalse( in_array( $id2, wp_list_pluck( $data, 'id' ), true ) ); } public function test_get_items_offset_query() { @@ -224,6 +224,45 @@ public function test_get_items_post_args() { $this->assertEquals( 'DC', $data[0]['name'] ); } + public function test_get_terms_post_args_paging() { + $post_id = $this->factory->post->create(); + $tag_ids = array(); + + for ( $i = 0; $i < 30; $i++ ) { + $tag_ids[] = $this->factory->tag->create( array( + 'name' => "Tag {$i}", + ) ); + } + wp_set_object_terms( $post_id, $tag_ids, 'post_tag' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/tags' ); + $request->set_param( 'post', $post_id ); + $request->set_param( 'page', 1 ); + $request->set_param( 'per_page', 15 ); + $request->set_param( 'orderby', 'id' ); + $response = $this->server->dispatch( $request ); + $tags = $response->get_data(); + + $i = 0; + foreach ( $tags as $tag ) { + $this->assertEquals( $tag['name'], "Tag {$i}" ); + $i++; + } + + $request = new WP_REST_Request( 'GET', '/wp/v2/tags' ); + $request->set_param( 'post', $post_id ); + $request->set_param( 'page', 2 ); + $request->set_param( 'per_page', 15 ); + $request->set_param( 'orderby', 'id' ); + $response = $this->server->dispatch( $request ); + $tags = $response->get_data(); + + foreach ( $tags as $tag ) { + $this->assertEquals( $tag['name'], "Tag {$i}" ); + $i++; + } + } + public function test_get_items_post_empty() { $post_id = $this->factory->post->create(); @@ -314,7 +353,7 @@ public function test_get_terms_pagination_headers() { $this->assertEquals( 5, $headers['X-WP-TotalPages'] ); $next_link = add_query_arg( array( 'page' => 2, - ), rest_url( '/wp/v2/tags' ) ); + ), rest_url( 'wp/v2/tags' ) ); $this->assertFalse( stripos( $headers['Link'], 'rel="prev"' ) ); $this->assertContains( '<' . $next_link . '>; rel="next"', $headers['Link'] ); // 3rd page @@ -329,11 +368,11 @@ public function test_get_terms_pagination_headers() { $this->assertEquals( 6, $headers['X-WP-TotalPages'] ); $prev_link = add_query_arg( array( 'page' => 2, - ), rest_url( '/wp/v2/tags' ) ); + ), rest_url( 'wp/v2/tags' ) ); $this->assertContains( '<' . $prev_link . '>; rel="prev"', $headers['Link'] ); $next_link = add_query_arg( array( 'page' => 4, - ), rest_url( '/wp/v2/tags' ) ); + ), rest_url( 'wp/v2/tags' ) ); $this->assertContains( '<' . $next_link . '>; rel="next"', $headers['Link'] ); // Last page $request = new WP_REST_Request( 'GET', '/wp/v2/tags' ); @@ -344,7 +383,7 @@ public function test_get_terms_pagination_headers() { $this->assertEquals( 6, $headers['X-WP-TotalPages'] ); $prev_link = add_query_arg( array( 'page' => 5, - ), rest_url( '/wp/v2/tags' ) ); + ), rest_url( 'wp/v2/tags' ) ); $this->assertContains( '<' . $prev_link . '>; rel="prev"', $headers['Link'] ); $this->assertFalse( stripos( $headers['Link'], 'rel="next"' ) ); // Out of bounds @@ -356,7 +395,7 @@ public function test_get_terms_pagination_headers() { $this->assertEquals( 6, $headers['X-WP-TotalPages'] ); $prev_link = add_query_arg( array( 'page' => 6, - ), rest_url( '/wp/v2/tags' ) ); + ), rest_url( 'wp/v2/tags' ) ); $this->assertContains( '<' . $prev_link . '>; rel="prev"', $headers['Link'] ); $this->assertFalse( stripos( $headers['Link'], 'rel="next"' ) ); } @@ -544,11 +583,12 @@ public function test_get_item_schema() { $response = $this->server->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertEquals( 7, count( $properties ) ); + $this->assertEquals( 8, count( $properties ) ); $this->assertArrayHasKey( 'id', $properties ); $this->assertArrayHasKey( 'count', $properties ); $this->assertArrayHasKey( 'description', $properties ); $this->assertArrayHasKey( 'link', $properties ); + $this->assertArrayHasKey( 'meta', $properties ); $this->assertArrayHasKey( 'name', $properties ); $this->assertArrayHasKey( 'slug', $properties ); $this->assertArrayHasKey( 'taxonomy', $properties ); @@ -595,10 +635,46 @@ public function test_get_additional_field_registration() { $wp_rest_additional_fields = array(); } + public function test_additional_field_update_errors() { + $schema = array( + 'type' => 'integer', + 'description' => 'Some integer of mine', + 'enum' => array( 1, 2, 3, 4 ), + 'context' => array( 'view', 'edit' ), + ); + + register_rest_field( 'tag', 'my_custom_int', array( + 'schema' => $schema, + 'get_callback' => array( $this, 'additional_field_get_callback' ), + 'update_callback' => array( $this, 'additional_field_update_callback' ), + ) ); + + wp_set_current_user( $this->administrator ); + $tag_id = $this->factory->tag->create(); + // Check for error on update. + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/tags/%d', $tag_id ) ); + $request->set_body_params( array( + 'my_custom_int' => 'returnError', + ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + + global $wp_rest_additional_fields; + $wp_rest_additional_fields = array(); + } + public function additional_field_get_callback( $object, $request ) { return 123; } + public function additional_field_update_callback( $value, $tag ) { + if ( 'returnError' === $value ) { + return new WP_Error( 'rest_invalid_param', 'Testing an error.', array( 'status' => 400 ) ); + } + } + public function tearDown() { _unregister_taxonomy( 'batman' ); _unregister_taxonomy( 'robin' ); diff --git a/tests/test-rest-taxonomies-controller.php b/tests/test-rest-taxonomies-controller.php index e34fea4e47..9cf9522f36 100644 --- a/tests/test-rest-taxonomies-controller.php +++ b/tests/test-rest-taxonomies-controller.php @@ -46,13 +46,22 @@ public function test_get_items_invalid_permission_for_context() { $this->assertErrorResponse( 'rest_cannot_view', $response, 401 ); } - public function test_get_taxonomies_with_types() { + public function test_get_taxonomies_for_type() { $request = new WP_REST_Request( 'GET', '/wp/v2/taxonomies' ); $request->set_param( 'type', 'post' ); $response = $this->server->dispatch( $request ); $this->check_taxonomies_for_type_response( 'post', $response ); } + public function test_get_taxonomies_for_invalid_type() { + $request = new WP_REST_Request( 'GET', '/wp/v2/taxonomies' ); + $request->set_param( 'type', 'wingding' ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( '{}', json_encode( $data ) ); + } + public function test_get_item() { $request = new WP_REST_Request( 'GET', '/wp/v2/taxonomies/category' ); $response = $this->server->dispatch( $request ); diff --git a/tests/test-rest-users-controller.php b/tests/test-rest-users-controller.php index 3a8ab03c56..0a7f371cbf 100644 --- a/tests/test-rest-users-controller.php +++ b/tests/test-rest-users-controller.php @@ -86,6 +86,38 @@ public function test_get_items() { $this->check_user_data( $userdata, $data, 'view', $data['_links'] ); } + public function test_get_items_with_edit_context() { + wp_set_current_user( $this->user ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request->set_param( 'context', 'edit' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $all_data = $response->get_data(); + $data = $all_data[0]; + $userdata = get_userdata( $data['id'] ); + $this->check_user_data( $userdata, $data, 'edit', $data['_links'] ); + } + + public function test_get_items_with_edit_context_without_permission() { + //test with a user not logged in + $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request->set_param( 'context', 'edit' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + + //test with a user logged in but without sufficient capabilities; capability in question: 'list_users' + wp_set_current_user( $this->editor ); + $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request->set_param( 'context', 'edit' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 403, $response->get_status() ); + } + public function test_get_items_unauthenticated_only_shows_public_users() { $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); $response = $this->server->dispatch( $request ); @@ -127,7 +159,7 @@ public function test_get_items_pagination_headers() { $this->assertEquals( 5, $headers['X-WP-TotalPages'] ); $next_link = add_query_arg( array( 'page' => 2, - ), rest_url( '/wp/v2/users' ) ); + ), rest_url( 'wp/v2/users' ) ); $this->assertFalse( stripos( $headers['Link'], 'rel="prev"' ) ); $this->assertContains( '<' . $next_link . '>; rel="next"', $headers['Link'] ); // 3rd page @@ -142,11 +174,11 @@ public function test_get_items_pagination_headers() { $this->assertEquals( 6, $headers['X-WP-TotalPages'] ); $prev_link = add_query_arg( array( 'page' => 2, - ), rest_url( '/wp/v2/users' ) ); + ), rest_url( 'wp/v2/users' ) ); $this->assertContains( '<' . $prev_link . '>; rel="prev"', $headers['Link'] ); $next_link = add_query_arg( array( 'page' => 4, - ), rest_url( '/wp/v2/users' ) ); + ), rest_url( 'wp/v2/users' ) ); $this->assertContains( '<' . $next_link . '>; rel="next"', $headers['Link'] ); // Last page $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); @@ -157,7 +189,7 @@ public function test_get_items_pagination_headers() { $this->assertEquals( 6, $headers['X-WP-TotalPages'] ); $prev_link = add_query_arg( array( 'page' => 5, - ), rest_url( '/wp/v2/users' ) ); + ), rest_url( 'wp/v2/users' ) ); $this->assertContains( '<' . $prev_link . '>; rel="prev"', $headers['Link'] ); $this->assertFalse( stripos( $headers['Link'], 'rel="next"' ) ); // Out of bounds @@ -169,7 +201,7 @@ public function test_get_items_pagination_headers() { $this->assertEquals( 6, $headers['X-WP-TotalPages'] ); $prev_link = add_query_arg( array( 'page' => 6, - ), rest_url( '/wp/v2/users' ) ); + ), rest_url( 'wp/v2/users' ) ); $this->assertContains( '<' . $prev_link . '>; rel="prev"', $headers['Link'] ); $this->assertFalse( stripos( $headers['Link'], 'rel="next"' ) ); } @@ -201,12 +233,12 @@ public function test_get_items_page() { $prev_link = add_query_arg( array( 'per_page' => 5, 'page' => 1, - ), rest_url( '/wp/v2/users' ) ); + ), rest_url( 'wp/v2/users' ) ); $headers = $response->get_headers(); $this->assertContains( '<' . $prev_link . '>; rel="prev"', $headers['Link'] ); } - public function test_get_items_orderby() { + public function test_get_items_orderby_name() { wp_set_current_user( $this->user ); $low_id = $this->factory->user->create( array( 'display_name' => 'AAAAA' ) ); $mid_id = $this->factory->user->create( array( 'display_name' => 'NNNNN' ) ); @@ -227,6 +259,99 @@ public function test_get_items_orderby() { $this->assertEquals( $low_id, $data[0]['id'] ); } + public function test_get_items_orderby_url() { + wp_set_current_user( $this->user ); + + $low_id = $this->factory->user->create( array( 'user_url' => 'http://a.com' ) ); + $high_id = $this->factory->user->create( array( 'user_url' => 'http://b.com' ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request->set_param( 'orderby', 'url' ); + $request->set_param( 'order', 'desc' ); + $request->set_param( 'per_page', 1 ); + $request->set_param( 'include', array( $low_id, $high_id ) ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( $high_id, $data[0]['id'] ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request->set_param( 'orderby', 'url' ); + $request->set_param( 'order', 'asc' ); + $request->set_param( 'per_page', 1 ); + $request->set_param( 'include', array( $low_id, $high_id ) ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( $low_id, $data[0]['id'] ); + } + + public function test_get_items_orderby_slug() { + wp_set_current_user( $this->user ); + + $high_id = $this->factory->user->create( array( 'user_nicename' => 'blogin' ) ); + $low_id = $this->factory->user->create( array( 'user_nicename' => 'alogin' ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request->set_param( 'orderby', 'slug' ); + $request->set_param( 'order', 'desc' ); + $request->set_param( 'per_page', 1 ); + $request->set_param( 'include', array( $low_id, $high_id ) ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( $high_id, $data[0]['id'] ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request->set_param( 'orderby', 'slug' ); + $request->set_param( 'order', 'asc' ); + $request->set_param( 'per_page', 1 ); + $request->set_param( 'include', array( $low_id, $high_id ) ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( $low_id, $data[0]['id'] ); + } + + public function test_get_items_orderby_email() { + wp_set_current_user( $this->user ); + + $high_id = $this->factory->user->create( array( 'user_email' => 'bemail@gmail.com' ) ); + $low_id = $this->factory->user->create( array( 'user_email' => 'aemail@gmail.com' ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request->set_param( 'orderby', 'email' ); + $request->set_param( 'order', 'desc' ); + $request->set_param( 'per_page', 1 ); + $request->set_param( 'include', array( $low_id, $high_id ) ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( $high_id, $data[0]['id'] ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request->set_param( 'orderby', 'email' ); + $request->set_param( 'order', 'asc' ); + $request->set_param( 'per_page', 1 ); + $request->set_param( 'include', array( $low_id, $high_id ) ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( $low_id, $data[0]['id'] ); + } + + public function test_get_items_orderby_email_unauthenticated() { + $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request->set_param( 'orderby', 'email' ); + $request->set_param( 'order', 'desc' ); + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_forbidden_orderby', $response, 401 ); + } + + public function test_get_items_orderby_registered_date_unauthenticated() { + $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); + $request->set_param( 'orderby', 'registered_date' ); + $request->set_param( 'order', 'desc' ); + $response = $this->server->dispatch( $request ); + $this->assertErrorResponse( 'rest_forbidden_orderby', $response, 401 ); + } + public function test_get_items_offset() { wp_set_current_user( $this->user ); // 2 users created in __construct(), plus default user @@ -278,13 +403,13 @@ public function test_get_items_exclude_query() { $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); - $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ) ) ); - $this->assertTrue( in_array( $id2, wp_list_pluck( $data, 'id' ) ) ); + $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ), true ) ); + $this->assertTrue( in_array( $id2, wp_list_pluck( $data, 'id' ), true ) ); $request->set_param( 'exclude', array( $id2 ) ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); - $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ) ) ); - $this->assertFalse( in_array( $id2, wp_list_pluck( $data, 'id' ) ) ); + $this->assertTrue( in_array( $id1, wp_list_pluck( $data, 'id' ), true ) ); + $this->assertFalse( in_array( $id2, wp_list_pluck( $data, 'id' ), true ) ); } public function test_get_items_search() { @@ -295,7 +420,7 @@ public function test_get_items_search() { $this->assertEquals( 0, count( $response->get_data() ) ); $yolo_id = $this->factory->user->create( array( 'display_name' => 'yololololo' ) ); $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); - $request->set_param( 'search', $yolo_id ); + $request->set_param( 'search', (string) $yolo_id ); $response = $this->server->dispatch( $request ); $this->assertEquals( 1, count( $response->get_data() ) ); // default to wildcard search @@ -414,6 +539,22 @@ public function test_get_user_invalid_id() { $this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 ); } + public function test_get_user_empty_capabilities() { + wp_set_current_user( $this->user ); + $this->allow_user_to_manage_multisite(); + + $lolz = $this->factory->user->create( array( 'display_name' => 'lolz', 'roles' => '' ) ); + delete_user_option( $lolz, 'capabilities' ); + delete_user_option( $lolz, 'user_level' ); + $request = new WP_REST_Request( 'GET', '/wp/v2/users/' . $lolz ); + $request->set_param( 'context', 'edit' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( $data['capabilities'], new stdClass() ); + $this->assertEquals( $data['extra_capabilities'], new stdClass() ); + } + public function test_get_item_without_permission() { wp_set_current_user( $this->editor ); @@ -487,7 +628,7 @@ public function test_get_current_user() { $headers = $response->get_headers(); $this->assertArrayHasKey( 'Location', $headers ); - $this->assertEquals( rest_url( '/wp/v2/users/' . $this->user ), $headers['Location'] ); + $this->assertEquals( rest_url( 'wp/v2/users/' . $this->user ), $headers['Location'] ); } public function test_get_current_user_without_permission() { @@ -857,7 +998,7 @@ public function test_update_user_invalid_id() { $request->set_body_params( $params ); $response = $this->server->dispatch( $request ); - $this->assertErrorResponse( 'rest_user_invalid_id', $response, 400 ); + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 ); } public function test_delete_item() { @@ -913,7 +1054,7 @@ public function test_delete_user_invalid_id() { $request['force'] = true; $response = $this->server->dispatch( $request ); - $this->assertErrorResponse( 'rest_user_invalid_id', $response, 400 ); + $this->assertErrorResponse( 'rest_user_invalid_id', $response, 404 ); } public function test_delete_user_reassign() { @@ -964,7 +1105,7 @@ public function test_get_item_schema() { $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertEquals( 17, count( $properties ) ); + $this->assertEquals( 18, count( $properties ) ); $this->assertArrayHasKey( 'avatar_urls', $properties ); $this->assertArrayHasKey( 'capabilities', $properties ); $this->assertArrayHasKey( 'description', $properties ); @@ -974,6 +1115,7 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'id', $properties ); $this->assertArrayHasKey( 'last_name', $properties ); $this->assertArrayHasKey( 'link', $properties ); + $this->assertArrayHasKey( 'meta', $properties ); $this->assertArrayHasKey( 'name', $properties ); $this->assertArrayHasKey( 'nickname', $properties ); $this->assertArrayHasKey( 'registered_date', $properties ); @@ -1053,11 +1195,48 @@ public function test_get_additional_field_registration() { $wp_rest_additional_fields = array(); } + public function test_additional_field_update_errors() { + $schema = array( + 'type' => 'integer', + 'description' => 'Some integer of mine', + 'enum' => array( 1, 2, 3, 4 ), + 'context' => array( 'view', 'edit' ), + ); + + register_rest_field( 'user', 'my_custom_int', array( + 'schema' => $schema, + 'get_callback' => array( $this, 'additional_field_get_callback' ), + 'update_callback' => array( $this, 'additional_field_update_callback' ), + ) ); + + wp_set_current_user( 1 ); + if ( is_multisite() ) { + $current_user = wp_get_current_user( 1 ); + update_site_option( 'site_admins', array( $current_user->user_login ) ); + } + + // Check for error on update. + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/users/%d', $this->user ) ); + $request->set_body_params( array( + 'my_custom_int' => 'returnError', + ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + + global $wp_rest_additional_fields; + $wp_rest_additional_fields = array(); + } + public function additional_field_get_callback( $object ) { return get_user_meta( $object['id'], 'my_custom_int', true ); } public function additional_field_update_callback( $value, $user ) { + if ( 'returnError' === $value ) { + return new WP_Error( 'rest_invalid_param', 'Testing an error.', array( 'status' => 400 ) ); + } update_user_meta( $user->ID, 'my_custom_int', $value ); } @@ -1079,8 +1258,8 @@ protected function check_user_data( $user, $data, $context, $links ) { $this->assertEquals( $user->last_name, $data['last_name'] ); $this->assertEquals( $user->nickname, $data['nickname'] ); $this->assertEquals( $user->user_email, $data['email'] ); - $this->assertEquals( $user->allcaps, $data['capabilities'] ); - $this->assertEquals( $user->caps, $data['extra_capabilities'] ); + $this->assertEquals( (object) $user->allcaps, $data['capabilities'] ); + $this->assertEquals( (object) $user->caps, $data['extra_capabilities'] ); $this->assertEquals( date( 'c', strtotime( $user->user_registered ) ), $data['registered_date'] ); $this->assertEquals( $user->user_login, $data['username'] ); $this->assertEquals( $user->roles, $data['roles'] ); @@ -1101,7 +1280,7 @@ protected function check_user_data( $user, $data, $context, $links ) { 'self', 'collection', ), array_keys( $links ) ); - + $this->assertArrayNotHasKey( 'password', $data ); } diff --git a/wp-api.js b/wp-api.js old mode 100755 new mode 100644 index 7a0aa8de70..0bb5d2e205 --- a/wp-api.js +++ b/wp-api.js @@ -2,6 +2,9 @@ 'use strict'; + /** + * Initialise the WP_API. + */ function WP_API() { this.models = {}; this.collections = {}; @@ -12,6 +15,11 @@ wp.api = wp.api || new WP_API(); wp.api.versionString = wp.api.versionString || 'wp/v2/'; + // Alias _includes to _.contains, ensuring it is available if lodash is used. + if ( ! _.isFunction( _.includes ) && _.isFunction( _.contains ) ) { + _.includes = _.contains; + } + })( window ); (function( window, undefined ) { @@ -112,7 +120,7 @@ }; /** - * Extract a route part based on negitive index. + * Extract a route part based on negative index. * * @param {string} route The endpoint route. * @param {int} part The number of parts from the end of the route to retrieve. Default 1. @@ -152,49 +160,49 @@ }; /** - * Add defaults to a model from a route's endpoints. + * Add args and options to a model prototype from a route's endpoints. * * @param {array} routeEndpoints Array of route endpoints. * @param {Object} modelInstance An instance of the model (or collection) - * to add the defaults to. + * to add the args to. */ wp.api.utils.decorateFromRoute = function( routeEndpoints, modelInstance ) { /** - * Build the defaults based on route endpoint data. + * Build the args based on route endpoint data. */ _.each( routeEndpoints, function( routeEndpoint ) { - // Add post and edit endpoints as model defaults. - if ( _.contains( routeEndpoint.methods, 'POST' ) || _.contains( routeEndpoint.methods, 'PUT' ) ) { + // Add post and edit endpoints as model args. + if ( _.includes( routeEndpoint.methods, 'POST' ) || _.includes( routeEndpoint.methods, 'PUT' ) ) { - // Add any non empty args, merging them into the defaults object. + // Add any non empty args, merging them into the args object. if ( ! _.isEmpty( routeEndpoint.args ) ) { - // Set as defauls if no defaults yet. - if ( _.isEmpty( modelInstance.defaults ) ) { - modelInstance.defaults = routeEndpoint.args; + // Set as defauls if no args yet. + if ( _.isEmpty( modelInstance.prototype.args ) ) { + modelInstance.prototype.args = routeEndpoint.args; } else { - // We already have defaults, merge these new args in. - modelInstance.defaults = _.union( routeEndpoint.args, modelInstance.defaults ); + // We already have args, merge these new args in. + modelInstance.prototype.args = _.union( routeEndpoint.args, modelInstance.prototype.defaults ); } } } else { // Add GET method as model options. - if ( _.contains( routeEndpoint.methods, 'GET' ) ) { + if ( _.includes( routeEndpoint.methods, 'GET' ) ) { // Add any non empty args, merging them into the defaults object. if ( ! _.isEmpty( routeEndpoint.args ) ) { // Set as defauls if no defaults yet. - if ( _.isEmpty( modelInstance.options ) ) { - modelInstance.options = routeEndpoint.args; + if ( _.isEmpty( modelInstance.prototype.options ) ) { + modelInstance.prototype.options = routeEndpoint.args; } else { // We already have options, merge these new args in. - modelInstance.options = _.union( routeEndpoint.args, modelInstance.options ); + modelInstance.prototype.options = _.union( routeEndpoint.args, modelInstance.prototype.options ); } } @@ -233,51 +241,47 @@ * @type {{toJSON: toJSON, parse: parse}}. */ TimeStampedMixin = { + /** - * Serialize the entity pre-sync. + * Prepare a JavaScript Date for transmitting to the server. + * + * This helper function accepts a field and Date object. It converts the passed Date + * to an ISO string and sets that on the model field. * - * @returns {*}. + * @param {Date} date A JavaScript date object. WordPress expects dates in UTC. + * @param {string} field The date field to set. One of 'date', 'date_gmt', 'date_modified' + * or 'date_modified_gmt'. Optional, defaults to 'date'. */ - toJSON: function() { - var attributes = _.clone( this.attributes ); - - // Serialize Date objects back into 8601 strings. - _.each( parseableDates, function( key ) { - if ( key in attributes ) { + setDate: function( date, field ) { + var theField = field || 'date'; - // Don't convert null values - if ( ! _.isNull( attributes[ key ] ) ) { - attributes[ key ] = attributes[ key ].toISOString(); - } - } - } ); + // Don't alter non parsable date fields. + if ( _.indexOf( parseableDates, theField ) < 0 ) { + return false; + } - return attributes; + this.set( theField, date.toISOString() ); }, /** - * Unserialize the fetched response. + * Get a JavaScript Date from the passed field. + * + * WordPress returns 'date' and 'date_modified' in the timezone of the server as well as + * UTC dates as 'date_gmt' and 'date_modified_gmt'. Draft posts do not include UTC dates. * - * @param {*} response. - * @returns {*}. + * @param {string} field The date field to set. One of 'date', 'date_gmt', 'date_modified' + * or 'date_modified_gmt'. Optional, defaults to 'date'. */ - parse: function( response ) { - var timestamp; + getDate: function( field ) { + var theField = field || 'date', + theISODate = this.get( theField ); - // Parse dates into native Date objects. - _.each( parseableDates, function( key ) { - if ( ! ( key in response ) ) { - return; - } - - // Don't convert null values - if ( ! _.isNull( response[ key ] ) ) { - timestamp = wp.api.utils.parseISO8601( response[ key ] ); - response[ key ] = new Date( timestamp ); - } - }); + // Only get date fields and non null values. + if ( _.indexOf( parseableDates, theField ) < 0 || _.isNull( theISODate ) ) { + return false; + } - return response; + return new Date( wp.api.utils.parseISO8601( theISODate ) ); } }, @@ -298,8 +302,8 @@ deferred = jQuery.Deferred(); embeddeds = parentModel.get( '_embedded' ) || {}; - // Verify that we have a valied author id. - if ( ! _.isNumber( modelId ) ) { + // Verify that we have a valied object id. + if ( ! _.isNumber( modelId ) || 0 === modelId ) { deferred.reject(); return deferred; } @@ -333,12 +337,12 @@ /** * Build a helper to retrieve a collection. * - * @param {string} parentModel The parent model. - * @param {string} collectionName The name to use when constructing the collection. - * @param {string} embedSourcePoint Where to check the embedds object for _embed data. - * @param {string} embedIndex An addiitonal optional index for the _embed data. + * @param {string} parentModel The parent model. + * @param {string} collectionName The name to use when constructing the collection. + * @param {string} embedSourcePoint Where to check the embedds object for _embed data. + * @param {string} embedIndex An addiitonal optional index for the _embed data. * - * @return {Deferred.promise} A promise which resolves to the constructed collection. + * @return {Deferred.promise} A promise which resolves to the constructed collection. */ buildCollectionGetter = function( parentModel, collectionName, embedSourcePoint, embedIndex ) { /** @@ -439,32 +443,112 @@ * Add a helper funtion to handle post Tags. */ TagsMixin = { + + /** + * Get the tags for a post. + * + * @return {Deferred.promise} promise Resolves to an array of tags. + */ getTags: function() { - return buildCollectionGetter( this, 'PostTags', 'https://api.w.org/term', 1 ); + var tagIds = this.get( 'tags' ), + tags = new wp.api.collections.Tags(); + + // Resolve with an empty array if no tags. + if ( _.isEmpty( tagIds ) ) { + return jQuery.Deferred().resolve( [] ); + } + + return tags.fetch( { data: { include: tagIds } } ); + }, + + /** + * Set the tags for a post. + * + * Accepts an array of tag slugs, or a Tags collection. + * + * @param {array|Backbone.Collection} tags The tags to set on the post. + * + */ + setTags: function( tags ) { + var allTags, newTag, + self = this, + newTags = []; + + if ( _.isString( tags ) ) { + return false; + } + + // If this is an array of slugs, build a collection. + if ( _.isArray( tags ) ) { + + // Get all the tags. + allTags = new wp.api.collections.Tags(); + allTags.fetch( { + data: { per_page: 100 }, + success: function( alltags ) { + + // Find the passed tags and set them up. + _.each( tags, function( tag ) { + newTag = new wp.api.models.Tag( alltags.findWhere( { slug: tag } ) ); + + // Tie the new tag to the post. + newTag.set( 'parent_post', self.get( 'id' ) ); + + // Add the new tag to the collection. + newTags.push( newTag ); + } ); + tags = new wp.api.collections.Tags( newTags ); + self.setTagsWithCollection( tags ); + } + } ); + + } else { + this.setTagsWithCollection( tags ); + } + }, + + /** + * Set the tags for a post. + * + * Accepts a Tags collection. + * + * @param {array|Backbone.Collection} tags The tags to set on the post. + * + */ + setTagsWithCollection: function( tags ) { + + // Pluck out the category ids. + this.set( 'tags', tags.pluck( 'id' ) ); + return this.save(); } }, + /** * Add a helper funtion to handle post Categories. */ CategoriesMixin = { /** - * Get a PostCategories model for an model's categories. + * Get a the categories for a post. * - * Uses the embedded data if available, otherwises fetches the - * data from the server. - * - * @return {Deferred.promise} promise Resolves to a wp.api.collections.PostCategories - * collection containing the post categories. + * @return {Deferred.promise} promise Resolves to an array of categories. */ getCategories: function() { - return buildCollectionGetter( this, 'PostCategories', 'https://api.w.org/term', 0 ); + var categoryIds = this.get( 'categories' ), + categories = new wp.api.collections.Categories(); + + // Resolve with an empty array if no categories. + if ( _.isEmpty( categoryIds ) ) { + return jQuery.Deferred().resolve( [] ); + } + + return categories.fetch( { data: { include: categoryIds } } ); }, /** * Set the categories for a post. * - * Accepts an array of category slugs, or a PostCategories collection. + * Accepts an array of category slugs, or a Categories collection. * * @param {array|Backbone.Collection} categories The categories to set on the post. * @@ -474,17 +558,22 @@ self = this, newCategories = []; + if ( _.isString( categories ) ) { + return false; + } + // If this is an array of slugs, build a collection. if ( _.isArray( categories ) ) { // Get all the categories. allCategories = new wp.api.collections.Categories(); allCategories.fetch( { + data: { per_page: 100 }, success: function( allcats ) { // Find the passed categories and set them up. _.each( categories, function( category ) { - newCategory = new wp.api.models.PostCategories( allcats.findWhere( { slug: category } ) ); + newCategory = new wp.api.models.Category( allcats.findWhere( { slug: category } ) ); // Tie the new category to the post. newCategory.set( 'parent_post', self.get( 'id' ) ); @@ -492,7 +581,7 @@ // Add the new category to the collection. newCategories.push( newCategory ); } ); - categories = new wp.api.collections.PostCategories( newCategories ); + categories = new wp.api.collections.Categories( newCategories ); self.setCategoriesWithCollection( categories ); } } ); @@ -506,37 +595,16 @@ /** * Set the categories for a post. * - * Accepts PostCategories collection. + * Accepts Categories collection. * * @param {array|Backbone.Collection} categories The categories to set on the post. * */ setCategoriesWithCollection: function( categories ) { - var removedCategories, addedCategories, categoriesIds, existingCategoriesIds; - - // Get the existing categories. - this.getCategories().done( function( existingCategories ) { - - // Pluck out the category ids. - categoriesIds = categories.pluck( 'id' ); - existingCategoriesIds = existingCategories.pluck( 'id' ); - - // Calculate which categories have been removed or added (leave the rest). - addedCategories = _.difference( categoriesIds, existingCategoriesIds ); - removedCategories = _.difference( existingCategoriesIds, categoriesIds ); - - // Add the added categories. - _.each( addedCategories, function( addedCategory ) { - // Save the new categories on the post with a 'POST' method, not Backbone's default 'PUT'. - existingCategories.create( categories.get( addedCategory ), { type: 'POST' } ); - } ); - - // Remove the removed categories. - _.each( removedCategories, function( removedCategory ) { - existingCategories.get( removedCategory ).destroy(); - } ); - } ); + // Pluck out the category ids. + this.set( 'categories', categories.pluck( 'id' ) ); + return this.save(); } }, @@ -550,22 +618,22 @@ }, /** - * Add a helper function to retrieve the featured image. + * Add a helper function to retrieve the featured media. */ - FeaturedImageMixin = { - getFeaturedImage: function() { - return buildModelGetter( this, this.get( 'featured_image' ), 'Media', 'https://api.w.org/featuredmedia', 'source_url' ); + FeaturedMediaMixin = { + getFeaturedMedia: function() { + return buildModelGetter( this, this.get( 'featured_media' ), 'Media', 'wp:featuredmedia', 'source_url' ); } }; // Exit if we don't have valid model defaults. - if ( _.isUndefined( model.defaults ) ) { + if ( _.isUndefined( model.prototype.args ) ) { return model; } // Go thru the parsable date fields, if our model contains any of them it gets the TimeStampedMixin. _.each( parseableDates, function( theDateKey ) { - if ( ! _.isUndefined( model.defaults[ theDateKey ] ) ) { + if ( ! _.isUndefined( model.prototype.args[ theDateKey ] ) ) { hasDate = true; } } ); @@ -576,17 +644,17 @@ } // Add the AuthorMixin for models that contain an author. - if ( ! _.isUndefined( model.defaults.author ) ) { + if ( ! _.isUndefined( model.prototype.args.author ) ) { model = model.extend( AuthorMixin ); } - // Add the FeaturedImageMixin for models that contain a featured_image. - if ( ! _.isUndefined( model.defaults.featured_image ) ) { - model = model.extend( FeaturedImageMixin ); + // Add the FeaturedMediaMixin for models that contain a featured_media. + if ( ! _.isUndefined( model.prototype.args.featured_media ) ) { + model = model.extend( FeaturedMediaMixin ); } // Add the CategoriesMixin for models that support categories collections. - if ( ! _.isUndefined( loadingObjects.collections[ modelClassName + 'Categories' ] ) ) { + if ( ! _.isUndefined( model.prototype.args.categories ) ) { model = model.extend( CategoriesMixin ); } @@ -596,7 +664,7 @@ } // Add the TagsMixin for models that support tags collections. - if ( ! _.isUndefined( loadingObjects.collections[ modelClassName + 'Tags' ] ) ) { + if ( ! _.isUndefined( model.prototype.args.tags ) ) { model = model.extend( TagsMixin ); } @@ -614,10 +682,12 @@ // Suppress warning about parse function's unused "options" argument: /* jshint unused:false */ -(function( wp, wpApiSettings, Backbone, window, undefined ) { +(function() { 'use strict'; + var wpApiSettings = window.wpApiSettings || {}; + /** * Backbone base model for all models. */ @@ -637,6 +707,16 @@ options = options || {}; + // Remove date_gmt if null. + if ( _.isNull( model.get( 'date_gmt' ) ) ) { + model.unset( 'date_gmt' ); + } + + // Remove slug if empty. + if ( _.isEmpty( model.get( 'slug' ) ) ) { + model.unset( 'slug' ); + } + if ( ! _.isUndefined( wpApiSettings.nonce ) && ! _.isNull( wpApiSettings.nonce ) ) { beforeSend = options.beforeSend; @@ -652,7 +732,7 @@ }; } - // Add '?force=true' to delete method when required. + // Add '?force=true' to use delete method when required. if ( this.requireForceForDelete && 'delete' === method ) { model.url = model.url() + '?force=true'; } @@ -665,7 +745,7 @@ save: function( attrs, options ) { // Do we have the put method, then execute the save. - if ( _.contains( this.methods, 'PUT' ) || _.contains( this.methods, 'POST' ) ) { + if ( _.includes( this.methods, 'PUT' ) || _.includes( this.methods, 'POST' ) ) { // Proxy the call to the original save function. return Backbone.Model.prototype.save.call( this, attrs, options ); @@ -682,7 +762,7 @@ destroy: function( options ) { // Do we have the DELETE method, then execute the destroy. - if ( _.contains( this.methods, 'DELETE' ) ) { + if ( _.includes( this.methods, 'DELETE' ) ) { // Proxy the call to the original save function. return Backbone.Model.prototype.destroy.call( this, options ); @@ -723,13 +803,14 @@ } } ); -})( wp, wpApiSettings, Backbone, window ); +})(); -/* global wpApiSettings:false */ -(function( wp, wpApiSettings, Backbone, _, window, undefined ) { +( function() { 'use strict'; + var wpApiSettings = window.wpApiSettings || {}; + /** * Contains basic collection functionality such as pagination. */ @@ -755,7 +836,7 @@ }, /** - * Overwrite Backbone.Collection.sync to pagination state based on response headers. + * Extend Backbone.Collection.sync to add nince and pagination support. * * Set nonce header before every Backbone sync. * @@ -771,6 +852,7 @@ options = options || {}; beforeSend = options.beforeSend; + // If we have a localized nonce, pass that along with each sync. if ( 'undefined' !== typeof wpApiSettings.nonce ) { options.beforeSend = function( xhr ) { xhr.setRequestHeader( 'X-WP-Nonce', wpApiSettings.nonce ); @@ -781,6 +863,7 @@ }; } + // When reading, add pagination data. if ( 'read' === method ) { if ( options.data ) { self.state.data = _.clone( options.data ); @@ -791,8 +874,8 @@ } if ( 'undefined' === typeof options.data.page ) { - self.state.currentPage = null; - self.state.totalPages = null; + self.state.currentPage = null; + self.state.totalPages = null; self.state.totalObjects = null; } else { self.state.currentPage = options.data.page - 1; @@ -800,8 +883,10 @@ success = options.success; options.success = function( data, textStatus, request ) { - self.state.totalPages = parseInt( request.getResponseHeader( 'x-wp-totalpages' ), 10 ); - self.state.totalObjects = parseInt( request.getResponseHeader( 'x-wp-total' ), 10 ); + if ( ! _.isUndefined( request ) ) { + self.state.totalPages = parseInt( request.getResponseHeader( 'x-wp-totalpages' ), 10 ); + self.state.totalObjects = parseInt( request.getResponseHeader( 'x-wp-total' ), 10 ); + } if ( null === self.state.currentPage ) { self.state.currentPage = 1; @@ -815,6 +900,7 @@ }; } + // Continue by calling Bacckbone's sync. return Backbone.sync( method, model, options ); }, @@ -862,19 +948,23 @@ } ); -})( wp, wpApiSettings, Backbone, _, window ); +} )(); -/* global wpApiSettings */ -(function( window, undefined ) { +( function() { 'use strict'; - var Endpoint, initializedDeferreds = {}; - + var Endpoint, initializedDeferreds = {}, + wpApiSettings = window.wpApiSettings || {}; window.wp = window.wp || {}; - wp.api = wp.api || {}; + wp.api = wp.api || {}; + + // If wpApiSettings is unavailable, try the default. + if ( _.isEmpty( wpApiSettings ) ) { + wpApiSettings.root = window.location.origin + '/wp-json/'; + } - Endpoint = Backbone.Model.extend({ + Endpoint = Backbone.Model.extend( { defaults: { apiRoot: wpApiSettings.root, versionString: wp.api.versionString, @@ -883,6 +973,9 @@ collections: {} }, + /** + * Initialize the Endpoint model. + */ initialize: function() { var model = this, deferred; @@ -894,8 +987,9 @@ model.schemaModel = new wp.api.models.Schema( null, { apiRoot: model.get( 'apiRoot' ), versionString: model.get( 'versionString' ) - }); + } ); + // When the model loads, resolve the promise. model.schemaModel.once( 'change', function() { model.constructFromSchema(); deferred.resolve( model ); @@ -905,29 +999,39 @@ // Use schema supplied as model attribute. model.schemaModel.set( model.schemaModel.parse( model.get( 'schema' ) ) ); - } else if ( ! _.isUndefined( sessionStorage ) && sessionStorage.getItem( 'wp-api-schema-model' + model.get( 'apiRoot' ) + model.get( 'versionString' ) ) ) { + } else if ( + ! _.isUndefined( sessionStorage ) && + ( _.isUndefined( wpApiSettings.cacheSchema ) || wpApiSettings.cacheSchema ) && + sessionStorage.getItem( 'wp-api-schema-model' + model.get( 'apiRoot' ) + model.get( 'versionString' ) ) + ) { // Used a cached copy of the schema model if available. model.schemaModel.set( model.schemaModel.parse( JSON.parse( sessionStorage.getItem( 'wp-api-schema-model' + model.get( 'apiRoot' ) + model.get( 'versionString' ) ) ) ) ); } else { - model.schemaModel.fetch({ + model.schemaModel.fetch( { /** - * When the server return the schema model data, store the data in a sessionCache so we don't + * When the server returns the schema model data, store the data in a sessionCache so we don't * have to retrieve it again for this session. Then, construct the models and collections based * on the schema model data. */ success: function( newSchemaModel ) { // Store a copy of the schema model in the session cache if available. - if ( ! _.isUndefined( sessionStorage ) ) { - sessionStorage.setItem( 'wp-api-schema-model' + model.get( 'apiRoot' ) + model.get( 'versionString' ), JSON.stringify( newSchemaModel ) ); + if ( ! _.isUndefined( sessionStorage ) && wpApiSettings.cacheSchema ) { + try { + sessionStorage.setItem( 'wp-api-schema-model' + model.get( 'apiRoot' ) + model.get( 'versionString' ), JSON.stringify( newSchemaModel ) ); + } catch ( error ) { + + // Fail silently, fixes errors in safari private mode. + } } }, - // @todo Handle the error condition. - error: function() { + // Log the error condition. + error: function( err ) { + window.console.log( err ); } - }); + } ); } }, @@ -973,10 +1077,10 @@ * Iterate thru the routes, picking up models and collections to build. Builds two arrays, * one for models and one for collections. */ - modelRoutes = []; - collectionRoutes = []; - schemaRoot = routeModel.get( 'apiRoot' ).replace( wp.api.utils.getRootUrl(), '' ); - loadingObjects = {}; + modelRoutes = []; + collectionRoutes = []; + schemaRoot = routeModel.get( 'apiRoot' ).replace( wp.api.utils.getRootUrl(), '' ); + loadingObjects = {}; /** * Tracking objects for models and collections. @@ -991,19 +1095,14 @@ index !== schemaRoot && index !== ( '/' + routeModel.get( 'versionString' ).slice( 0, -1 ) ) ) { - /** - * Single item models end with a regex/variable. - * - * @todo make model/collection logic more robust. - */ - if ( index.endsWith( '+)' ) ) { + + // Single items end with a regex (or the special case 'me'). + if ( /(?:.*[+)]|\/me)$/.test( index ) ) { modelRoutes.push( { index: index, route: route } ); } else { // Collections end in a name. - if ( ! index.endsWith( 'me' ) ) { - collectionRoutes.push( { index: index, route: route } ); - } + collectionRoutes.push( { index: index, route: route } ); } } } ); @@ -1018,7 +1117,13 @@ // Extract the name and any parent from the route. var modelClassName, routeName = wp.api.utils.extractRoutePart( modelRoute.index, 2 ), - parentName = wp.api.utils.extractRoutePart( modelRoute.index, 4 ); + parentName = wp.api.utils.extractRoutePart( modelRoute.index, 4 ), + routeEnd = wp.api.utils.extractRoutePart( modelRoute.index, 1 ); + + // Handle the special case of the 'me' route. + if ( 'me' === routeEnd ) { + routeName = 'me'; + } // If the model has a parent in its route, add that to its class name. if ( '' !== parentName && parentName !== routeName ) { @@ -1026,7 +1131,7 @@ modelClassName = mapping.models[ modelClassName ] || modelClassName; loadingObjects.models[ modelClassName ] = wp.api.WPApiBaseModel.extend( { - // Function that returns a constructed url based on the parent and id. + // Return a constructed url based on the parent and id. url: function() { var url = routeModel.get( 'apiRoot' ) + routeModel.get( 'versionString' ) + parentName + '/' + @@ -1050,6 +1155,7 @@ methods: modelRoute.route.methods, initialize: function() { + /** * Posts and pages support trashing, other types don't support a trash * and require that you pass ?force=true to actually delete them. @@ -1059,7 +1165,7 @@ if ( 'Posts' !== this.name && 'Pages' !== this.name && - _.contains( this.methods, 'DELETE' ) + _.includes( this.methods, 'DELETE' ) ) { this.requireForceForDelete = true; } @@ -1074,7 +1180,10 @@ // Function that returns a constructed url based on the id. url: function() { - var url = routeModel.get( 'apiRoot' ) + routeModel.get( 'versionString' ) + routeName; + var url = routeModel.get( 'apiRoot' ) + + routeModel.get( 'versionString' ) + + ( ( 'me' === routeName ) ? 'users/me' : routeName ); + if ( ! _.isUndefined( this.get( 'id' ) ) ) { url += '/' + this.get( 'id' ); } @@ -1092,7 +1201,7 @@ } ); } - // Add defaults to the new model, pulled form the endpoint + // Add defaults to the new model, pulled form the endpoint. wp.api.utils.decorateFromRoute( modelRoute.route.endpoints, loadingObjects.models[ modelClassName ] ); } ); @@ -1109,7 +1218,7 @@ routeName = collectionRoute.index.slice( collectionRoute.index.lastIndexOf( '/' ) + 1 ), parentName = wp.api.utils.extractRoutePart( collectionRoute.index, 3 ); - // If the collection has a parent in its route, add that to its class name/ + // If the collection has a parent in its route, add that to its class name. if ( '' !== parentName && parentName !== routeName ) { collectionClassName = wp.api.utils.capitalize( parentName ) + wp.api.utils.capitalize( routeName ); @@ -1161,7 +1270,7 @@ } ); } - // Add defaults to the new model, pulled form the endpoint + // Add defaults to the new model, pulled form the endpoint. wp.api.utils.decorateFromRoute( collectionRoute.route.endpoints, loadingObjects.collections[ collectionClassName ] ); } ); @@ -1172,11 +1281,11 @@ } - }); + } ); - wp.api.endpoints = new Backbone.Collection({ + wp.api.endpoints = new Backbone.Collection( { model: Endpoint - }); + } ); /** * Initialize the wp-api, optionally passing the API root. @@ -1189,10 +1298,10 @@ wp.api.init = function( args ) { var endpoint, attributes = {}, deferred, promise; - args = args || {}; - attributes.apiRoot = args.apiRoot || wpApiSettings.root; + args = args || {}; + attributes.apiRoot = args.apiRoot || wpApiSettings.root; attributes.versionString = args.versionString || wpApiSettings.versionString; - attributes.schema = args.schema || null; + attributes.schema = args.schema || null; if ( ! attributes.schema && attributes.apiRoot === wpApiSettings.root && attributes.versionString === wpApiSettings.versionString ) { attributes.schema = wpApiSettings.schema; } @@ -1223,6 +1332,6 @@ */ // The wp.api.init function returns a promise that will resolve with the endpoint once it is ready. - wp.api.init(); + wp.api.loadPromise = wp.api.init(); -})( window ); +} )(); diff --git a/wp-api.min.js b/wp-api.min.js index 6f95da773b..68e17f52a3 100644 --- a/wp-api.min.js +++ b/wp-api.min.js @@ -1,2 +1,2 @@ -!function(a,b){"use strict";function c(){this.models={},this.collections={},this.views={}}a.wp=a.wp||{},wp.api=wp.api||new c,wp.api.versionString=wp.api.versionString||"wp/v2/"}(window),function(a,b){"use strict";var c,d;a.wp=a.wp||{},wp.api=wp.api||{},wp.api.utils=wp.api.utils||{},Date.prototype.toISOString||(c=function(a){return d=String(a),1===d.length&&(d="0"+d),d},Date.prototype.toISOString=function(){return this.getUTCFullYear()+"-"+c(this.getUTCMonth()+1)+"-"+c(this.getUTCDate())+"T"+c(this.getUTCHours())+":"+c(this.getUTCMinutes())+":"+c(this.getUTCSeconds())+"."+String((this.getUTCMilliseconds()/1e3).toFixed(3)).slice(2,5)+"Z"}),wp.api.utils.parseISO8601=function(a){var c,d,e,f,g=0,h=[1,4,5,6,7,10,11];if(d=/^(\d{4}|[+\-]\d{6})(?:-(\d{2})(?:-(\d{2}))?)?(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{3}))?)?(?:(Z)|([+\-])(\d{2})(?::(\d{2}))?)?)?$/.exec(a)){for(e=0;f=h[e];++e)d[f]=+d[f]||0;d[2]=(+d[2]||1)-1,d[3]=+d[3]||1,"Z"!==d[8]&&b!==d[9]&&(g=60*d[10]+d[11],"+"===d[9]&&(g=0-g)),c=Date.UTC(d[1],d[2],d[3],d[4],d[5]+g,d[6],d[7])}else c=Date.parse?Date.parse(a):NaN;return c},wp.api.utils.getRootUrl=function(){return a.location.origin?a.location.origin+"/":a.location.protocol+"/"+a.location.host+"/"},wp.api.utils.capitalize=function(a){return _.isUndefined(a)?a:a.charAt(0).toUpperCase()+a.slice(1)},wp.api.utils.extractRoutePart=function(a,b){var c;return b=b||1,a=a.replace(wp.api.versionString,""),c=a.split("/").reverse(),_.isUndefined(c[--b])?"":c[b]},wp.api.utils.extractParentName=function(a){var b,c=a.lastIndexOf("_id>[\\d]+)/");return 0>c?"":(b=a.substr(0,c-1),b=b.split("/"),b.pop(),b=b.pop())},wp.api.utils.decorateFromRoute=function(a,b){_.each(a,function(a){_.contains(a.methods,"POST")||_.contains(a.methods,"PUT")?_.isEmpty(a.args)||(_.isEmpty(b.defaults)?b.defaults=a.args:b.defaults=_.union(a.args,b.defaults)):_.contains(a.methods,"GET")&&(_.isEmpty(a.args)||(_.isEmpty(b.options)?b.options=a.args:b.options=_.union(a.args,b.options)))})},wp.api.utils.addMixinsAndHelpers=function(a,b,c){var d=!1,e=["date","modified","date_gmt","modified_gmt"],f={toJSON:function(){var a=_.clone(this.attributes);return _.each(e,function(b){b in a&&(_.isNull(a[b])||(a[b]=a[b].toISOString()))}),a},parse:function(a){var b;return _.each(e,function(c){c in a&&(_.isNull(a[c])||(b=wp.api.utils.parseISO8601(a[c]),a[c]=new Date(b)))}),a}},g=function(a,b,c,d,e){var f,g,h,i;return i=jQuery.Deferred(),g=a.get("_embedded")||{},_.isNumber(b)?(g[d]&&(h=_.findWhere(g[d],{id:b})),h||(h={id:b}),f=new wp.api.models[c](h),f.get(e)?i.resolve(f):f.fetch({success:function(a){i.resolve(a)}}),i.promise()):(i.reject(),i)},h=function(a,b,c,d){var e,f,g,h="",j="",k=jQuery.Deferred();return e=a.get("id"),f=a.get("_embedded")||{},_.isNumber(e)&&0!==e?(_.isUndefined(c)||_.isUndefined(f[c])?h={parent:e}:j=_.isUndefined(d)?f[c]:f[c][d],g=new wp.api.collections[b](j,h),_.isUndefined(g.models[0])?g.fetch({success:function(a){i(a,e),k.resolve(a)}}):(i(g,e),k.resolve(g)),k.promise()):(k.reject(),k)},i=function(a,b){_.each(a.models,function(a){a.set("parent_post",b)})},j={getMeta:function(){return h(this,"PostMeta","https://api.w.org/meta")}},k={getRevisions:function(){return h(this,"PostRevisions")}},l={getTags:function(){return h(this,"PostTags","https://api.w.org/term",1)}},m={getCategories:function(){return h(this,"PostCategories","https://api.w.org/term",0)},setCategories:function(a){var b,c,d=this,e=[];_.isArray(a)?(b=new wp.api.collections.Categories,b.fetch({success:function(b){_.each(a,function(a){c=new wp.api.models.PostCategories(b.findWhere({slug:a})),c.set("parent_post",d.get("id")),e.push(c)}),a=new wp.api.collections.PostCategories(e),d.setCategoriesWithCollection(a)}})):this.setCategoriesWithCollection(a)},setCategoriesWithCollection:function(a){var b,c,d,e;this.getCategories().done(function(f){d=a.pluck("id"),e=f.pluck("id"),c=_.difference(d,e),b=_.difference(e,d),_.each(c,function(b){f.create(a.get(b),{type:"POST"})}),_.each(b,function(a){f.get(a).destroy()})})}},n={getAuthorUser:function(){return g(this,this.get("author"),"User","author","name")}},o={getFeaturedImage:function(){return g(this,this.get("featured_image"),"Media","https://api.w.org/featuredmedia","source_url")}};return _.isUndefined(a.defaults)?a:(_.each(e,function(b){_.isUndefined(a.defaults[b])||(d=!0)}),d&&(a=a.extend(f)),_.isUndefined(a.defaults.author)||(a=a.extend(n)),_.isUndefined(a.defaults.featured_image)||(a=a.extend(o)),_.isUndefined(c.collections[b+"Categories"])||(a=a.extend(m)),_.isUndefined(c.collections[b+"Meta"])||(a=a.extend(j)),_.isUndefined(c.collections[b+"Tags"])||(a=a.extend(l)),_.isUndefined(c.collections[b+"Revisions"])||(a=a.extend(k)),a)}}(window),function(a,b,c,d,e){"use strict";a.api.WPApiBaseModel=c.Model.extend({sync:function(a,d,e){var f;return e=e||{},_.isUndefined(b.nonce)||_.isNull(b.nonce)||(f=e.beforeSend,e.beforeSend=function(a){return a.setRequestHeader("X-WP-Nonce",b.nonce),f?f.apply(this,arguments):void 0}),this.requireForceForDelete&&"delete"===a&&(d.url=d.url()+"?force=true"),c.sync(a,d,e)},save:function(a,b){return _.contains(this.methods,"PUT")||_.contains(this.methods,"POST")?c.Model.prototype.save.call(this,a,b):!1},destroy:function(a){return _.contains(this.methods,"DELETE")?c.Model.prototype.destroy.call(this,a):!1}}),a.api.models.Schema=a.api.WPApiBaseModel.extend({defaults:{_links:{},namespace:null,routes:{}},initialize:function(c,d){var e=this;d=d||{},a.api.WPApiBaseModel.prototype.initialize.call(e,c,d),e.apiRoot=d.apiRoot||b.root,e.versionString=d.versionString||b.versionString},url:function(){return this.apiRoot+this.versionString}})}(wp,wpApiSettings,Backbone,window),function(a,b,c,d,e,f){"use strict";a.api.WPApiBaseCollection=c.Collection.extend({initialize:function(a,b){this.state={data:{},currentPage:null,totalPages:null,totalObjects:null},d.isUndefined(b)?this.parent="":this.parent=b.parent},sync:function(a,e,f){var g,h,i=this;return f=f||{},g=f.beforeSend,"undefined"!=typeof b.nonce&&(f.beforeSend=function(a){return a.setRequestHeader("X-WP-Nonce",b.nonce),g?g.apply(i,arguments):void 0}),"read"===a&&(f.data?(i.state.data=d.clone(f.data),delete i.state.data.page):i.state.data=f.data={},"undefined"==typeof f.data.page?(i.state.currentPage=null,i.state.totalPages=null,i.state.totalObjects=null):i.state.currentPage=f.data.page-1,h=f.success,f.success=function(a,b,c){return i.state.totalPages=parseInt(c.getResponseHeader("x-wp-totalpages"),10),i.state.totalObjects=parseInt(c.getResponseHeader("x-wp-total"),10),null===i.state.currentPage?i.state.currentPage=1:i.state.currentPage++,h?h.apply(this,arguments):void 0}),c.sync(a,e,f)},more:function(a){if(a=a||{},a.data=a.data||{},d.extend(a.data,this.state.data),"undefined"==typeof a.data.page){if(!this.hasMore())return!1;null===this.state.currentPage||this.state.currentPage<=1?a.data.page=2:a.data.page=this.state.currentPage+1}return this.fetch(a)},hasMore:function(){return null===this.state.totalPages||null===this.state.totalObjects||null===this.state.currentPage?null:this.state.currentPage[\\d]+)/");return 0>c?"":(b=a.substr(0,c-1),b=b.split("/"),b.pop(),b=b.pop())},wp.api.utils.decorateFromRoute=function(a,b){_.each(a,function(a){_.includes(a.methods,"POST")||_.includes(a.methods,"PUT")?_.isEmpty(a.args)||(_.isEmpty(b.prototype.args)?b.prototype.args=a.args:b.prototype.args=_.union(a.args,b.prototype.defaults)):_.includes(a.methods,"GET")&&(_.isEmpty(a.args)||(_.isEmpty(b.prototype.options)?b.prototype.options=a.args:b.prototype.options=_.union(a.args,b.prototype.options)))})},wp.api.utils.addMixinsAndHelpers=function(a,b,c){var d=!1,e=["date","modified","date_gmt","modified_gmt"],f={setDate:function(a,b){var c=b||"date";return _.indexOf(e,c)<0?!1:void this.set(c,a.toISOString())},getDate:function(a){var b=a||"date",c=this.get(b);return _.indexOf(e,b)<0||_.isNull(c)?!1:new Date(wp.api.utils.parseISO8601(c))}},g=function(a,b,c,d,e){var f,g,h,i;return i=jQuery.Deferred(),g=a.get("_embedded")||{},_.isNumber(b)&&0!==b?(g[d]&&(h=_.findWhere(g[d],{id:b})),h||(h={id:b}),f=new wp.api.models[c](h),f.get(e)?i.resolve(f):f.fetch({success:function(a){i.resolve(a)}}),i.promise()):(i.reject(),i)},h=function(a,b,c,d){var e,f,g,h="",j="",k=jQuery.Deferred();return e=a.get("id"),f=a.get("_embedded")||{},_.isNumber(e)&&0!==e?(_.isUndefined(c)||_.isUndefined(f[c])?h={parent:e}:j=_.isUndefined(d)?f[c]:f[c][d],g=new wp.api.collections[b](j,h),_.isUndefined(g.models[0])?g.fetch({success:function(a){i(a,e),k.resolve(a)}}):(i(g,e),k.resolve(g)),k.promise()):(k.reject(),k)},i=function(a,b){_.each(a.models,function(a){a.set("parent_post",b)})},j={getMeta:function(){return h(this,"PostMeta","https://api.w.org/meta")}},k={getRevisions:function(){return h(this,"PostRevisions")}},l={getTags:function(){var a=this.get("tags"),b=new wp.api.collections.Tags;return _.isEmpty(a)?jQuery.Deferred().resolve([]):b.fetch({data:{include:a}})},setTags:function(a){var b,c,d=this,e=[];return _.isString(a)?!1:void(_.isArray(a)?(b=new wp.api.collections.Tags,b.fetch({data:{per_page:100},success:function(b){_.each(a,function(a){c=new wp.api.models.Tag(b.findWhere({slug:a})),c.set("parent_post",d.get("id")),e.push(c)}),a=new wp.api.collections.Tags(e),d.setTagsWithCollection(a)}})):this.setTagsWithCollection(a))},setTagsWithCollection:function(a){return this.set("tags",a.pluck("id")),this.save()}},m={getCategories:function(){var a=this.get("categories"),b=new wp.api.collections.Categories;return _.isEmpty(a)?jQuery.Deferred().resolve([]):b.fetch({data:{include:a}})},setCategories:function(a){var b,c,d=this,e=[];return _.isString(a)?!1:void(_.isArray(a)?(b=new wp.api.collections.Categories,b.fetch({data:{per_page:100},success:function(b){_.each(a,function(a){c=new wp.api.models.Category(b.findWhere({slug:a})),c.set("parent_post",d.get("id")),e.push(c)}),a=new wp.api.collections.Categories(e),d.setCategoriesWithCollection(a)}})):this.setCategoriesWithCollection(a))},setCategoriesWithCollection:function(a){return this.set("categories",a.pluck("id")),this.save()}},n={getAuthorUser:function(){return g(this,this.get("author"),"User","author","name")}},o={getFeaturedMedia:function(){return g(this,this.get("featured_media"),"Media","wp:featuredmedia","source_url")}};return _.isUndefined(a.prototype.args)?a:(_.each(e,function(b){_.isUndefined(a.prototype.args[b])||(d=!0)}),d&&(a=a.extend(f)),_.isUndefined(a.prototype.args.author)||(a=a.extend(n)),_.isUndefined(a.prototype.args.featured_media)||(a=a.extend(o)),_.isUndefined(a.prototype.args.categories)||(a=a.extend(m)),_.isUndefined(c.collections[b+"Meta"])||(a=a.extend(j)),_.isUndefined(a.prototype.args.tags)||(a=a.extend(l)),_.isUndefined(c.collections[b+"Revisions"])||(a=a.extend(k)),a)}}(window),function(){"use strict";var a=window.wpApiSettings||{};wp.api.WPApiBaseModel=Backbone.Model.extend({sync:function(b,c,d){var e;return d=d||{},_.isNull(c.get("date_gmt"))&&c.unset("date_gmt"),_.isEmpty(c.get("slug"))&&c.unset("slug"),_.isUndefined(a.nonce)||_.isNull(a.nonce)||(e=d.beforeSend,d.beforeSend=function(b){return b.setRequestHeader("X-WP-Nonce",a.nonce),e?e.apply(this,arguments):void 0}),this.requireForceForDelete&&"delete"===b&&(c.url=c.url()+"?force=true"),Backbone.sync(b,c,d)},save:function(a,b){return _.includes(this.methods,"PUT")||_.includes(this.methods,"POST")?Backbone.Model.prototype.save.call(this,a,b):!1},destroy:function(a){return _.includes(this.methods,"DELETE")?Backbone.Model.prototype.destroy.call(this,a):!1}}),wp.api.models.Schema=wp.api.WPApiBaseModel.extend({defaults:{_links:{},namespace:null,routes:{}},initialize:function(b,c){var d=this;c=c||{},wp.api.WPApiBaseModel.prototype.initialize.call(d,b,c),d.apiRoot=c.apiRoot||a.root,d.versionString=c.versionString||a.versionString},url:function(){return this.apiRoot+this.versionString}})}(),function(){"use strict";var a=window.wpApiSettings||{};wp.api.WPApiBaseCollection=Backbone.Collection.extend({initialize:function(a,b){this.state={data:{},currentPage:null,totalPages:null,totalObjects:null},_.isUndefined(b)?this.parent="":this.parent=b.parent},sync:function(b,c,d){var e,f,g=this;return d=d||{},e=d.beforeSend,"undefined"!=typeof a.nonce&&(d.beforeSend=function(b){return b.setRequestHeader("X-WP-Nonce",a.nonce),e?e.apply(g,arguments):void 0}),"read"===b&&(d.data?(g.state.data=_.clone(d.data),delete g.state.data.page):g.state.data=d.data={},"undefined"==typeof d.data.page?(g.state.currentPage=null,g.state.totalPages=null,g.state.totalObjects=null):g.state.currentPage=d.data.page-1,f=d.success,d.success=function(a,b,c){return _.isUndefined(c)||(g.state.totalPages=parseInt(c.getResponseHeader("x-wp-totalpages"),10),g.state.totalObjects=parseInt(c.getResponseHeader("x-wp-total"),10)),null===g.state.currentPage?g.state.currentPage=1:g.state.currentPage++,f?f.apply(this,arguments):void 0}),Backbone.sync(b,c,d)},more:function(a){if(a=a||{},a.data=a.data||{},_.extend(a.data,this.state.data),"undefined"==typeof a.data.page){if(!this.hasMore())return!1;null===this.state.currentPage||this.state.currentPage<=1?a.data.page=2:a.data.page=this.state.currentPage+1}return this.fetch(a)},hasMore:function(){return null===this.state.totalPages||null===this.state.totalObjects||null===this.state.currentPage?null:this.state.currentPage