diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..e43b0f9889 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000000..d8019385db --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,40 @@ +# Scrutinizer Configuration File + +tools: + 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: { } \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 05aff999e5..6ef1b0d48b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,23 +4,18 @@ language: php php: - - "5.2" - - "5.3" - - "5.4" - - "5.5" + - 5.2 + - 5.3 + - 5.4 + - 5.5 + +env: + - WP_VERSION=latest WP_MULTISITE=0 + - WP_VERSION=latest WP_MULTISITE=1 + - WP_VERSION=nightly WP_MULTISITE=0 # Clones WordPress and configures our testing environment. before_script: - - export PLUGIN_SLUG=$(basename $(pwd)) - - git clone --depth=1 git://develop.git.wordpress.org/ /tmp/wordpress - - mkdir "/tmp/wordpress/src/wp-content/plugins/$PLUGIN_SLUG" - - cp -r . "/tmp/wordpress/src/wp-content/plugins/$PLUGIN_SLUG/" - - cd /tmp/wordpress - - mysql -e "CREATE DATABASE wordpress_tests;" -uroot - - cp wp-tests-config-sample.php wp-tests-config.php - - sed -i "s/youremptytestdbnamehere/wordpress_tests/" wp-tests-config.php - - sed -i "s/yourusernamehere/travis/" wp-tests-config.php - - sed -i "s/yourpasswordhere//" wp-tests-config.php - - cd "/tmp/wordpress/src/wp-content/plugins/$PLUGIN_SLUG" + - bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION script: phpunit diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a99d1df3..58192c810f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,1100 @@ # Changelog +## 1.2.5 + +- Ensure media of private posts are private too. + + Reported by @danielbachhuber on 2016-01-08. + +## 1.2.4 + +- Compatibilty with WordPress 4.4 + + Because WordPress 4.4 also registers rewrite rules for /wp-json/, WP-API v1 needs to register its rewrite rules with higher priority to continue to function as expected. + + (props @danielbachhuber) + +## 1.2.3 + +- Fix potential XSS vulnerability. + + Requests from other origins could potentially run code on the API domain, allowing cross-origin access to authentication cookies or similar. + + Reported by @xknown on 2015-07-23. + +## 1.2.2 + +- Fix user access security vulnerability. + + Authenticated users were able to escalate their privileges bypassing the + expected capabilities check. + + Reported by @kacperszurek on 2015-05-16. + +## 1.2.1 + +- Fix information disclosure security vulnerability. + + Unauthenticated users could access revisions of published and unpublished posts. Revisions are now only accessible to authenticated users with permission to edit the revision's post. + + Reported by @chredd on 2015-04-09. + +## 1.2.0 + +- Add handling for Cross-Origin Resource Sharing (CORS) OPTIONS requests. + + Preflighted requests (using the OPTIONS method) include the headers + `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, and + `Access-Control-Allow-Credentials` in the response, if the HTTP origin is + set. + + (props @rmccue, [#281][gh-281]) + +- Allow overriding full requests. + + The `json_pre_dispatch` filter allows a request to be hijacked before it is + dispatched. Hijacked requests can be anything a normal endpoint can return. + + (props @rmccue, [#281][gh-281]) + +- Check for JSON encoding/decoding errors. + + Returns the last error (if any) occurred during the last JSON encoding or + decoding operation. + + (props @joshkadis, @rmccue, [#461][gh-461]) + +- Add filtering to the terms collection endpoint. + + Available filter arguments are based on the `get_terms()` function. Example: + `/taxonomies/category/terms?filter[number]=10` would limit the response to 10 + category terms. + + (props @mauteri, [#401][gh-401], [#347][gh-347]) + +- Add handling for the `role` parameter when creating or updating a user. + + Allow users to be created or updated with a provided `role`. + + (props @pippinsplugins, [#392][gh-392], [#335][gh-335]) + +- Add handling for the `post_id` parameter when creating media. + + Allow passing the `post_id` parameter to associate a new media item with + a post. + + (props @pkevan, [#294][gh-294]) + +- Handle route matching for `-` in taxonomy and terms. + + Previously the regular expression used to match taxonomy and term names did + not support names with dashes. + + (props @EdHurtig, @evansobkowicz, [#410][gh-410]) + +- Handle JSONP callback matching for `.` in the function name. + + Previously the regular expression used to match JSONP callback functions did + not support names with periods. + + (props @codonnell822, [#455][gh-455]) + +- Fix the Content-Type header for JSONP requests. + + Previously JSONP requests sent the incorrect `application/json` Content-Type + header with the response. This would result in an error if strict MIME + checking was enabled. The Content-Type header was corrected to + `application/javascript` for JSONP responses. + + (props @simonlampen, [#380][gh-380]) + +- Add `$context` parameter to `json_prepare_term` filter. + + Terms responses can now be modified based on the `context` parameter of the + request. + + (props @traversal, [#316][gh-316]) + +- Move the JavaScript client library into the plugin. + + Previously, the `wp-api.js` file was a separate repository. The JavaScript + client has moved back into the plugin to coordinate code changes. + + (props @tlovett1, [#730][gh-730]) + +- Always return an object for media sizes + + The media sizes value should always be an object even when empty. Previously, + if a media item did not have any sizes set, an empty array was returned. + + **Compatibility warning**: Clients should be prepared to accept an empty + object as a value for media sizes. + + (props @maxcutler, [#300][gh-300]) + +- Give top-level posts a `null` parent value. + + For date type consistency, post parent property should be `null`. Previously, + parent-less posts returned `0` for parent. + + **Compatibility warning**: Clients should be prepared to accept `null` as a + value for post parent. + + (props @maxcutler, [#391][gh-391]) + +- Move permission checks out of `WP_JSON_Posts`. + + Introduce `json_check_post_permission()` function to allow post object + capability checks to be used outside the `WP_JSON_Posts` class. + + **Deprecation warning:** Calling `WP_JSON_Posts::check_read_permission` and + `WP_JSON_Posts::check_edit_permission` is now deprecated. + + (props @rachelbaker, [#486][gh-486], [#378][gh-378]) + +- Split comment endpoints into separate class. + + All comment handling has moved to the `WP_JSON_Comments` class. + + **Deprecation warning:** Calling `WP_JSON_Posts::get_comments`, + `WP_JSON_Posts::get_comment`, `WP_JSON_Posts::delete_comment`, and + `WP_JSON_Posts::prepare_comment` is now deprecated. + + (props @whyisjake, @rmccue, @rachelbaker, [#378][gh-378]) + +- Split meta endpoints into separate class. + + All post meta handling has moved to the new `WP_JSON_Meta_Posts` class. + + **Deprecation warning:** Calling `WP_JSON_Posts::get_all_meta`, + `WP_JSON_Posts::get_meta`, `WP_JSON_Posts::update_meta`, + `WP_JSON_Posts::add_meta`, `WP_JSON_Posts::delete_meta`, + `WP_JSON_Posts::prepare_meta`, and `WP_JSON_Posts::is_valid_meta_data` is + now deprecated. + + (props @rmccue, @rachelbaker, [#358][gh-358], [#474][gh-474]) + +- Rename internal create methods. + + **Deprecation warning:** Calling `WP_JSON_Posts::new_post`, + `WP_JSON_CustomPostType::new_post` and `WP_JSON_Posts::new_post` + is now deprecated. + + (props @rachelbaker, @rmccue, [#374][gh-374], [#377][gh-377], [#376][gh-376]) + +- Fix discrepancies in edit and create posts documentation examples. + + Corrected the edit and create posts code examples in the Getting Started + section. The new post example was updated to include the required + `content_raw` parameter. The new and edit posts examples were updated to use + a correct date parameter. + + (props @rachelbaker, [#305][gh-305]) + +- Update the cookie authentication documentation examples. + + With 1.1 the localized JavaScript object for `wp-api.js` changed to + `WP_API_Settings`. This updates the Authentication section documentation + nonce example to use the updated object name. + + (props @rachelbaker, [#321][gh-321]) + +- Add flexibility and multisite support to unit tests. + + Tests can be run from any WordPress install, and are not limited to only as + a plugin installed within a WordPress.org develop checkout. Unit tests are + now run against a multisite installation. + + (props @danielbachhuber, [#397][gh-397]) + +- Add `taxonomy` slug to the term response. + + (props @kalenjohnson, [#481][gh-481]) + +- Fix error when getting child comment. + + Previously an error occurred when a requested comment had a parent. + + (props @EdHurtig, [#413][gh-413], [#411][gh-411]) + +- Parse query strings before returning a JSON decode error. + + (props @jtsternberg, [#499][gh-499]) + +- Typecast the user ID parameter to be an integer for the `/users/{ID}` route. + + (props @dimadin, [#333][gh-333]) + +- Confirm a given JSONP callback is a string. + + (props @ircrash, @rmccue, [#405][gh-405]) + +- Register the JavaScript client in the admin. + + (props @tlovett1, [#473][gh-473]) + +- Remove duplicate error checks on post ids. + + (props @danielbachhuber, [#271][gh-271]) + +- Update documentation link references to wp-api.org. + + (props @pollyplummer, [#320][gh-320]) + +- Update documentation to note routes needing authentication. + + (props @kellbot, [#402][gh-402], [#309][gh-309]) + +- Correct Post route documentation filter parameters. + + (props @modemlooper, @rachelbaker, @rmccue, [#357][gh-357], [#462][gh-462]) + +- Update taxonomy route documentation with correct paths. + + (props @davidbhayes, [#364][gh-364], [#355][gh-355]) + +- Remove references to legacy `$fields` parameter. + + (props @JDGrimes, [#326][gh-326]) + +- Alter readme installation steps to use wp-cli for plugin and permalink setup. + + (props @kadamwhite, [#390][gh-390]) + +- Add steps to readme for executing tests with `vagrant ssh -c`. + + (props @kadamwhite, [#416][gh-416]) + +- Update readme to include provision step for testing suite. + + (props @ironpaperweight, [#396][gh-396]) + +- Update readme Getting Started link. + + (props @NikV, [#519][gh-519]) + +- Update readme Chassis repository links. + + (props @Japh, [#505][gh-505]) + +- Clean-up of `docs` folder. + + (props @pollyplummer, [#441][gh-441]) + +- Documentation audit for plugin.php file. + + (props @DrewAPicture, [#293][gh-293]) + +- Rename tests to match class file naming. + + (props @danielbachhuber, @rmccue, [#359][gh-359]) + +- Add license.txt file with license terms. + + (props @rachelbaker, [#393][gh-393], [#384][gh-384]) + +- Fix test_root when using WordPress.org developer checkout. + + (props @markoheijnen, [#437][gh-437]) + +[View all changes](https://github.com/rmccue/WP-API/compare/1.1.1...1.2) + +[gh-271]: https://github.com/WP-API/WP-API/issues/271 +[gh-281]: https://github.com/WP-API/WP-API/issues/281 +[gh-293]: https://github.com/WP-API/WP-API/issues/293 +[gh-294]: https://github.com/WP-API/WP-API/issues/294 +[gh-300]: https://github.com/WP-API/WP-API/issues/300 +[gh-305]: https://github.com/WP-API/WP-API/issues/305 +[gh-309]: https://github.com/WP-API/WP-API/issues/309 +[gh-316]: https://github.com/WP-API/WP-API/issues/316 +[gh-320]: https://github.com/WP-API/WP-API/issues/320 +[gh-321]: https://github.com/WP-API/WP-API/issues/321 +[gh-326]: https://github.com/WP-API/WP-API/issues/326 +[gh-333]: https://github.com/WP-API/WP-API/issues/333 +[gh-333]: https://github.com/WP-API/WP-API/issues/333 +[gh-335]: https://github.com/WP-API/WP-API/issues/335 +[gh-347]: https://github.com/WP-API/WP-API/issues/347 +[gh-355]: https://github.com/WP-API/WP-API/issues/355 +[gh-357]: https://github.com/WP-API/WP-API/issues/357 +[gh-358]: https://github.com/WP-API/WP-API/issues/358 +[gh-359]: https://github.com/WP-API/WP-API/issues/359 +[gh-364]: https://github.com/WP-API/WP-API/issues/364 +[gh-374]: https://github.com/WP-API/WP-API/issues/374 +[gh-376]: https://github.com/WP-API/WP-API/issues/376 +[gh-377]: https://github.com/WP-API/WP-API/issues/377 +[gh-378]: https://github.com/WP-API/WP-API/issues/378 +[gh-380]: https://github.com/WP-API/WP-API/issues/380 +[gh-384]: https://github.com/WP-API/WP-API/issues/384 +[gh-390]: https://github.com/WP-API/WP-API/issues/390 +[gh-391]: https://github.com/WP-API/WP-API/issues/391 +[gh-392]: https://github.com/WP-API/WP-API/issues/392 +[gh-393]: https://github.com/WP-API/WP-API/issues/393 +[gh-396]: https://github.com/WP-API/WP-API/issues/396 +[gh-397]: https://github.com/WP-API/WP-API/issues/397 +[gh-401]: https://github.com/WP-API/WP-API/issues/401 +[gh-402]: https://github.com/WP-API/WP-API/issues/402 +[gh-405]: https://github.com/WP-API/WP-API/issues/405 +[gh-410]: https://github.com/WP-API/WP-API/issues/410 +[gh-411]: https://github.com/WP-API/WP-API/issues/411 +[gh-413]: https://github.com/WP-API/WP-API/issues/413 +[gh-416]: https://github.com/WP-API/WP-API/issues/416 +[gh-437]: https://github.com/WP-API/WP-API/issues/437 +[gh-438]: https://github.com/WP-API/WP-API/issues/438 +[gh-441]: https://github.com/WP-API/WP-API/issues/441 +[gh-455]: https://github.com/WP-API/WP-API/issues/455 +[gh-458]: https://github.com/WP-API/WP-API/issues/458 +[gh-461]: https://github.com/WP-API/WP-API/issues/461 +[gh-462]: https://github.com/WP-API/WP-API/issues/462 +[gh-473]: https://github.com/WP-API/WP-API/issues/473 +[gh-474]: https://github.com/WP-API/WP-API/issues/474 +[gh-481]: https://github.com/WP-API/WP-API/issues/481 +[gh-486]: https://github.com/WP-API/WP-API/issues/486 +[gh-499]: https://github.com/WP-API/WP-API/issues/499 +[gh-505]: https://github.com/WP-API/WP-API/issues/505 +[gh-519]: https://github.com/WP-API/WP-API/issues/519 +[gh-524]: https://github.com/WP-API/WP-API/issues/524 +[gh-528]: https://github.com/WP-API/WP-API/issues/528 +[gh-595]: https://github.com/WP-API/WP-API/issues/595 +[gh-730]: https://github.com/WP-API/WP-API/issues/730 +[gh-933]: https://github.com/WP-API/WP-API/issues/933 +[gh-985]: https://github.com/WP-API/WP-API/issues/985 + +## 1.1.1 + +- Mitigate Flash CSRF exploit + + Using the API's JSONP support, it's possible to control the first bytes of the + response sent to the browser. Combining this with an ASCII-encoded SWF allows + arbitrary SWFs to be served from the site, allowing bypassing the same-origin + policy built in to browsers. + + While the API includes CSRF protection and is not directly vulnerable, this + can be used to bypass other browser origin controls. + + Reported by @iandunn on 2014-07-10. + + (props @iandunn, @rmccue, [#356][gh-356]) + +[View all changes](https://github.com/rmccue/WP-API/compare/1.0...1.1) + +[gh-356]: https://github.com/WP-API/WP-API/issues/356 + +## 1.1 + +- Add new routes for taxonomies and terms. + + Taxonomies and terms have now been moved from the `/posts/types/` + namespace to global routes: `/taxonomies`, `/taxonomies/`, + `/taxonomies//terms` and `/taxonomies//terms/` + + Test coverage for taxonomy endpoints has also been increased to 100%. + + **Deprecation warning**: The `/posts/types//taxonomies` endpoint (and + sub-endpoints with the same prefix) have been deprecated in favour of the new + endpoints. These deprecated endpoints will now return a + `X-WP-DeprecatedFunction` header indicating that the endpoint should not be + used for new development, but will continue to work in the future. + + (props @kadamwhite, @rachelbaker, @rmccue, [#198][gh-198], [#211][gh-211]) + +- Allow customizing the API resources prefix + + The API base (typically `wp-json/`) can now be customized to a different + prefix using the `json_url_prefix` filter. Note that rewrites will need to be + flushed manually after changing this. + + (props @ericandrewlewis, @rmccue, [#104][gh-104], [#244][gh-244], [#278][gh-278]) + +- Give `null` as date for draft posts. + + Draft posts would previously return "0000-00-00 00:00:00" or + "1970-01-01T00:00:00", as draft posts are not assigned a publish date. The API + now returns `null` where a date is not available. + + **Compatibility warning**: Clients should be prepared to accept `null` as a + value for date/time fields, and treat it as if no value is set. + + (props @rmccue, [#229][gh-229], [#230][gh-230]) + +- Fix errors with excerpt. + + Posts without excerpts could previously return nonsense strings, excerpts from + other posts, or cause internal PHP errors. Posts without excerpts will now + always return an excerpt, typically automatically generated from the post + content. + + The `excerpt_raw` field was added to the edit context on posts. This field + contains the raw excerpt data saved for the post, including empty + string values. + + (props @rmccue, [#222][gh-226], [#226][gh-226]) + +- Only expose email for edit context. + + User email addresses are now only exposed for `context=edit`, which requires + the `edit_users` permission (not required for the current user). + + The email address field will now return `false` instead of a string if the + field is not exposed. + + (props @pkevan, @rmccue, [#290][gh-290], [#296][gh-296]) + +- Correct password-protected post handling. + + Password-protected posts could previously be exposed to all users, however + could also have broken behaviour with excerpts. Password-protected posts are + now hidden to unauthenticated users, while content and excerpts are shown + correctly for the `edit` context. + + (Note that hiding password-protected posts is intended to be a temporary + measure, and will likely change in the future.) + + (props @rmccue, [#286][gh-286], [#313][gh-313]) + +- Add documentation on authentication methods. + + Full documentation on [authentication](https://github.com/WP-API/WP-API/blob/master/docs/authentication.md) + is now available. This documentation explains the difference between the + various available authentication methods, and notes which should be used. + + (props @rmccue, [#242][gh-242]) + +- Include new client JS from github.io + + The WP-API Javascript library is now loaded dynamically from + `wp-api.github.io` to ensure it is always up-to-date. + + (props @tlovett1, [#179][gh-179], [#240][gh-240]) + +- Don't allow setting the modification date on post creation/update. + + As it turns out, WP core doesn't allow us to set this, so this was previously + a no-op anyway. Discovered during test coverage phase. + + (props @rachelbaker, @rmccue, [#285][gh-285], [#288][gh-288]) + +- Check post parent correctly on insertion. + + Posts could previously be added with an invalid parent ID. These IDs are now + checked to ensure the post exists. + + (props @rmccue, [#228][gh-228], [#231][gh-231]) + +- Make sure the type is actually evaluated for `json_prepare_${type}` filter. + + This value was previously not interpolated correctly, due to the use of the + single-quoted string type. + + (props @danielbachhuber, [#266][gh-266]) + +- Return `WP_Error` instead of array of empty objects for a revisions + permissions error. + + Previously, when trying to access post revisions without correct permissions, + a JSON list of internal error objects would be returned. This has been + corrected to return a standard API error instead. + + (props @rachelbaker, @tlovett1, [#251][gh-251], [#276][gh-276]) + +- Flip user parameters check for insert/update. + + Previously, you could add a user without specifying username/password/email, + but couldn't update a user without those parameters. The logic has been + inverted here instead. + + (props @rmccue, [#221][gh-221], [#289][gh-289]) + +- Add revision endpoints tests + + (props @danielbachhuber, @rachelbaker, @rmccue, [#275][gh-275], [#277][gh-277], [#284][gh-284], [#279][gh-279]) + +- Add post endpoint testing + + Now at >54% coverage for the whole class, and >80% for the main methods. This + figure will continue to rise over the next few releases. + + (props @rachelbaker, @rmccue, [#99][gh-99]) + +- Separate helper functions into global namespace. + + `WP_JSON_Server::get_timezone()`, `WP_JSON_Server::get_date_with_gmt()`, + `WP_JSON_Server::get_avatar_url()` and ``WP_JSON_Server::parse_date()` have + all been moved into the global namespace to decouple them from the server + class. + + **Deprecation warning**: These methods have been deprecated. The new + `json_get_timezone()`, `json_get_date_with_gmt()`, `json_get_avatar_url()` and + `json_parse_date()` methods should now be used instead. + + (props @rmccue, [#185][gh-185], [#298][gh-298]) + +- Re-order Users and Media routes documentation based on CRUD order + + (props @rachelbaker, [#214][gh-214]) + +- Update Post route documentation to provide more detail for data parameter + + (props @rachelbaker, [#212][gh-212]) + +- Correct documentation typo ("inforcement" -> "enforcement"). + + (props @ericandrewlewis, [#236][gh-236]) + +- Coding Standards audit + + (props @DrewAPicture, [#235][gh-235]) + +- Add comparison documentation. + + (props @rachelbaker, @rmccue, [#217][gh-225], [#225][gh-225]) + +- `json_url` filter call should be passed `$scheme` + + (props @ericandrewlewis, [#243][gh-243]) + +- Set `class-jsonserializable.php` file mode to 644. + + (props @jeremyfelt, [#255][gh-255]) + +- Remove unneeded "which" in implementation doc. + + (props @JDGrimes, [#254][gh-254]) + +- Fix a copy/paste error in schema doc. + + (props @JDGrimes, [#253][gh-253]) + +- Correct reference link in example schema. + + (props @danielbachhuber, [#258][gh-258]) + +- Add missing post formats to post schema documentation. + + (props @danielbachhuber, [#260][gh-260]) + +- Ensure we always use "public" on public methods. + + (props @danielbachhuber, [#268][gh-268]) + +- Ensure we don't cause a PHP error if a post does not have revisions. + + (props @rmccue, [#227][gh-227]) + +- Add note to where upload_files cap comes from + + (props @pkevan, [#282][gh-282]) + +- Add handling of `sticky` property when creating or editing posts. + + (props @rachelbaker, [#218][gh-218]) + +- Update post route endpoint docs to include details on `post_meta` handling. + + (props @rachelbaker, [#213][gh-213]) + +- Update main readme file to better describe the project. + + (props @rmccue, [#303][gh-303]) + +- Fix `--data-binary` cURL option in documentation + + (props @Pezzab, @rachelbaker, @rmccue, [#283][gh-283], [#304][gh-304]) + +[View all changes](https://github.com/rmccue/WP-API/compare/1.0...1.1) + +[gh-99]: https://github.com/WP-API/WP-API/issues/99 +[gh-104]: https://github.com/WP-API/WP-API/issues/104 +[gh-179]: https://github.com/WP-API/WP-API/issues/179 +[gh-185]: https://github.com/WP-API/WP-API/issues/185 +[gh-198]: https://github.com/WP-API/WP-API/issues/198 +[gh-211]: https://github.com/WP-API/WP-API/issues/211 +[gh-212]: https://github.com/WP-API/WP-API/issues/212 +[gh-213]: https://github.com/WP-API/WP-API/issues/213 +[gh-214]: https://github.com/WP-API/WP-API/issues/214 +[gh-218]: https://github.com/WP-API/WP-API/issues/218 +[gh-221]: https://github.com/WP-API/WP-API/issues/221 +[gh-225]: https://github.com/WP-API/WP-API/issues/225 +[gh-225]: https://github.com/WP-API/WP-API/issues/225 +[gh-226]: https://github.com/WP-API/WP-API/issues/226 +[gh-226]: https://github.com/WP-API/WP-API/issues/226 +[gh-227]: https://github.com/WP-API/WP-API/issues/227 +[gh-228]: https://github.com/WP-API/WP-API/issues/228 +[gh-229]: https://github.com/WP-API/WP-API/issues/229 +[gh-230]: https://github.com/WP-API/WP-API/issues/230 +[gh-231]: https://github.com/WP-API/WP-API/issues/231 +[gh-235]: https://github.com/WP-API/WP-API/issues/235 +[gh-236]: https://github.com/WP-API/WP-API/issues/236 +[gh-240]: https://github.com/WP-API/WP-API/issues/240 +[gh-242]: https://github.com/WP-API/WP-API/issues/242 +[gh-243]: https://github.com/WP-API/WP-API/issues/243 +[gh-244]: https://github.com/WP-API/WP-API/issues/244 +[gh-251]: https://github.com/WP-API/WP-API/issues/251 +[gh-253]: https://github.com/WP-API/WP-API/issues/253 +[gh-254]: https://github.com/WP-API/WP-API/issues/254 +[gh-255]: https://github.com/WP-API/WP-API/issues/255 +[gh-258]: https://github.com/WP-API/WP-API/issues/258 +[gh-260]: https://github.com/WP-API/WP-API/issues/260 +[gh-266]: https://github.com/WP-API/WP-API/issues/266 +[gh-268]: https://github.com/WP-API/WP-API/issues/268 +[gh-275]: https://github.com/WP-API/WP-API/issues/275 +[gh-276]: https://github.com/WP-API/WP-API/issues/276 +[gh-277]: https://github.com/WP-API/WP-API/issues/277 +[gh-278]: https://github.com/WP-API/WP-API/issues/278 +[gh-279]: https://github.com/WP-API/WP-API/issues/279 +[gh-282]: https://github.com/WP-API/WP-API/issues/282 +[gh-283]: https://github.com/WP-API/WP-API/issues/283 +[gh-284]: https://github.com/WP-API/WP-API/issues/284 +[gh-285]: https://github.com/WP-API/WP-API/issues/285 +[gh-286]: https://github.com/WP-API/WP-API/issues/286 +[gh-288]: https://github.com/WP-API/WP-API/issues/288 +[gh-289]: https://github.com/WP-API/WP-API/issues/289 +[gh-290]: https://github.com/WP-API/WP-API/issues/290 +[gh-296]: https://github.com/WP-API/WP-API/issues/296 +[gh-298]: https://github.com/WP-API/WP-API/issues/298 +[gh-303]: https://github.com/WP-API/WP-API/issues/303 +[gh-304]: https://github.com/WP-API/WP-API/issues/304 +[gh-313]: https://github.com/WP-API/WP-API/issues/313 + +## 1.0 + +- Add user endpoints. + + Creating, reading, updating and deleting users and their data is now possible + by using the `/users` endpoints. `/users/me` can be used to determine the + current user, and returns a 401 status for non-logged in users. + + Note that the format of post authors has changed, as it is now an embedded + User entity. This should not break backwards compatibility. + + Custom post types gain this ability automatically. + + (props @tobych, @rmccue, [#20][gh-20], [#146][gh-146]) + +- Add post meta endpoints. + + Creating, reading, updating and deleting post meta is now possible by using + the `/posts//meta` endpoints. Post meta is now correctly embedded into + Post entities. + + Meta can be updated via the Post entity (e.g. `PUT` to `/posts/`) or via + the entity itself at `/posts//meta/`. Meta deletion must be done via + a `DELETE` request to the latter. + + Only non-protected and non-serialized meta can be accessed or manipulated via + the API. This is not predicted to change in the future; clients wishing to + access this data should consider alternative approaches. + + Custom post types do not currently gain this ability automatically. + + (props @attitude, @alisspers, @rachelbaker, @rmccue, @tlovett1, @tobych, + @zedejose, [#68][gh-68], [#168][gh-168], [#189][gh-189], [#207][gh-207]) + +- Add endpoint for deleting a single comment. + + Clients can now send a `DELETE` request to comment routes to delete + the comment. + + Custom post types supporting comments will gain this ability automatically. + + (props @tlovett1, @rmccue, [#178][gh-178], [#191][gh-191]) + +- Add endpoint for post revisions. + + Post revisions are now available at `/posts//revisions`, and are linked in + the `meta.links.version-history` key of post entities. + + Custom post types supporting revisions will gain this ability automatically. + + (props @tlovett1, [#193][gh-193]) + +- Respond to requests without depending on pretty permalink settings. + + For sites without pretty permalinks enabled, the API is now available from + `?json_route=/`. Clients should check for this via the autodiscovery methods + (Link header or RSD). + + (props @rmccue, [#69][gh-69], [#138][gh-138]) + +- Add register post type argument. + + Post types can now indicate their availability via the API using the + `show_in_json` argument passed to `register_post_type`. This value defaults to + the `publicly_queryable` argument (which itself defaults to the + `public` argument). + + (props @iandunn, @rmccue, [#145][gh-145]) + +- Remove basic authentication handler. + + **This breaks backwards compatibility** for clients using Basic + authentication. Clients are encouraged to switch to using [OAuth + authentication][OAuth1]. The [Basic Authentication plugin][Basic-Auth] can be + installed for backwards compatibility and local development, however should + not be used in production. + + (props @rmccue, [#37][gh-37], [#152][gh-152]) + +- Require nonces for cookie-based authentication. + + **This breaks backwards compatibility** and requires any clients using cookie + authentication to also send a nonce with the request. The built-in Javascript + API automatically handles this. + + (props @rmccue, [#177][gh-177], [#180][gh-180]) + +- Clean up deprecated methods/functions. + + Functions and methods previously deprecated in 0.8/0.9 have now been removed. + Future deprecations will take place in the same manner as WordPress core. + + **This breaks backwards compatibility**, however these were marked as + deprecated in previous releases. + + (props @rmccue, [#187][gh-187]) + +- Only expose meta on 'edit' context as a temporary workaround. + + Privacy concerns around exposing meta to all users necessitate this change. + + **This breaks backwards compatibility** as post meta data is no longer + available to all users. Clients wishing to access this data should + authenticate and use the `edit` context. + + (props @iandunn, @rmccue, [#135][gh-135]) + +- Add `json_ensure_response` function to ensure either a + `WP_JSON_ResponseInterface` or a `WP_Error` object is returned. + + When extending the API, the `json_ensure_response` function can be used to + ensure that any raw data returned is wrapped with a `WP_JSON_Response` object. + This allows using `get_status`/`get_data` easily, however `WP_Error` must + still be checked via `is_wp_error`. + + (props @rmccue, [#151][gh-151], [#154][gh-154]) + +- Use version option to check on init if rewrite rules should be flushed. + + Rewrite rules on multisite are now flushed via an init hook, rather than + switching to each site on activation. + + (props @rachelbaker, [#149][gh-149]) + +- Fix typo in schema docs + + (props @codebykat, [#132][gh-132]) + +- Add check for valid JSON data before using to avoid parameter overwrite. + + When passing data to an endpoint that accepts JSON data, the data will now be + validated before passing to the endpoint. + + (props @rachelbaker, @rmccue, [#133][gh-133]) + +- Add authentication property to site index. + + (props @rmccue, [#131][gh-131]) + +- Move the test helper to a subdirectory. + + The plugin will now no longer prompt for updates due to the helper. + + (props @rmccue, [#127][gh-127]) + +- Include post ID with `json_prepare_meta` filter. + + (props @rmccue, [#137][gh-137]) + +- Corrected parameter names in x-form examples in docs. + + (props @rachelbaker, [#134][gh-134]) + +- Pass `WP_JSON_Server` instance to `json_serve_request`. + + (props @alisspers, @rmccue, [#61][gh-61], [#139][gh-139]) + +- Don't use deprecated function in `WP_JSON_Posts::edit_post()` + + (props @rachelbaker, [#150][gh-150]) + +- Pass post ID to `json_insert_post` action during both insert and update. + + (props @cmmarslender, [#148][gh-148]) + +- Add descriptions to taxonomy term data. + + (props @pushred, [#111][gh-111]) + +- Ensure we handle raw data passed to the API. + + (props @tlovett1, @rmccue, [#91][gh-91], [#155][gh-155]) + +- Remove unused `prepare_author` method from `WP_JSON_Posts` class. + + (props @rachelbaker, [#165][gh-165]) + +- Add multiple post type support to get_posts method. + + (props @rmccue, [#142][gh-142], [#163][gh-163]) + +- Return `WP_Error` in `WP_JSON_Posts::get_comment` for invalid comments. + + (props @tlovett1, [#166][gh-166], [#171][gh-171]) + +- Update getting started documentation. + + (props @rmccue, [#176][gh-176]) + +- Improve and clarify "array" input syntax documentation. + + (props @rmccue, [#140][gh-140], [#175][gh-175]) + +- Update post routes documentation. + + (props @rmccue, [#172][gh-172], [#174][gh-174]) + +- Add documentation for user endpoints. + + (props @rachelbaker, @rmccue, [#158][gh-158]) + +- Add permalink settings step to Quick Setup instructions. + + (props @kadamwhite, [#183][gh-183]) + +- Update taxonomy collection to return indexed array. + + (props @mattheu, [#184][gh-184]) + +- Remove placeholder endpoints. + + (props @rmccue, [#161][gh-161], [#192][gh-192]) + +- Fix issues with embedded attachments. + + Checks that the post supports attachment data before adding it, and ensures we + don't embed entities many layers deep. + + (props @rmccue, [#194][gh-194]) + +- Change post parent preparation context to embed. + + (props @rmccue, [#195][gh-195]) + +- Change server meta links to reference the WP-API organization GitHub repo. + + (props @rachelbaker, [#208][gh-208]) + +- Fix plugin tests + + (props @rmccue, [#215][gh-215]) + +- Check for errors with invalid dates and remove duplicate date parsing + methods. + + (props @rachelbaker, @rmccue, [#216][gh-216], [#219][gh-219]) + +[View all changes](https://github.com/rmccue/WP-API/compare/0.9...1.0) + +[OAuth1]: https://github.com/WP-API/OAuth1 +[Basic-Auth]: https://github.com/WP-API/Basic-Auth +[gh-20]: https://github.com/WP-API/WP-API/issues/20 +[gh-37]: https://github.com/WP-API/WP-API/issues/37 +[gh-61]: https://github.com/WP-API/WP-API/issues/61 +[gh-68]: https://github.com/WP-API/WP-API/issues/68 +[gh-69]: https://github.com/WP-API/WP-API/issues/69 +[gh-91]: https://github.com/WP-API/WP-API/issues/91 +[gh-111]: https://github.com/WP-API/WP-API/issues/111 +[gh-127]: https://github.com/WP-API/WP-API/issues/127 +[gh-131]: https://github.com/WP-API/WP-API/issues/131 +[gh-132]: https://github.com/WP-API/WP-API/issues/132 +[gh-133]: https://github.com/WP-API/WP-API/issues/133 +[gh-134]: https://github.com/WP-API/WP-API/issues/134 +[gh-135]: https://github.com/WP-API/WP-API/issues/135 +[gh-137]: https://github.com/WP-API/WP-API/issues/137 +[gh-138]: https://github.com/WP-API/WP-API/issues/138 +[gh-139]: https://github.com/WP-API/WP-API/issues/139 +[gh-140]: https://github.com/WP-API/WP-API/issues/140 +[gh-142]: https://github.com/WP-API/WP-API/issues/142 +[gh-145]: https://github.com/WP-API/WP-API/issues/145 +[gh-146]: https://github.com/WP-API/WP-API/issues/146 +[gh-148]: https://github.com/WP-API/WP-API/issues/148 +[gh-149]: https://github.com/WP-API/WP-API/issues/149 +[gh-150]: https://github.com/WP-API/WP-API/issues/150 +[gh-151]: https://github.com/WP-API/WP-API/issues/151 +[gh-152]: https://github.com/WP-API/WP-API/issues/152 +[gh-154]: https://github.com/WP-API/WP-API/issues/154 +[gh-155]: https://github.com/WP-API/WP-API/issues/155 +[gh-158]: https://github.com/WP-API/WP-API/issues/158 +[gh-161]: https://github.com/WP-API/WP-API/issues/161 +[gh-163]: https://github.com/WP-API/WP-API/issues/163 +[gh-165]: https://github.com/WP-API/WP-API/issues/165 +[gh-166]: https://github.com/WP-API/WP-API/issues/166 +[gh-168]: https://github.com/WP-API/WP-API/issues/168 +[gh-171]: https://github.com/WP-API/WP-API/issues/171 +[gh-172]: https://github.com/WP-API/WP-API/issues/172 +[gh-174]: https://github.com/WP-API/WP-API/issues/174 +[gh-175]: https://github.com/WP-API/WP-API/issues/175 +[gh-176]: https://github.com/WP-API/WP-API/issues/176 +[gh-177]: https://github.com/WP-API/WP-API/issues/177 +[gh-178]: https://github.com/WP-API/WP-API/issues/178 +[gh-180]: https://github.com/WP-API/WP-API/issues/180 +[gh-183]: https://github.com/WP-API/WP-API/issues/183 +[gh-184]: https://github.com/WP-API/WP-API/issues/184 +[gh-187]: https://github.com/WP-API/WP-API/issues/187 +[gh-189]: https://github.com/WP-API/WP-API/issues/189 +[gh-191]: https://github.com/WP-API/WP-API/issues/191 +[gh-192]: https://github.com/WP-API/WP-API/issues/192 +[gh-193]: https://github.com/WP-API/WP-API/issues/193 +[gh-194]: https://github.com/WP-API/WP-API/issues/194 +[gh-195]: https://github.com/WP-API/WP-API/issues/195 +[gh-207]: https://github.com/WP-API/WP-API/issues/207 +[gh-208]: https://github.com/WP-API/WP-API/issues/208 +[gh-215]: https://github.com/WP-API/WP-API/issues/215 +[gh-216]: https://github.com/WP-API/WP-API/issues/216 +[gh-219]: https://github.com/WP-API/WP-API/issues/219 + +## 0.9 + +- Move from `wp-json.php/` to `wp-json/` + + **This breaks backwards compatibility** and requires any clients to now use + `wp-json/`, or preferably the new RSD/Link headers. + + (props @rmccue, @matrixik, [#46][gh-46], [#96][gh-96], [#106][gh-106]) + +- Move filter registration out of CPT constructor. CPT subclasses now require + you to call `$myobject->register_filters()`, in order to move global state out + of the constructor. + + **This breaks backwards compatibility** and requires any subclassing to now + call `$myobject->register_filters()` + + (props @rmccue, @thenbrent, [#42][gh-42], [#126][gh-126]) + +- Introduce Response/ResponseInterface + + Endpoints that need to set headers or response codes should now return a + `WP_JSON_Response` rather than using the server methods. + `WP_JSON_ResponseInterface` may also be used for more flexible use of the + response methods. + + **Deprecation warning:** Calling `WP_JSON_Server::header`, + `WP_JSON_Server::link_header` and `WP_JSON_Server::query_navigation_headers` + is now deprecated. This will be removed in 1.0. + + (props @rmccue, [#33][gh-33]) + +- Change all semiCamelCase names to underscore_case. + + **Deprecation warning**: Any calls to semiCamelCase methods require any + subclassing to update method references. This will be removed in 1.0. + + (props @osiux, [#36][gh-36], [#82][gh-82]) + +- Add multisite compatibility. If the plugin is network activated, the plugin is + now activated once-per-site, so `wp-json/` is always site-local. + + (props @rachelbaker, [#48][gh-48], [#49][gh-49]) + +- Add RSD and Link headers for discovery + + (props @rmccue, [#40][gh-40]) + +- WP_JSON_Posts->prepare_author() now verifies the `$user` object is set. + + (props @rachelbaker, [#51][gh-51], [#54][gh-54]) + +- Added unit testing framework. Currently only a smaller number of tests, but we + plan to increase this significantly as soon as possible. + + (props @tierra, @osiux, [#65][gh-65], [#76][gh-76], [#84][gh-84]) + +- Link collection filtering docs to URL formatting guide. + + (props @kadamwhite, [#74][gh-74]) + +- Remove hardcoded `/pages` references from `WP_JSON_Pages` + + (props @rmccue, @thenbrent, [#28][gh-28], [#78][gh-78]) + +- Fix compatibility with `DateTime::createFromFormat` on PHP 5.2 + + (props @osiux, [#52][gh-52], [#79][gh-79]) + +- Document that `WP_JSON_CustomPostType::__construct()` requires a param of type + `WP_JSON_ResponseHandler`. + + (props @tlovett1, [#88][gh-88]) + +- Add timezone parameter to WP_JSON_DateTime::createFromFormat() + + (props @royboy789, @rachelbaker, [#85][gh-85], [#87][gh-87]) + +- Remove IXR references. `IXR_Error` is no longer accepted as a return value. + + **This breaks backwards compatibility** and requires anyone returning + `IXR_Error` objects to now return `WP_Error` or `WP_JSON_ResponseInterface` + objects. + + (props @rmccue, [#50][gh-50], [#77][gh-77]) + +- Fix bugs with attaching featured images to posts: + - `WP_JSON_Media::attachThumbnail()` should do nothing if `$update` is false + without a post ID + - The post ID must be fetched from the `$post` array. + + (props @Webbgaraget, [#55][gh-55]) + +- Don't declare `jsonSerialize` on ResponseInterface + + (props @rmccue, [#97][gh-97]) + +- Allow JSON post creation/update for `WP_JSON_CustomPostType` + + (props @tlovett1, [#90][gh-90], [#108][gh-108]) + +- Return null if post doesn't have an excerpt + + (props @rachelbacker, [#72][gh-72]) + +- Fix link to issue tracker in README + + (props @rmccue, @tobych, [#125][gh-125]) + +[View all changes](https://github.com/rmccue/WP-API/compare/0.8...0.9) + +[gh-28]: https://github.com/WP-API/WP-API/issues/28 +[gh-33]: https://github.com/WP-API/WP-API/issues/33 +[gh-36]: https://github.com/WP-API/WP-API/issues/36 +[gh-40]: https://github.com/WP-API/WP-API/issues/40 +[gh-42]: https://github.com/WP-API/WP-API/issues/42 +[gh-46]: https://github.com/WP-API/WP-API/issues/46 +[gh-48]: https://github.com/WP-API/WP-API/issues/48 +[gh-49]: https://github.com/WP-API/WP-API/issues/49 +[gh-50]: https://github.com/WP-API/WP-API/issues/50 +[gh-51]: https://github.com/WP-API/WP-API/issues/51 +[gh-52]: https://github.com/WP-API/WP-API/issues/52 +[gh-54]: https://github.com/WP-API/WP-API/issues/54 +[gh-55]: https://github.com/WP-API/WP-API/issues/55 +[gh-65]: https://github.com/WP-API/WP-API/issues/65 +[gh-72]: https://github.com/WP-API/WP-API/issues/72 +[gh-74]: https://github.com/WP-API/WP-API/issues/74 +[gh-76]: https://github.com/WP-API/WP-API/issues/76 +[gh-77]: https://github.com/WP-API/WP-API/issues/77 +[gh-78]: https://github.com/WP-API/WP-API/issues/78 +[gh-79]: https://github.com/WP-API/WP-API/issues/79 +[gh-82]: https://github.com/WP-API/WP-API/issues/82 +[gh-84]: https://github.com/WP-API/WP-API/issues/84 +[gh-85]: https://github.com/WP-API/WP-API/issues/85 +[gh-87]: https://github.com/WP-API/WP-API/issues/87 +[gh-88]: https://github.com/WP-API/WP-API/issues/88 +[gh-90]: https://github.com/WP-API/WP-API/issues/90 +[gh-96]: https://github.com/WP-API/WP-API/issues/96 +[gh-97]: https://github.com/WP-API/WP-API/issues/97 +[gh-106]: https://github.com/WP-API/WP-API/issues/106 +[gh-108]: https://github.com/WP-API/WP-API/issues/108 +[gh-125]: https://github.com/WP-API/WP-API/issues/125 +[gh-126]: https://github.com/WP-API/WP-API/issues/126 + ## 0.8 - Add compatibility layer for JsonSerializable. You can now return arbitrary objects from endpoints and use the `jsonSerialize()` method to return the data diff --git a/README.md b/README.md index d9e2c97579..40e3cc2dda 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,46 @@ # REST API -This is a project to create a JSON-based REST API for WordPress. This project is -run by Ryan McCue and is part of the WordPress 2013 GSoC projects. -[![Build Status](https://travis-ci.org/WP-API/WP-API.png?branch=master)](https://travis-ci.org/WP-API/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=master)](https://travis-ci.org/WP-API/WP-API) -## Documentation +## About -Read the [plugin's documentation][docs]. +WordPress is moving towards becoming a fully-fledged application framework, and +we need new APIs. This project was born to create an easy-to-use, +easy-to-understand and well-tested framework for creating these APIs, plus +creating APIs for core. -[docs]: https://github.com/WP-API/WP-API/tree/master/docs +This plugin provides an easy to use REST API, available via HTTP. Grab your +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/posts`. +Update user with ID 4? Send a `POST` request to `/wp-json/users/4`. Get all +posts with the search term "awesome"? `GET /wp-json/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. + +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. + +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! + +We're currently aiming for integration into WordPress 4.1 as a permanent part of +core. ## Installation -### As a Plugin 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. -### As Part of Core -**Note: These instructions will likely be broken while in development. Please -use the plugin method instead.** - -Drop `wp-json.php` into your WordPress directory, and drop -`class-wp-json-server.php` into your `wp-includes/` directory. You'll need -working `PATH_INFO` on your server, but you don't need pretty permalinks -enabled. - ## Quick Setup @@ -34,18 +48,18 @@ Want to test out WP-API and work on it? Here's how you can set up your own testing environment in a few easy steps: 1. Install [Vagrant](http://vagrantup.com/) and [VirtualBox](https://www.virtualbox.org/). -2. Clone [Chassis](https://github.com/sennza/Chassis): +2. Clone [Chassis](https://github.com/Chassis/Chassis): ```bash - git clone --recursive git@github.com:sennza/Chassis.git api-tester - vagrant plugin install vagrant-hostsupdater + git clone --recursive git@github.com:Chassis/Chassis.git api-tester ``` 3. Grab a copy of WP API: ```bash cd api-tester - mkdir -p content/plugins + mkdir -p content/plugins content/themes + cp -r wp/wp-content/themes/* content/themes git clone git@github.com:WP-API/WP-API.git content/plugins/json-rest-api ``` @@ -55,47 +69,82 @@ testing environment in a few easy steps: vagrant up ``` -5. Browse to http://vagrant.local/wp/wp-admin/ and activate the WP API plugin +5. Activate the plugin: + ```bash + vagrant ssh -c 'cd /vagrant && wp plugin activate json-rest-api' ``` - Username: admin - Password: password + +6. Set the permalink structure to something other than the default, in order to + enable the http://vagrant.local/wp-json/ endpoint URL (if you skip this + step, it can be accessed at http://vagrant.local/?json_route=/): + + ```bash + vagrant ssh -c "cd /vagrant && wp rewrite structure '/%postname%/'" ``` -6. Browse to http://vagrant.local/wp-json/ +You're done! You should now have a WordPress site available at +http://vagrant.local; you can access the API via http://vagrant.local/wp-json/ +To access the admin interface, visit http://vagrant.local/wp/wp-admin and log +in with the credentials below: + + ``` + Username: admin + Password: password + ``` ### Testing For testing, you'll need a little bit more: -1. SSH into your Vagrant box, and install PHPUnit: +1. Clone the [Tester extension](https://github.com/Chassis/Tester) for Chassis: ```bash - vagrant ssh - sudo apt-get install php-pear - sudo pear config-set auto_discover 1 - sudo pear install pear.phpunit.de/PHPUnit + # From your base directory, api-tester if following the steps from before + git clone --recursive https://github.com/Chassis/Tester.git extensions/tester ``` -2. Clone WordPress development (including tests): +2. Update the `wpdevel` submodule in Chassis to latest on master from [WordPress Git Mirror](https://make.wordpress.org/core/2014/01/15/git-mirrors-for-wordpress/): ```bash - git clone git://develop.git.wordpress.org/ /tmp/wordpress - export WP_DEVELOP_DIR=/tmp/wordpress + # From your base directory, api-tester if following the steps from before + cd extensions/tester/wpdevel + git checkout master + git pull + ``` + +3. Run the provisioner: + + ``` + vagrant provision ``` -3. Run the testing suite: +4. Log in to the virtual machine and run the testing suite: ```bash + vagrant ssh cd /vagrant/content/plugins/json-rest-api phpunit ``` + You can also execute the tests in the context of the VM without SSHing + into the virtual machine (this is equivalent to the above): + + ```bash + vagrant ssh -c 'cd /vagrant/content/plugins/json-rest-api && phpunit' + ``` + ## Issue Tracking -All tickets for the project are being tracked on the [GSoC Trac][]. Make sure -you use the JSON REST API component. +All tickets for the project are being tracked on [GitHub][]. You can also take a +look at the [recent updates][] for the project. + +Previous issues can be found on the [GSOC Trac][] issue tracker, however new +issues should not be filed there. -[GSoC Trac]: https://gsoc.trac.wordpress.org/query?component=JSON+REST+API +[docs]: http://wp-api.org/ +[GitHub]: https://github.com/WP-API/WP-API +[GSOC Trac]: https://gsoc.trac.wordpress.org/query?component=JSON+REST+API +[recent updates]: http://make.wordpress.org/core/tag/json-api/ diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh new file mode 100755 index 0000000000..5baa6cb56c --- /dev/null +++ b/bin/install-wp-tests.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash + +if [ $# -lt 3 ]; then + echo "usage: $0 [db-host] [wp-version]" + exit 1 +fi + +DB_NAME=$1 +DB_USER=$2 +DB_PASS=$3 +DB_HOST=${4-localhost} +WP_VERSION=${5-latest} + +WP_TESTS_DIR=${WP_TESTS_DIR-/tmp/wordpress-tests-lib} +WP_CORE_DIR=${WP_CORE_DIR-/tmp/wordpress/} + +download() { + if [ `which curl` ]; then + curl -s "$1" > "$2"; + elif [ `which wget` ]; then + wget -nv -O "$2" "$1" + fi +} + +if [[ $WP_VERSION =~ [0-9]+\.[0-9]+(\.[0-9]+)? ]]; then + WP_TESTS_TAG="tags/$WP_VERSION" +elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + WP_TESTS_TAG="trunk" +else + # http serves a single offer, whereas https serves multiple. we only want one + download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json + grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json + LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') + if [[ -z "$LATEST_VERSION" ]]; then + echo "Latest WordPress version could not be found" + exit 1 + fi + WP_TESTS_TAG="tags/$LATEST_VERSION" +fi + +set -ex + +install_wp() { + + if [ -d $WP_CORE_DIR ]; then + return; + fi + + mkdir -p $WP_CORE_DIR + + if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + mkdir -p /tmp/wordpress-nightly + download https://wordpress.org/nightly-builds/wordpress-latest.zip /tmp/wordpress-nightly/wordpress-nightly.zip + unzip -q /tmp/wordpress-nightly/wordpress-nightly.zip -d /tmp/wordpress-nightly/ + mv /tmp/wordpress-nightly/wordpress/* $WP_CORE_DIR + else + if [ $WP_VERSION == 'latest' ]; then + local ARCHIVE_NAME='latest' + else + local ARCHIVE_NAME="wordpress-$WP_VERSION" + fi + download https://wordpress.org/${ARCHIVE_NAME}.tar.gz /tmp/wordpress.tar.gz + tar --strip-components=1 -zxmf /tmp/wordpress.tar.gz -C $WP_CORE_DIR + fi + + download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php +} + +install_test_suite() { + # portable in-place argument for both GNU sed and Mac OSX sed + if [[ $(uname -s) == 'Darwin' ]]; then + local ioption='-i .bak' + else + local ioption='-i' + fi + + # set up testing suite if it doesn't yet exist + if [ ! -d $WP_TESTS_DIR ]; then + # set up testing suite + mkdir -p $WP_TESTS_DIR + svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes + fi + + cd $WP_TESTS_DIR + + if [ ! -f wp-tests-config.php ]; then + download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php + fi + +} + +install_db() { + # parse DB_HOST for port or socket references + local PARTS=(${DB_HOST//\:/ }) + local DB_HOSTNAME=${PARTS[0]}; + local DB_SOCK_OR_PORT=${PARTS[1]}; + local EXTRA="" + + if ! [ -z $DB_HOSTNAME ] ; then + if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then + EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" + elif ! [ -z $DB_SOCK_OR_PORT ] ; then + EXTRA=" --socket=$DB_SOCK_OR_PORT" + elif ! [ -z $DB_HOSTNAME ] ; then + EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" + fi + fi + + # create database + mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA +} + +install_wp +install_test_suite +install_db diff --git a/docs/README.md b/docs/README.md index 0b220dedba..ff989fd3e3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,10 +16,10 @@ Dive in deeper into the [schema details][schema] to better understand the little details, or read about the [philosophy][] behind them. Read about the [implementation details][implementation] on how the API works internally. -[Getting Started]: guides/getting-started.md -[guides]: guides/README.md -[post-routes]: routes/posts.md -[media-routes]: routes/media.md +[Getting Started]: http://wp-api.org/guides/getting-started.html +[guides]: http://wp-api.org/guides.html +[post-routes]: http://wp-api.org/#posts +[media-routes]: http://wp-api.org/#media [compatibility]: compatibility.md [schema]: schema.md [philosophy]: internals/philosophy.md diff --git a/docs/compatibility.md b/docs/compatibility.md deleted file mode 100644 index f51865baeb..0000000000 --- a/docs/compatibility.md +++ /dev/null @@ -1,41 +0,0 @@ -Notes -===== - -### Inputting data as an "array" -Endpoints may allow passing in an array of data, typically used for querying -entities. URL semantics do not specify how to pass this, however the convention -used by PHP and this API is to pass them with the array name concatenated with -the key name in square brackets. - -For example: - - filter[post_status]=draft&filter[s]=foo - - -### JSON data input -Some posts allow directly passing JSON data (usually an entity) via the request -body. These should be specified with a Content-Type header of `application/json` -although individual endpoints may prefer more specific types. - -If your client platform does not support native JSON encoding, the data can be -submitted via a regular HTTP multipart body, with properties set as values to -the `data` parameter. - -That is, the following are equivalent: - -Content-Type: application/x-www-form-urlencoded - - data[post_title]=Hello%20World!&data[post_content]=Content - - -Content-Type: application/json - - {"post_title":"Hello World!","post_content":"Content"} - - -### HTTP method compatibility -Due to their relatively new nature, some methods such as PATCH may not be -supported by client software. To emulate support for this, a `_method` parameter -may be passed via the URL with the value set to a valid HTTP method (DELETE, -GET, HEAD, PATCH, POST, PUT, DELETE). Note that this must be passed via the URL -and cannot be passed in the HTTP body. diff --git a/docs/guides/README.md b/docs/guides/README.md deleted file mode 100644 index d3559409dc..0000000000 --- a/docs/guides/README.md +++ /dev/null @@ -1,15 +0,0 @@ -Guides -====== -So, you want to learn about the API? Where better to start than with the guides. -Each guide will walk you through interacting with the relevant part of the API -and mention tips and tricks for using it. - -* [Getting Started][]: Begin working with the API, and learn how to access basic - post data. -* [Working with Posts][]: Explore more advanced usage of post-related data, - including filtering and additional data. -* [Extending the API][]: Create your own API routes and endpoints in a plugin. - -[Getting Started]: getting-started.md -[Working with Posts]: working-with-posts.md -[Extending the API]: extending.md diff --git a/docs/guides/extending.md b/docs/guides/extending.md deleted file mode 100644 index 8f6124a5af..0000000000 --- a/docs/guides/extending.md +++ /dev/null @@ -1,352 +0,0 @@ -Extending the API -================= - -If you're a plugin author, you're probably itching to start adding functionality -to the API, including working with custom post types. I'm here to guide you -through the process of extending the API with your data, and to remind you of -some important points. - - -Before We Start ---------------- -This guide assumes that you have a fairly decent knowledge of the API already. -Before starting, make sure you've read the [Getting Started][] guide, and if you -want to work with custom post types, also read the [Working With Posts][] guide. - -You should also have a pretty good knowledge of working with actions and filters -in WordPress, as well as how plugins work in general. - -[Getting Started]: getting-started.md -[Working with Posts]: working-with-posts.md - - -A Philosophy Lesson -------------------- -Right off the bat, let me just say that the REST API is not designed for every -use under the sun. Following the mantra of [Decisions, not Options][decisions], -the API is opinionated and designed for the most common use case. You may find -that what you're trying to do doesn't fit in with the REST philosophy, and -**that's fine**; the low-level `admin-ajax.php` handler is -[still available][admin-ajax] for you to use in these scenarios, and you can -always roll your own API. An example of an API which doesn't fit with this API -is the heartbeat API; heartbeats aren't inherently designed around resources, -but events, so are a poor fit for a REST API. - -That said, evaluate whether it's better to rethink your structure in terms of -resources, or to roll your own API. In most cases, thinking about data in terms -of resources rather than events or actions will not only fit better with the -API, but also make for cleaner and more maintainable code. For example, although -publishing a post is an action, it's equivalent to updating a post's status. - -Keep in mind that since the REST API is opinionated, you will have to work with -the API's structure rather than rolling your own. The API is strict about how -you talk to the client, and you should always use the built-in handlers rather -than rolling your own. This strict inforcement of data handling helps to make -the API more pluggable and extensible, plus ensures that you play nice with -other plugins. - -[decisions]: http://wordpress.org/about/philosophy/ -[admin-ajax]: http://codex.wordpress.org/AJAX_in_Plugins - - -Designing Your Entities ------------------------ -The first step you should take in working with the API is to think about the -data that you're working with. You should always think about the data in terms -of the clients that you're working with; it's your job to handle translation -back and forth. While it might seem easier to just return internal objects -directly, this may expose private data, or data that's harder to handle for -clients; posts internally translate WordPress's date format (YYYY-MM-DD -HH:MM:SS) to the standard RFC3339 which can be parsed by most date parsers. It's -worth your time to consider issues like this which can save clients hours of -work on their end. - -You should also consider how other plugins might extend your data, and design -for this. While things like `comment_status` could become a boolean field, -plugins might add custom statuses. On the other side of this, you should also -consider how these fields should be handled by clients. Custom field values that -a client doesn't understand should have a sensible default handling; for -example, the `comment_status` field is documented as the following: - -> Providers MAY use statuses other than "open" or "closed" to indicate other -> statuses. Consumers who encounter an unknown or missing comment status SHOULD -> treat it as "closed". - -This ensures that while clients may not know what a field value means, they can -still operate in a safe manner with the posts. - - -Designing Your Routes and Endpoints ------------------------------------ -After you've decided what your entities should look like, it's time to think -about how you want clients to access and interact with the data. Picking your -routes is an important part of this, and should be oriented around your data. -Collections of Entities should be a base route, with individual Entities as -subroutes of this. The following is a good general rule: - -* `/`: - * GET returns a collection - * POST adds a new item to the collection -* `//`: - * GET returns an entity - * PUT updates the entity - * DELETE deletes the entity - -(Again, this is the difference between routes and endpoints. There are two -routes here, but with two and three endpoints respectively. In general, a route -maps to an Entity/Collection, whereas an endpoint maps to an action on that -Entity/Collection.) - -Since your data is in your plugin, you should also prefix your routes. For -example, routes for Hello Dolly might look something like: - -* `/hello-dolly`: Get all lines from Hello Dolly -* `/hello-dolly/`: Get the nth line from Hello Dolly -* `/hello-dolly/random`: Get a random line from Hello Dolly - -If you're extending an existing route, you might want to use a flat style of -prefixing. Plugins like Akismet might implement something like: - -* `/posts//comments/akismet-recheck`: Recheck a post's comments for spam - -Note that this is somewhat against the previously discussed philosophy of -organising around resources; sometimes, you might want to have actions -available directly. These should always be a POST endpoint, since you're taking -action on the server in response. You should also avoid this where possible, -although there's no need to go overboard and create things like an Akismet Queue -resource just for a single action. - - -Organizing Your Endpoints -------------------------- -Now that you've got a roadmap for everything you'd like to implement, it's time -to start implementing it. - -The recommended way to organize your endpoints is to group related endpoints -together into a class, and extend the base classes built into the API. -Post-related endpoints should look to extend the `WP_JSON_CustomPostType` class, -and use the same method naming as it, falling back to it for the post -preparation and request handling. This will automatically register all the post -methods for their endpoints. - -Along these lines, keep your methods named as generically as possible; while -`MyPlugin_API_MyType::get_my_type_items()` might seem like a good name, it makes it -harder for other plugins to use; standardising on -`MyPlugin_API_MyType::get_posts()` with similar arguments to the parent is a -better idea and allows a nicer fall-through. - -You should also aim to keep these related endpoints modular, and make liberal -use of filters for this. As an example of this, the built-in Posts API is -filtered by the Media API to add featured image-related data, ensuring that -there's no dependencies between the two and either can be enabled independently. - - -Creating Your Endpoints ------------------------ -Before you actually write the code, there's some important things to know about -how you write the code. (Remember the opinions I mentioned before? This -is them.) - -Each endpoint should be written in the same way you'd write an internal function -for your plugin. That is, they take parameters, and return data. They also -*never* read directly from request globals (such as `$_GET` or `$_POST`), but -instead read them via the parameters to your function. The REST API serving code -automatically maps GET and POST parameters to your function's parameters (along -with some special request-related parameters). This concept might seem a little -strange at first, but it helps to ensure pluggability and standardisation of -errors. - -For example, an endpoint that takes a required `context` parameter, an optional -`type` parameter and uses the `X-WP-Example` header would look like this: - - function get_my_data( $context, $_headers, $type = 'my-default-value' ) { - if ( isset( $_headers['X-WP-EXAMPLE'] ) ) { - $my_header_value = $_headers['X-WP-EXAMPLE']; - } - - if ( $type !== 'my-default-value' && $type !== 'some-other-value' ) { - return new WP_Error( 'myplugin_mydata_invalid_type', __( 'Invalid type.' ), array( 'status' => 400 ) ); - } - - // ... - - return array( /* ... */ ); - } - -Note that this is intentionally the same way you'd write a function for internal -consumption; default parameter values are specified as default argument values, -errors are returned as a WP_Error object (with the special `status` field in the -data set to an appropriate HTTP status), and the data itself being returned from -the function. - -The following special values are also available, and can be trusted (always -internal data, not overridable by clients): - -* `_method`: The requested HTTP method (`GET`, `HEAD`, `POST`, `PUT`, `DELETE`) -* `_route`: The route followed to your endpoint (`/posts/(?P\d+)`) -* `_path`: The actual path that matches your route (`/posts/1`) -* `_headers`: An associative array of header names to values. Names are always - uppercased (HTTP header names are case-insensitive) -* `_files`: An associative array of upload file data, in the same format - as `$_FILES` - -The special `data` parameter is also available if your endpoint is set to -receive JSON or raw data. Note that this can be given by clients either via the -HTTP body or via the `data` query parameter, which is intentional for backwards -compatibility. That is, the following requests are the same: - - Content-Type: application/json; charset=utf-8 - - { - "foo": "bar", - "hello": "dolly" - } - - ---- - - Content-Type: application/x-www-form-urlencoded - - data[foo]=bar&data[hello]=dolly - -The return value of your endpoints should be an error object with the status -field set to an [appropriate value][http-status-codes], or the relevant data. -The data itself can be any JSON-serializable value: an integer, string, array, -object or null. In practice, this is almost always an array (for Collections) or -an object (for Entities). - -Return headers can be set via the `WP_JSON_Server::header()` function, or -similar helper functions (`WP_JSON_Server::send_status()`, -`WP_JSON_Server::link_header()`, `WP_JSON_Server::query_navigation_headers()`) -called on the global `$wp_json_server` object, but should *never* be set via the -direct `header()` or `status_header()` functions. - -[http-status-codes]: http://en.wikipedia.org/wiki/List_of_HTTP_status_codes - - -Registering Your Endpoints --------------------------- -Now that we've done the bulk of the work, it's time to tell WordPress that we -have an API to register. If you're following the same structure as the API's -built-in types, your registration code should look something like this: - - function myplugin_api_init() { - global $myplugin_api_mytype; - - $myplugin_api_mytype = new MyPlugin_API_MyType(); - add_filter( 'json_endpoints', array( $myplugin_api_mytype, 'register_routes' ) ); - } - add_action( 'wp_json_server_before_serve', 'myplugin_api_init' ); - - class MyPlugin_API_MyType { - public function register_routes( $routes ) { - $routes['/myplugin/mytypeitems'] = array( - array( array( $this, 'get_posts'), WP_JSON_Server::READABLE ), - array( array( $this, 'new_post'), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), - ); - $routes['/myplugin/mytypeitems/(?P\d+)'] = array( - array( array( $this, 'get_post'), WP_JSON_Server::READABLE ), - array( array( $this, 'edit_post'), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), - array( array( $this, 'delete_post'), WP_JSON_Server::DELETABLE ), - ); - - // Add more custom routes here - - return $routes; - } - - // ... - } - -You will need to implement the getPost, editPost, getPosts, and newPost methods within your new class. Take a look at the WP_JSON_Posts class to see examples of how these methods can be written. - -Alternatively, use the custom post type base class, which will handle the -hooking and more for you: - - // main.php - function myplugin_api_init( $server ) { - global $myplugin_api_mytype; - - require_once dirname( __FILE__ ) . '/class-myplugin-api-mytype.php'; - $myplugin_api_mytype = new MyPlugin_API_MyType( $server ); - } - add_action( 'wp_json_server_before_serve', 'myplugin_api_init' ); - - // class-myplugin-api-mytype.php - class MyPlugin_API_MyType extends WP_JSON_CustomPostType { - protected $base = '/myplugin/mytypeitems'; - protected $type = 'myplugin-mytype'; - - public function register_routes( $routes ) { - $routes = parent::register_routes( $routes ); - // $routes = parent::register_revision_routes( $routes ); - // $routes = parent::register_comment_routes( $routes ); - - // Add more custom routes here - - return $routes; - } - - // ... - } - -(Note that this CPT base class handles other things as well, including strict -post type checking and correcting URLs.) - -It is important that this class lives in a separate file that is only included -on a WP API hook, as your plugin may load before the WP API plugin. If you get -errors about the `WP_JSON_CustomPostType` class not being loaded, this is -the reason. - -The data passed in and returned by the `json_endpoints` filter should be in the -format of an array containing a map of route regular expression to endpoint -lists. An endpoint list is a potentially unlimited array of endpoints' data. -Endpoint data is a two-element array, with the first element being the endpoint -callback, and the second element specifying options. In other words, the format -is: - - $routes['regex expression'] = array( - // one or more: - array( - 'callback_function', - 0 // bitwise options flag - ) - ); - -(Where possible, use named parameters of the form `(?P...)` for variables -in your route, as these will be automatically replaced with a more friendly -version in the index.) - -The available bitwise options are: - -* `WP_JSON_Server::READABLE`: Read endpoint (responds to GET) -* `WP_JSON_Server::CREATABLE`: Creation endpoint (responds to POST) -* `WP_JSON_Server::EDITABLE`: Edit endpoint (responds to POST/PUT/PATCH) -* `WP_JSON_Server::DELETABLE`: Deletion endpoint (responds to DELETE) -* `WP_JSON_Server::ALLMETHODS`: Generic endpoint (responds to - GET/POST/PUT/PATCH/DELETE) -* `WP_JSON_Server::ACCEPT_RAW`: Accepts raw data in the HTTP body -* `WP_JSON_Server::ACCEPT_JSON`: Accepts JSON data in the HTTP body - (automatically deserialized by the server) -* `WP_JSON_Server::HIDDEN_ENDPOINT`: Hide the endpoint from the index - -Once you've done this, you should now go and check that your route is appearing -in the index. Whip out a HTTP client and test out that your API endpoints are -working correctly. - - -Next Steps ----------- -You should now have a working API built with the REST API. Congratulations! -You now have a mastery of how the API works, so get out there and start -expanding your API. You should consider contributing back to the core API and -improving the built-in APIs. - -* [API Philosophy][]: Learn more about the design of the API and the decisions - made in the core. -* [Schema][]: Read up on the schema for the core API and use it to inspire your - own entity design. -* [Internal Implementation][]: Learn about how the REST server works internally. - -[API Philosophy]: ../internals/philosophy.md -[Schema]: ../schema.md -[Internal Implementation]: ../internals/implementation.md diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md deleted file mode 100644 index 8eb83e070f..0000000000 --- a/docs/guides/getting-started.md +++ /dev/null @@ -1,433 +0,0 @@ -Getting Started -=============== - -Hi! If you're reading this, you're probably wondering how to get started using -the REST API. In that case, you've come to the right place! I'm about to show -you the basics of the API from the ground up. - -(If you're in the wrong place, there's not much I can do to help. Sorry!) - - -Before We Start ---------------- -To interact with the API, you're going to need some tools. The first of these -you need is a HTTP client/library. These examples use command-line cURL, but any -HTTP client would do here. - -The next tool you need is a JSON parser, and generator if you're sending data. -I'm going to do this by hand for the command-line examples, but it's recommended -to use a proper serializer/deserializer in your programming language of choice. -That said, it's not a bad idea to know how to read and write JSON by hand. - - -Checking for the API --------------------- -As our first command, let's go ahead and check the API index. The index tells us -what routes are available, and a short summary about the site. - -The index is always available at the site's address with `/wp-json/` on the -end. Note the trailing slash there; it indicates that we want to access the `/` -route. My test site is set up at `http://example.com/`, so all my routes will -start with `http://example.com/wp-json` followed by the route (which here is -just `/`). - -Let's fire off the request: - - curl -i http://example.com/wp-json/ - -(By the way, `-i` tells cURL that we want to see the headers as well. I'll strip -some irrelevant ones for this documentation.) - -And here's what we get back: - - HTTP/1.1 200 OK - Content-Type: application/json; charset=UTF-8 - - { - "name": "My WordPress Site", - "description": "Just another WordPress site", - "URL": "http:\/\/example.com", - "routes": { - "\/": { - "supports": [ - "HEAD", - "GET" - ], - "meta": { - "self": "http:\/\/example.com\/wp-json\/" - } - }, - "\/posts": { - "supports": [ - "HEAD", - "GET", - "POST" - ], - "meta": { - "self": "http:\/\/example.com\/wp-json\/posts" - }, - "accepts_json": true - }, - "\/posts\/": { - "supports": [ - "HEAD", - "GET", - "POST", - "PUT", - "PATCH", - "DELETE" - ], - "accepts_json": true - }, - "\/posts\/\/revisions": { - "supports": [ - "HEAD", - "GET" - ] - }, - "\/posts\/\/comments": { - "supports": [ - "HEAD", - "GET", - "POST" - ], - "accepts_json": true - }, - "\/posts\/\/comments\/": { - "supports": [ - "HEAD", - "GET", - "POST", - "PUT", - "PATCH", - "DELETE" - ], - "accepts_json": true - }, - "\/posts\/types": { - "supports": [ - "HEAD", - "GET" - ], - "meta": { - "self": "http:\/\/example.com\/wp-json\/posts\/types" - } - }, - "\/posts\/types\/": { - "supports": [ - "HEAD", - "GET" - ] - }, - "\/posts\/statuses": { - "supports": [ - "HEAD", - "GET" - ], - "meta": { - "self": "http:\/\/example.com\/wp-json\/posts\/statuses" - } - }, - "\/taxonomies": { - "supports": [ - "HEAD", - "GET" - ], - "meta": { - "self": "http:\/\/example.com\/wp-json\/taxonomies" - } - }, - "\/taxonomies\/": { - "supports": [ - "HEAD", - "GET", - "POST", - "PUT", - "PATCH", - "DELETE" - ], - "accepts_json": true - }, - "\/taxonomies\/\/terms": { - "supports": [ - "HEAD", - "GET", - "POST" - ], - "accepts_json": true - }, - "\/taxonomies\/\/terms\/": { - "supports": [ - "HEAD", - "GET", - "POST", - "PUT", - "PATCH", - "DELETE" - ], - "accepts_json": true - }, - "\/users": { - "supports": [ - "HEAD", - "GET", - "POST" - ], - "meta": { - "self": "http:\/\/example.com\/wp-json\/users" - }, - "accepts_json": true - }, - "\/users\/me": { - "supports": [ - "HEAD", - "GET", - "POST", - "PUT", - "PATCH", - "DELETE" - ], - "meta": { - "self": "http:\/\/example.com\/wp-json\/users\/me" - } - }, - "\/users\/": { - "supports": [ - "HEAD", - "GET", - "POST" - ], - "accepts_json": true - } - }, - "meta": { - "links": { - "help": "https:\/\/github.com\/rmccue\/WP-API", - "profile": "https:\/\/raw.github.com\/rmccue\/WP-API\/master\/docs\/schema.json" - } - } - } - -Wow, that's a lot of data! Let's break it down. - -First, let's look at the headers. We get a 200 status code back, indicating that -we can successfully get the index. Note that it's possible for sites to disable -the API, which would give us a 404 error. You can check the returned body to -find out if the API exists at all; a disabled site will give you a JSON object -with the `json_disabled` error code, while a site without the API at all will -probably give you an error from the theme. - -Next, we can see the `name`, `description` and `URL` fields. These are intended -to be used if you want to show the site's name to users, such as in settings -dialogs. - -The `routes` object contains the meat of the body. This object has a bunch of -keys with "routes" (templates for creating URLs) pointing to objects containing -data (this data tells you about the "endpoint"). Each of these objects has at -least a `supports` key that contains a list of the HTTP methods supported. The -`meta` key has a hyperlink to the route, while the `accepts_json` key will also -be set and be `true` if you can POST/PUT JSON directly to the endpoint. - -You might notice that some of the routes have a `` part in them. This -is a variable part, and it tells you that you can replace it with something, -such as an object ID. - - -Routes vs Endpoints -------------------- -Quick note on terminology: a "route" is a URL that gets passed into the API; -these look like `/posts` or `/posts/2` and can be written in a generic form as -`/some/part/`. An "endpoint" on the other hand is the handler -that actually does something with your request. - -For example, the `/posts` route has two endpoints: the Retrieve Posts endpoint -to handle GET requests, and the Create Post endpoint to handle POST requests. - - -Meta Objects ------------- -You'll also see in the response that we have a `meta` object at the top level, -plus a bunch of smaller ones for the routes. Meta objects are mappings of [link -relations][] to their corresponding URL. These URLs are usually internal URLs to -help you browse the API, and can be used by clients embracing the [HATEOAS][] -methodology. - -[link relations]: http://www.iana.org/assignments/link-relations/link-relations.xhtml -[HATEOAS]: http://en.wikipedia.org/wiki/HATEOAS - -Here's some common relations you'll see: - -* `self`: The canonical URL for the resource. This is usually most helpful in - embedded objects (post parent, author, etc) -* `collection`: The URL for the collection containing the resource. The endpoint - at this route usually gives a list, of which, the current resource is a - member (for example, `/posts/1` would have a collection of `/posts`) -* `archive`: The URL for the collection owned by this resource (for example, - posts written by an author) -* `replies`: Responses to a resource (for example, comments on a post, replies - to a comment) - - -Getting Posts -------------- -Now that we understand some of the basics, let's have a look at the posts route. -All we need to do is send a GET request to the posts endpoint. - - curl -i http://example.com/wp-json/posts - -And this time, we get (again trimming headers, you'll have more than this): - - HTTP/1.1 200 OK - Last-Modified: Wed, 31 Oct 2012 18:26:17 GMT - Link: ; rel="item"; title="Hello world!" - - [ - { - "ID": 1, - "title": "Hello world!", - "status": "publish", - "type": "post", - "author": { - "ID": 1, - "name": "admin", - "slug": "admin", - "URL": "", - "avatar": "http:\/\/0.gravatar.com\/avatar\/c57c8945079831fa3c19caef02e44614&d=404&r=G", - "meta": { - "links": { - "self": "http:\/\/example.com\/wp-json\/users\/1", - "archives": "http:\/\/example.com\/wp-json\/users\/1\/posts" - } - } - }, - "content": "Welcome to WordPress. This is your first post. Edit or delete it, then start blogging!", - "parent": 0, - "link": "http:\/\/example.com\/2012\/10\/31\/hello-world\/", - "date": "2012-10-31T18:26:17+10:00", - "modified": "2012-10-31T18:26:17+10:00", - "format": "standard", - "terms": { - "category": { - "ID": 1, - "name": "Uncategorized", - "slug": "uncategorized", - "group": 0, - "parent": 0, - "count": 1, - "meta": { - "links": { - "collection": "http:\/\/example.com\/wp-json\/taxonomy\/category", - "self": "http:\/\/example.com\/wp-json\/taxonomy\/category\/terms\/1" - } - } - } - }, - "meta": { - "links": { - "self": "http:\/\/example.com\/wp-json\/posts\/1", - "author": "http:\/\/example.com\/wp-json\/users\/1", - "collection": "http:\/\/example.com\/wp-json\/posts", - "replies": "http:\/\/example.com\/wp-json\/posts\/1\/comments", - "version-history": "http:\/\/example.com\/wp-json\/posts\/1\/revisions" - } - } - } - ] - -Hopefully this looks fairly self-explanatory. For a full look at all the fields -you can get back from this, take a look at [Working with Posts][] or the -[definition][schema] for the Post entity. - -You'll notice that in this case we're getting a list back with just a single -item, one Post. If you have two Posts here, you'll get back two, and so on, up -to 10. If there are more than 10, pagination will kick in and you'll have to -handle this in your client. - -Pagination details are given in the HTTP headers from the endpoint. The -`X-WP-Total` header has the total number of posts available to you, while the -`X-WP-TotalPages` header has the number of pages. While you can build the page -URL manually, you should use the `next` and `prev` links from the `Link` header -where possible. - -Example of pagination headers: - - X-WP-Total: 492 - X-WP-TotalPages: 50 - Link: ; rel="next", - ; rel="prev" - -If you want to grab a single post, you can instead send a GET request to the -post itself. You can grab the URL for this from the `meta.links.self` field, or -construct it yourself (`/posts/`): - - curl -i http://example.com/wp-json/posts/1 - - -Editing and Creating Posts --------------------------- -Just as we can use a GET request to get a post, we can use PUT to edit a post. -The easiest way to do this is to send a JSON body back to the server with just -the fields you want to change. For example, to edit the title and the -modification date: - - { - "title": "Hello Updated World!", - "modified": "2013-04-01T14:00:00+10:00" - } - -Save the data as "updated-post.json", then we can send this to the server with -the correct headers and authentication. The API uses HTTP Basic authentication: - - curl --data-binary="@updated-post.json" \ - -H "Content-Type: application/javascript" \ - --user admin:password \ - http://example.com/wp-json/posts/1 - -And we should get back a 200 status code, indicating that the post has been -updated, plus the updated Post in the body. - -Note that there are some fields we can't update; ID is an obvious example, but -others like timezone fields cannot be updated either. Check the [schema][] for -more details on this. - -Similarly to editing posts, you can create posts. We can use the same data from -before, but this time, we POST it to the main posts route. - - curl --data-binary="@updated-post.json" \ - -H "Content-Type: application/javascript" \ - --user admin:password \ - http://example.com/wp-json/posts - -We should get a similar response to the editing endpoint, but this time we get -a 201 Created status code, with a Location header telling us where to access the -post in future: - - HTTP/1.1 201 Created - Location: http://example.com/wp-json/posts/2 - -Finally, we can clean this post up and delete it by sending a DELETE request: - - curl -X DELETE --user admin:password http://example.com/wp-json/posts/2 - -In general, routes follow the same pattern: - -* `/`: - * GET returns a collection - * POST adds a new item to the collection -* `//`: - * GET returns an entity - * PUT updates the entity - * DELETE deletes the entity - -Next Steps ----------- -You should now be able to understand the basics of accessing and creating data -via the API, plus have the ability to apply this to posts. We've only really -touched on the post-related data so far, and there's plenty more to the API, so -get exploring! - -* [Working With Posts][]: Learn more about Posts and their data -* [Schema][schema]: View technical information on all the available data - -[Working with Posts]: working-with-posts.md -[schema]: ../schema.md diff --git a/docs/guides/working-with-posts.md b/docs/guides/working-with-posts.md deleted file mode 100644 index 0d16603469..0000000000 --- a/docs/guides/working-with-posts.md +++ /dev/null @@ -1,287 +0,0 @@ -Working with Posts -================== -Back in the [Getting Started][] guide we used posts to demonstrate how to work -with the API, but only touched on some of the details. Let's take a more -detailed look at the Post API. - - -Before We Start ---------------- -This guide assumes you have a basic knowledge of the API, as well as the -prerequisites noted in the [Getting Started] guide. If you haven't already, make -sure you read that guide to ensure you know the basics. - -This guide also assumes that you know how to send requests given how to use -them, so the examples will be HTTP requests. I recommend reading the cURL manual -or using a higher level tool if you don't know how to wrangle cURL. - -The examples also pretend that your JSON base URL (`wp-json` in the main WP -directory) is located at `/`, which is probably not the case. For example, if -your base URL is `http://example.com/wp-json` and the example request is -`GET /posts`, you should should actually send the following: - - GET /wp-json/posts HTTP/1.1 - Host: example.com - -Higher level HTTP clients can usually handle this for you. - - -Post Entities and Collections ------------------------------ -As mentioned previously, we can send a GET request to the main posts route to -get posts: - - GET /posts HTTP/1.1 - -This returns a list of Post objects. From a technical point of view, each object -is called an Entity (in this case, Post Entities), while the endpoint returns a -Collection (here, a Post Collection). - -Post Entities have certain [defined properties][schema], although which are set -depend on the context. For example, with embedded entities certain properties -may have different values; while the post parent entity is embedded, the parent -field inside this is only the ID. - - -Collection Views ----------------- -Post Collections can be modified by using various query string parameters. One -that you may already know is the `page` parameter. This parameter is used for -pagination, and while you can set it manually, it's also sent via the Link -headers to point you to the next and previous pages. A `context` parameter is -also available that we'll talk about later. - -You can also change the post type via the `type` parameter. It's recommended -that you check the [available post types](#custom-post-types) before doing this, -as plugins may change what's available. - -The last parameter is the `filter` parameter. This gives you full access to the -[WP_Query][] parameters, to alter the query to your liking. Depending on the -level of access you have, not all parameters will be available, so check the -[schema][] for the available parameters. A good assumption to make is that -anything you can put in a query on the site itself (such as `?s=...` for -searches) will be available. You can specify filter parameters in a request -using [array-style URL formatting][]. - - -Creating and Editing Posts --------------------------- -The main posts route also has a creation endpoint: - - POST /posts HTTP/1.1 - Content-Type: application/json; charset=utf-8 - - { - "title": "My Post Title" - } - -Here we send a Post entity that we've created locally to the server. These Post -entities are the same as Post entities served up by the server, but excluding -some fields which are immutable. For example, the ID and timezone fields cannot -be changed. - -When editing posts, it's helpful to pass the `edit` context along with your -request. This gives you extra data useful for editing, such as the unfiltered -content and title. This is not required, however it is recommended, as the -normal content and title fields have been filtered to correct HTML (such as via -the `wpautop` function), making them less than optimal for editing. - -You can also pass an `If-Unmodified-Since` header when editing posts with the -previous modification date to ensure that you don't accidentally overwrite edits -made in the meantime. Note that while dates in the `modified` field of the post -are in [RFC3339][] format, this header requires the use of [RFC1123][] (similar -to [RFC822][]). (Sorry about that, but the HTTP standard requires it.) - -[RFC3339]: http://tools.ietf.org/html/rfc3339 -[RFC1123]: http://tools.ietf.org/html/rfc1123 -[RFC822]: http://tools.ietf.org/html/rfc822 - - -Custom Post Types ------------------ -Custom post types can be queried via the main post routes (`/posts` and -children) when they have been made public. The type can be set via the `type` -query parameter. - -Before working with custom post types, you should first check that the API has -support for the post type that you want to work with. This data is available via -the read-only APIs at `/posts/types`: - - GET /posts/types HTTP/1.1 - -This should return a list of the available types: - - { - "post": { - "name": "Posts", - "slug": "post", - "description": "", - "labels": { - "name": "Posts", - "singular_name": "Post", - "add_new": "Add New", - "add_new_item": "Add New Post", - "edit_item": "Edit Post", - "new_item": "New Post", - "view_item": "View Post", - "search_items": "Search Posts", - "not_found": "No posts found.", - "not_found_in_trash": "No posts found in Trash.", - "parent_item_colon": null, - "all_items": "All Posts", - "menu_name": "Posts", - "name_admin_bar": "Post" - }, - "queryable": true, - "searchable": true, - "hierarchical": false, - "meta": { - "links": { - "self": "http:\/\/example.com\/wp-json\/posts\/types\/post", - "archives": "http:\/\/example.com\/wp-json\/posts" - } - } - }, - "page": { - "name": "Pages", - "slug": "page", - "description": "", - "labels": { - "name": "Pages", - "singular_name": "Page", - "add_new": "Add New", - "add_new_item": "Add New Page", - "edit_item": "Edit Page", - "new_item": "New Page", - "view_item": "View Page", - "search_items": "Search Pages", - "not_found": "No pages found.", - "not_found_in_trash": "No pages found in Trash.", - "parent_item_colon": "Parent Page:", - "all_items": "All Pages", - "menu_name": "Pages", - "name_admin_bar": "Page" - }, - "queryable": false, - "searchable": true, - "hierarchical": true, - "meta": { - "links": { - "self": "http:\/\/example.com\/wp-json\/posts\/types\/page" - } - } - }, - "attachment": { - "name": "Media", - "slug": "attachment", - "description": "", - "labels": { - "name": "Media", - "singular_name": "Media", - "add_new": "Add New", - "add_new_item": "Add New Post", - "edit_item": "Edit Media", - "new_item": "New Post", - "view_item": "View Attachment Page", - "search_items": "Search Posts", - "not_found": "No posts found.", - "not_found_in_trash": "No posts found in Trash.", - "parent_item_colon": null, - "all_items": "Media", - "menu_name": "Media", - "name_admin_bar": "Media" - }, - "queryable": true, - "searchable": true, - "hierarchical": false, - "meta": { - "links": { - "self": "http:\/\/example.com\/wp-json\/posts\/types\/attachment", - "archives": "http:\/\/example.com\/wp-json\/posts?type=attachment" - } - } - } - } - -The `meta.links.archives` value gives a nicer way to access given post type -archives for HATEOAS supporters and should always be used rather than manually -setting the `type` parameter directly, as CPTs may create their own route -structure instead. The `labels` fields should also always be used when -displaying the field, as these are run through WordPress' translations. - -A similar API exists for post statuses at `/posts/statuses`: - - { - "publish": { - "name": "Published", - "slug": "publish", - "public": true, - "protected": false, - "private": false, - "queryable": true, - "show_in_list": true, - "meta": { - "archives": "http:\/\/example.com\/wp-json\/posts" - } - }, - "future": { - "name": "Scheduled", - "slug": "future", - "public": false, - "protected": true, - "private": false, - "queryable": false, - "show_in_list": true, - "meta": [ - ] - }, - "draft": { - "name": "Draft", - "slug": "draft", - "public": false, - "protected": true, - "private": false, - "queryable": false, - "show_in_list": true, - "meta": [ - ] - }, - "pending": { - "name": "Pending", - "slug": "pending", - "public": false, - "protected": true, - "private": false, - "queryable": false, - "show_in_list": true, - "meta": [ - ] - }, - "private": { - "name": "Private", - "slug": "private", - "public": false, - "protected": false, - "private": true, - "queryable": false, - "show_in_list": true, - "meta": [ - ] - } - } - - -Next Steps ----------- -You should now understand more advanced usage of the post-related APIs, and be -able to implement a fully compliant client for the API. You might now want to -take a look at the other APIs, or look at documentation on the specifics. - -* [Schema][schema]: Full documentation of every parameter for the APIs. -* [Extending the API][]: Create your own API endpoints. - -[Getting Started]: getting-started.md -[Extending the API]: extending.md -[schema]: ../schema.md -[WP_Query]: http://codex.wordpress.org/Class_Reference/WP_Query -[array-style URL formatting]: ../compatibility.md#inputting-data-as-an-array diff --git a/docs/internals/implementation.md b/docs/internals/implementation.md deleted file mode 100644 index 278798b96a..0000000000 --- a/docs/internals/implementation.md +++ /dev/null @@ -1,52 +0,0 @@ -Implementation Details -====================== - -Method Naming -------------- -By convention, routing code uses underscore_case for method naming, whereas -endpoints use partialCamelCase. - - -Routing -------- -The routing code is written in such a way to ensure that the input and output -are handled only by this code. - -The majority of state is handled in the `serve_request()` method, which handles -both reading input and serializing the output to JSON. This is responsible for -translating HTTP to an internal representation suitable for dispatching. - -The routing code also operates with global state, as it's intended to handle the -HTTP interactions, which is inherently global in PHP. - -The `dispatch()` method handles matching the requested route with an endpoint. -This method also deals with global state for GET and POST parameters, however -this is not the optimal situation, as it requires reimplementation of the -routing code when embedding the API. - -This could possibly be optimized by performing straight string matching for the -static portion of the URL. Symfony uses this internally in their routing, -although they don't allow arbitrary regular expressions for routes. - - -Parameters ----------- -Endpoints take parameters as direct function arguments. This fits in with the -philosophy that the endpoints should match up with functions fairly directly. -This means that in order to write an endpoint, all you need to know is how to -write a function. - -This uses the Reflection API to match provided parameters up with the function's -parameters in the correct order, and with default values as needed. The -performance of this API is anecdotally not a real consideration, however -benchmarks are yet to be taken in a rigorous manner. - -Intentionally, it is not possible to get all supplied parameters. This is -regarded as a bad API smell, as each parameter should be fully documented for -consumers. It is possible to construct hacks around this using the -`json_dispatch_args` filter, but this is intentionally made to feel hacky. - -Some parameters which give information about the context of the call. These are -prefixed with an underscore, and are *always* set. This ensures that a rogue -consumer can't pass in the internal name to override it and possibly cause a -security issue. diff --git a/docs/internals/philosophy.md b/docs/internals/philosophy.md deleted file mode 100644 index d5ff7ae09f..0000000000 --- a/docs/internals/philosophy.md +++ /dev/null @@ -1,72 +0,0 @@ -Philosophy -========== - -Rules ------ - -### Rule 1: Avoid writing global state - - For example, the routing code is responsible for handling all input and output, - so direct calls from methods to functions such as `header()` or `echo` should - be avoided completely. - -### Rule 2: Endpoints must be embeddable - - Endpoints should be able to call each other without worrying about global state. - This follows from Rule 1, in that global state must be avoided for this, however - this also means that endpoints can be expected to be called internally. - -### Rule 3: Explict is better than implicit - - You should never have to guess where a variable came from, or why a method was - called. Routes should have a 1:1 mapping with endpoints and magic catch-all - routes should be avoided. Parameters should always be documented as if the API - was a closed (source) box. - -### Rule 4: Reduce, reuse, recycle - - Reduce the complexity of clients and the server. - - Reuse code where possible. - - Recycle endpoints by building on the existing instead of reinventing. - - -Guidelines ----------- - -### Guideline 1: Endpoints should operate like regular functions - - By using the API, you're essentially using a form of Remote Procedure Call. - Both remote and local calls should look essentially the same while operating - within the convention of REST calls for the remote calls. - - Parameters to endpoints follow this rule, wherein remote parameters are - matched up with defined parameters in the endpoint. More detailed structures - are handled as entities, which have a clearly defined structure in - the specification. - - This follows from Rules 2 and 3. - -### Guideline 2: Endpoints should avoid assuming HTTP/JSON characteristics - - Although the API usually operates over HTTP using JSON, endpoints can safely - assume that the serialization may take place using a different serializer - (such as protobufs or bson) over a different transport (such as ZeroMQ). - - This doesn't mean that endpoints need to reinvent the wheel; HTTP status codes - and header names are used since this is the normal state. However, values - should never be encoded in endpoints for HTTP transport rules or JSON encoding - rules. - - This follows from Rules 1 and 2. - -### Guideline 3: Base responses around entities - - Being explicit with data formats means documenting everything. No one likes - writing documentation constantly, so these data formats should be based around - the concept of reusable entities. - - This simplifies the code and reduces the workload of documentation. - - This follows from Rules 3 and 4. diff --git a/docs/internals/release-process.md b/docs/internals/release-process.md deleted file mode 100644 index b75f72716e..0000000000 --- a/docs/internals/release-process.md +++ /dev/null @@ -1,29 +0,0 @@ -Release Process -=============== -Here's the process used to generate new releases for the API plugin. - -* Write changelog items for this release. Generate these automatically with the - following script: - - SINCE=$1 - if [[ -z $SINCE ]]; then - echo "usage: changelog " - exit 1 - fi - - OUTPUT=$(git log $SINCE... --reverse --format="format:- %w(80,0,4)%B") - OUTPUT=$(echo "$OUTPUT" | awk -v nlines=2 "/^ *git-svn-id:/ {for (i=0; i - -### Input -#### `fields` -... - -### Response -The response is a Post entity containing the requested Post if available. - - -Edit a Post ------------ - - PUT /posts/ - -For compatibility reasons, this endpoint also accepts the POST and PATCH -methods. Both of these methods have the same behaviour as using PUT. It is -recommended to use PUT if available to fit with REST convention. - -### Input -The supplied data should be a Post object. This data can be submitted via a -regular HTTP multipart body, with Post values set as values to the `data` -parameter, or through a direct JSON body. See the Create Post endpoint for an -example. - -### Response -On a successful update, a 200 OK status is given, indicating the post has been -updated. The updated Post entity is returned in the body. - - -Delete a Post -------------- - - DELETE /posts/ - -### Input -#### `force` -The `force` parameter controls whether the post is permanently deleted or not. -By default, this is set to false, indicating that the post will be sent to an -intermediate storage (such as the trash) allowing it to be restored later. If -set to true, the post will not be able to be restored by the user. - -Default is false. (boolean) - -### Response -On successful deletion, a 202 Accepted status code will be returned, indicating -that the post has been moved to the trash for permanent deletion at a -later date. - -If force was set to true, a 200 OK status code will be returned instead, -indicating that the post has been permanently deleted. \ No newline at end of file diff --git a/docs/schema.md b/docs/routes/routes.md similarity index 51% rename from docs/schema.md rename to docs/routes/routes.md index 1b0d922074..ede70221f4 100644 --- a/docs/schema.md +++ b/docs/routes/routes.md @@ -1,4 +1,714 @@ -Introduction +Posts +===== + +Create a Post +------------- + + POST /posts + + Requires [authentication](http://wp-api.org/guides/authentication.html) + +### Input +The `data` parameter consists of the elements of the Post object to be +created. This data can be submitted via a regular HTTP multipart body, with +the Post keys and values set to the `data` parameter, or through a direct JSON +body. + +That is, the following are equivalent: + +Content-Type: application/x-www-form-urlencoded + + data[title]=Hello%20World!&data[content_raw]=Content&data[excerpt_raw]=Excerpt + + +Content-Type: application/json + + {"title":"Hello World!","content_raw":"Content","excerpt_raw":"Excerpt"} + +The `data` parameter should be an object containing the following key value +pairs: + +* `title` - Title of the post. (string) __*required*__ +* `content_raw` - Full text of the post. (string) __*required*__ +* `excerpt_raw` - Text for excerpt of the post. (string) *optional* +* `name` - Slug of the post. (string) *optional* +* `status` - Post status of the post: `draft`, `publish`, `pending`, `future`, + `private`, or any custom registered status. If providing a status of + `future`, you must specify a `date` in order for the post to be published as + expected. Default is `draft`. (string) *optional* +* `type` - Post type of the post: `post`, `page`, `link`, `nav_menu_item`, or + a any custom registered type. Default is `post`. (string) *optional* +* `date` - Date and time the post was, or should be, published in local time. + Date should be an RFC3339 timestamp](http://tools.ietf.org/html/rfc3339). + Example: 2014-01-01T12:20:52Z. Default is the local date and time. (string) + *optional* +* `date_gmt` - Date and time the post was, or should be, published in UTC time. + Date should be an [RFC3339 timestamp](http://tools.ietf.org/html/rfc3339). + Example: 201401-01T12:20:52Z. Default is the current GMT date and time. + (string) *optional* +* `author` - Author of the post. Author can be provided as a string of the + author's ID or as the User object of the author. Default is current user. + (object \| string) *optional* +* `password` - Password for protecting the post. Default is empty string. + (string) *optional* +* `post_parent` - Post ID of the post parent. Default is 0. (integer) + *optional* +* `post_format` - Format of the post. Default is `standard`. (string) + *optional* +* `menu_order` - The order in which posts specified as the `page` type should + appear in supported menus. Default 0. (integer) *optional* +* `comment_status` - Comment status for the post: `open` or `closed`. + Indicates whether users can submit comments to the post. Default is the + option 'default_comment_status', or 'closed'. (string) *optional* +* `ping_status` - Ping status for the post: `open` or `closed`. Indicates + whether users can submit pingbacks or trackbacks to the post. Default is the + option 'default_ping_status'. (string) *optional* +* `sticky` - Sticky status for the post: `true` or `false`. Default is + `false`. (boolean) *optional* +* `post_meta` - Post meta entries of the post. Post meta should be an array + of one or more Meta objects for each post meta entry. See the Create Meta + for a Post endpoint for the key value pairs. (array) *optional* + + +### Response +On a successful creation, a 201 Created status is given, indicating that the +post has been created. The post is available canonically from the URL specified +in the Location header. + +The new Post entity is also returned in the body for convienience. + +If the client is not authenticated, a 403 Forbidden response is given. + +Retrieve Posts +-------------- +The Posts endpoint returns a Post Collection containing a subset of the site's +posts. + + GET /posts + +### Input +#### `filter` +The `filter` parameter controls the parameters used to query for posts. + +**Note:** Only "public" query variables are available via the API, as not all +query variables are safe to expose. "Private" query variables are also available +when authenticated as a user with `edit_posts`. Other query variables can be +registered via the `query_vars` filter, or `json_query_vars` for API-specific +query variables. + +Extended documentation on the query variables is available from +[the codex](http://codex.wordpress.org/Class_Reference/WP_Query). + +The following query variables are available to the API: + +* `m` +* `p` +* `posts` +* `w` +* `cat` +* `withcomments` +* `withoutcomments` +* `s` +* `search` +* `exact` +* `sentence` +* `calendar` +* `page` +* `paged` +* `more` +* `tb` +* `pb` +* `author` +* `order` +* `orderby` +* `year` +* `monthnum` +* `day` +* `hour` +* `minute` +* `second` +* `name` +* `category_name` +* `tag` +* `feed` +* `author_name` +* `static` +* `pagename` +* `page_id` +* `error` +* `comments_popup` +* `attachment` +* `attachment_id` +* `subpost` +* `subpost_id` +* `preview` +* `robots` +* `taxonomy` +* `term` +* `cpage` +* `post_type` +* `posts_per_page` + +In addition, the following are available when authenticated as a user with +`edit_posts`: + +* `offset` +* `posts_per_archive_page` +* `showposts` +* `nopaging` +* `post_type` +* `post_status` +* `category__in` +* `category__not_in` +* `category__and` +* `tag__in` +* `tag__not_in` +* `tag__and` +* `tag_slug__in` +* `tag_slug__and` +* `tag_id` +* `post_mime_type` +* `perm` +* `comments_per_page` +* `post__in` +* `post__not_in` +* `post_parent` +* `post_parent__in` +* `post_parent__not_in` + +``` +GET /posts?filter[posts_per_page]=8&filter[order]=ASC +``` + +#### `context` +The `context` parameter controls the format of the data to return. See the +Retrieve a Post endpoint for available contexts. + +Default is "view". (string) + + +#### `type` +The `type` parameter specifies the post type to retrieve. This can either be a +string or an array of types. + +Note that arrays are specified using the `[]` URL syntax. e.g. + +``` +GET /posts?type[]=post&type[]=page +``` + +Default is "post". (string) + + +### Response +The response is a Post Collection document containing the requested Posts if +available. + + +Retrieve a Post +--------------- + + GET /posts/ + +### Input +#### `context` +The `context` parameter controls the format of the data to return. The +following contexts are available: + +* `view`: The default context. Gives the normal User entity. +* `edit`: Context used for extra fields relevant to updating a user. Includes + the `title_raw`, `content_raw`, `guid_raw` and `post_meta` fields, suitable + for editing the post. +* `parent`: Context used when embedding the response inside another (e.g. post + author). This is intended as a minimal subset of the user data to reduce + response size. Returns the `parent` field as an ID, rather than an embedded + post, to ensure we don't traverse the entire post hierarchy. + +### Response +The response is a Post entity containing the requested Post if available. The +fields available on the Post depend on the `context` parameter. + + +Edit a Post +----------- + + PUT /posts/ + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +For compatibility reasons, this endpoint also accepts the POST and PATCH +methods. Both of these methods have the same behaviour as using PUT. It is +recommended to use PUT if available to fit with REST convention. + +### Input +The `data` parameter consists of Post ID and the elements of the Post object +to be modified. This data can be submitted via a regular HTTP multipart body, +with the Post keys and values set to the `data` parameter, or through a direct +JSON body. See the Create Post endpoint for an example. + +The `data` parameter should be an object containing the following key value +pairs: + +* `ID` - Unique ID of the post. (integer) __*required*__ +* `title` - Title of the post. (string) __*required*__ +* `content_raw` - Full text of the post. (string) __*required*__ +* `excerpt_raw` - Text for excerpt of the post. (string) *optional* +* `name` - Slug of the post. (string) *optional* +* `status` - Post status of the post: `draft`, `publish`, `pending`, `future`, + `private`, or any custom registered status. If providing a status of + `future`, you must specify a `date` in order for the post to be published as + expected. Default is `draft`. (string) *optional* +* `type` - Post type of the post: `post`, `page`, `link`, `nav_menu_item`, or + a any custom registered type. Default is `post`. (string) *optional* +* `date` - Date and time the post was, or should be, published in local time. + Date should be an RFC3339 timestamp](http://tools.ietf.org/html/rfc3339). + Example: 2014-01-01T12:20:52Z. Default is the local date and time. (string) + *optional* +* `date_gmt` - Date and time the post was, or should be, published in UTC time. + Date should be an [RFC3339 timestamp](http://tools.ietf.org/html/rfc3339). + Example: 201401-01T12:20:52Z. Default is the current GMT date and time. + (string) *optional* +* `author` - Author of the post. Author can be provided as a string of the + author's ID or as the User object of the author. Default is current user. + (object \| string) *optional* +* `password` - Password for protecting the post. Default is empty string. + (string) *optional* +* `post_parent` - Post ID of the post parent. Default is 0. (integer) + *optional* +* `post_format` - Format of the post. Default is `standard`. (string) + *optional* +* `menu_order` - The order in which posts specified as the `page` type should + appear in supported menus. Default 0. (integer) *optional* +* `comment_status` - Comment status for the post: `open` or `closed`. + Indicates whether users can submit comments to the post. Default is the + option 'default_comment_status', or 'closed'. (string) *optional* +* `ping_status` - Ping status for the post: `open` or `closed`. Indicates + whether users can submit pingbacks or trackbacks to the post. Default is the + option 'default_ping_status'. (string) *optional* +* `sticky` - Sticky status for the post: `true` or `false`. Default is + `false`. (boolean) *optional* +* `post_meta` - Post meta entries of the post. Post meta should be an array + of one or more Meta objects for each post meta entry. See the Edit Meta + for a Post endpoint for the key value pairs. (array) *optional* + + +### Response +On a successful update, a 200 OK status is given, indicating the post has been +updated. The updated Post entity is returned in the body. + +If the client is not authenticated, a 403 Forbidden response is sent. + +Delete a Post +------------- + + DELETE /posts/ + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +### Input +#### `force` +The `force` parameter controls whether the post is permanently deleted or not. +By default, this is set to false, indicating that the post will be sent to an +intermediate storage (such as the trash) allowing it to be restored later. If +set to true, the post will not be able to be restored by the user. + +Default is false. (boolean) + +### Response +On successful deletion, a 202 Accepted status code will be returned, indicating +that the post has been moved to the trash for permanent deletion at a +later date. + +If force was set to true, a 200 OK status code will be returned instead, +indicating that the post has been permanently deleted. + +If the client is not authenticated, a 403 Forbidden status code will be returned. + +Retrieve Revisions for a Post +------------------------ + + GET /posts//revisions + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +### Response +If successful, returns a 200 OK status code and revisions for the given post. + +If the client is not authenticated, a 403 Forbidden status code will be returned. + + +Create Meta for a Post +------------------------ + + POST /posts//meta + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +### Input +The supplied data should be a Meta object. This data can be submitted via a +regular HTTP multipart body, with the Meta key and value set with the `data` +parameter, or through a direct JSON body. + +The `data` parameter should be an object containing the following key value +pairs: + +* `key` - The post meta key to be created. (string) *required* +* `value` - The post meta value for the key provided. (string) *required* + +### Response +On a successful creation, a 201 Created status is given, indicating that the +Meta has been created. The post meta is available canonically from the URL +specified in the Location header. + +The new Meta entity is also returned in the body for convienience. + +If the client is not authenticated, a 403 Forbidden status code will be returned. + +Retrieve Meta for a Post +------------------------ + + GET /posts//meta + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +### Response +The response is a Meta entity containing all the post_meta for the specified +Post if available. + +Returns a 403 Forbidden status code if the client is not authenticated. + +Retrieve a Meta for a Post +------------------------ + + GET /posts//meta/ + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +### Response +The response a Meta entity containing the post_meta for the specified Meta and +Post if available. + +Returns a 403 Forbidden status code if the client is not authenticated. + +Edit a Meta for a Post +------------------------ + + PUT /posts//meta/ + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +### Input +The supplied data should be a Meta object. This data can be submitted via a +regular HTTP multipart body, with the Meta key and value set with the `data` +parameter, or through a direct JSON body. + +The `data` parameter should be an array containing the following key value pairs: + +* `key` - The post meta key to be updated. (string) *required* +* `value` - The post meta value for the key provided. (string) *required* + +### Response +On a successful update, a 200 OK status is given, indicating the post_meta has +been updated. The updated Meta entity is returned in the body. + +If the client is not authenticated, a 403 Forbidden status code is returned. + +Delete a Meta for a Post +------------- + + DELETE /posts//meta/ + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +### Response +On successful deletion, a 200 OK status code will be returned, indicating +that the post_meta has been permanently deleted. + +If the client is not authenticated, a 403 Forbidden status code is returned. + +Media +===== + + +Create an Attachment +-------------------- +The Create Attachment endpoint is used to create the raw data for an attachment. +This is a binary object (blob), such as image data or a video. + + POST /media + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +### Input +The attachment creation endpoint can accept data in two forms. + +The primary input method accepts raw data POSTed with the corresponding content +type set via the `Content-Type` HTTP header. This is the preferred submission +method. + +The secondary input method accepts data POSTed via `multipart/form-data`, as per +[RFC 2388][]. The uploaded file should be submitted with the name field set to +"file", and the filename field set to the relevant filename for the file. + +In addition, a `Content-MD5` header can be set with the MD5 hash of the file, to +enable the server to check for consistency errors. If the supplied hash does not +match the hash calculated on the server, a 412 Precondition Failed header will +be issued. + +[RFC 2388]: http://tools.ietf.org/html/rfc2388 + +### Response +On a successful creation, a 201 Created status is given, indicating that the +attachment has been created. The attachment is available canonically from the +URL specified in the Location header. + +The new Attachment entity is also returned in the body for convienience. + +Returns a 403 Forbidden status code if the client is not authenticated. + +Get Attachments +--------------- +The Attachments endpoint returns an Attachment collection containing a subset of +the site's attachments. + +This endpoint is an extended version of the Post retrieval endpoint. + + GET /media + +### Input +#### `fields` +... + +### Response +The response is an Attachment entity containing the requested Attachment if +available. + + +Users +===== + + +Create a User +------------- + + POST /users + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +### Input +The supplied data should be a User object. This data can be submitted via a +regular HTTP multipart body, with User values set as values to the `data` +parameter, or through a direct JSON body. + +That is, the following are equivalent: + +Content-Type: application/x-www-form-urlencoded + + data[username]=newuser&data[name]=New%20User&data[password]=secret + + +Content-Type: application/json + + {"username":"newuser","name":"New User","password":"secret"} + +### Response +On a successful creation, a 201 Created status is given, indicating that the +user has been created. The user is available canonically from the URL specified +in the Location header. + +The new User entity is also returned in the body for convenience. + +A 403 Forbidden status is returned if the client is not authenticated. + +Retrieve Users +-------------- +The Users endpoint returns a User Collection containing a subset of the site's +users. + + GET /users + +Requires [authentication](http://wp-api.org/guides/authentication.html) + + +### Input +#### `filter` +The `filter` parameter controls the query parameters. It is essentially a subset +of the parameters available to [`WP_User_Query`](http://codex.wordpress.org/Class_Reference/WP_User_Query). + +The parameter should be an array of the following key/value pairs: + +* `number` - Number of users to retrieve, use `-1` for all users. Default + is set by the site. (integer) +* `offset` - Number of posts to skip. Default is 0. (integer) +* `orderby` - Parameter to search by, as per [`WP_User_Query`](https://codex.wordpress.org/Class_Reference/WP_User_Query#Order_.26_Orderby_Parameters). + Default is "user_login". (string) +* `order` - Order to sort by. Default is "ASC". (string, "ASC" or "DESC") +* `s` - Keyword to search for. (string) + +### Response +The response is a User Collection document containing the requested Users if +available. + +A 403 Forbidden status is returned if the client is not authenticated. + + +Retrieve a User +--------------- + + GET /users/ + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +### Input +#### `context` +The `context` parameter controls the format of the data to return. The following +contexts are available: + +* `view`: The default context. Gives the normal User entity. +* `edit`: Context used for extra fields relevant to updating a user. Includes + the `extra_capabilities` field; this field contains the capabilities assigned + to the user themselves, rather than those inherited from their roles. Requires [authentication](http://wp-api.org/guides/authentication.html). +* `embed`: Context used when embedding the response inside another (e.g. post + author). This is intended as a minimal subset of the user data to reduce + response size. Excludes `roles` and `capabilities`. + +Default is "view". (string) + +### Response +The response is a User entity containing the requested User if available. The +fields available on the User depend on the `context` parameter. + +A 403 Forbidden status is returned if the client is not authenticated. + + +Retrieve Current User +------------- + + GET /users/me + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +This endpoint offers a permalink to get the current user, without needing to +know the user's ID. + +### Input +#### `context` +The `context` parameter controls the format of the data to return. See the +Retrieve a User endpoint for available contexts. + +Default is "view". (string) + +### Response +If the client is currently logged in, a 302 Found status is given. The User is +available canonically from the URL specified in the Location header. + +The User entity containing the current User is also returned in the body for +convenience. The fields available on the User depend on the `context` parameter. + +If the client is not logged in, a 403 Forbidden status is given. + + +Edit a User +----------- + + PUT /users/ + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +For compatibility reasons, this endpoint also accepts the POST and PATCH +methods. Both of these methods have the same behaviour as using PUT. It is +recommended to use PUT if available to fit with REST convention. + +### Input +The supplied data should be a User object. This data can be submitted via a +regular HTTP multipart body, with User values set as values to the `data` +parameter, or through a direct JSON body. See the Create User endpoint for an +example. + +### Response +On a successful update, a 200 OK status is given, indicating the user has been +updated. The updated User entity is returned in the body. + +If the client is not logged in, a 403 Forbidden status is given. + +Delete a User +------------- + + DELETE /users/ + +Requires [authentication](http://wp-api.org/guides/authentication.html) + +### Input +#### `force` +The `force` parameter controls whether the user is permanently deleted or not. +By default, this is set to false, indicating that the user will be sent to an +intermediate storage (such as the trash) allowing it to be restored later. If +set to true, the user will not be able to be restored. + +Default is false. (boolean) + +#### `reassign` +The `reassign` parameter controls whether the deleted user's content is +reassigned to a new User or not. If set to `null`, the deleted user's content +will not be reassigned. + +Default is null. (integer) + + +### Response +On successful deletion, a 202 Accepted status code will be returned, indicating +that the user has been moved to the trash for permanent deletion at a +later date. + +If force was set to true, a 200 OK status code will be returned instead, +indicating that the user has been permanently deleted. + +If the client is not authenticated, a 403 Forbidden status is given. + +Taxonomies +========== + + +Retrieve All Taxonomies +----------------------- +The Taxonomies endpoint returns a collection containing objects for each of the +site's registered taxonomies. + + GET /taxonomies + + +### Response +The response is a collection document containing all registered taxonomies. + + +Retrieve a Taxonomy +------------------- + + GET /taxonomies/ + +### Response +The response is a Taxonomy entity containing the requested Taxonomy, if available. + + +Retrieve Terms for a Taxonomy +----------------------------- + + GET /taxonomies//terms + +### Response +The response is a collection of taxonomy terms for the specified Taxonomy, if +available. + +Retrieve a Taxonomy Term +------------------------ + + GET /taxonomies//terms/ + +### Response +The response is a Taxonomy entity object containing the Taxonomy with the +requested ID, if available. + +SCHEMA ============ The API is designed around two types of responses: entities, and collections. Entities are JSON objects representing internal objects, both abstract and @@ -8,7 +718,7 @@ This document is for clients and providers wanting to ensure full compliance with the specification. -Defintions +Definitions ========== The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be @@ -155,8 +865,8 @@ value indicating a human-readable documentation page about the API. }, "meta": { "links": { - "help": "https:\/\/github.com\/rmccue\/WP-API", - "profile": "https:\/\/raw.github.com\/rmccue\/WP-API\/master\/docs\/schema.json" + "help": "https:\/\/github.com\/WP-API\/WP-API", + "profile": "https:\/\/raw.github.com\/WP-API\/WP-API\/master\/docs\/schema.json" } } } @@ -181,9 +891,9 @@ Section 5.6 datetime representation. [RFC3339]: http://tools.ietf.org/html/rfc3339 ### `modified`, `modified_gmt` -The `date` and `date_gmt` fields are strings with the post's last modification -date and time in the local time and UTC respectively. These fields follow the -[RFC3339][] Section 5.6 datetime representation. +The `modified` and `modified_gmt` fields are strings with the post's last +modification date and time in the local time and UTC respectively. These fields +follow the [RFC3339][] Section 5.6 datetime representation. modified = date-time modified_gmt = date-time @@ -334,7 +1044,7 @@ indicates how some meta fields should be displayed. For example, posts with the "link" format may wish to display an extra link to a URL specified in a meta field or emphasise a link in the post content. - post-format = "standard" / "aside" / "gallery" / "image" / "link" / "status" + post-format = "standard" / "aside" / "gallery" / "image" / "link" / "status" / "quote" / "video" / "audio" / "chat" Providers MUST NOT use post formats not specified by this specification, unless specified in a subsequent version of the specification. Consumers MUST treat @@ -411,8 +1121,8 @@ representation. "count": 7, "meta": { "links": { - "collection": "http:\/\/example.com\/wp-json\/posts\/types\/post\/taxonomies\/category\/terms", - "self": "http:\/\/example.com\/wp-json\/posts\/types\/post\/taxonomies\/category\/terms\/1" + "collection": "http:\/\/example.com\/wp-json\/taxonomies\/category\/terms", + "self": "http:\/\/example.com\/wp-json\/taxonomies\/category\/terms\/1" } } } diff --git a/docs/schema.json b/docs/schema.json deleted file mode 100644 index ea09364367..0000000000 --- a/docs/schema.json +++ /dev/null @@ -1,238 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema#", - "id": "http://wp-api.ryanmccue.info/schema#", - - "definitions": { - "index": { - "title": "Index", - - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The site's name" - }, - "description": { - "type": "string", - "description": "The site's description" - }, - "URL": { - "type": "string", - "description": "The location of the site" - }, - "routes": { - "type": "object", - "description": "The URL patterns that are handled by the API", - "patternProperties": { - ".+": { - "type": "object", - "description": "A single route description", - "properties": { - "supports": { - "type": "array", - "description": "The HTTP methods supported by the endpoint", - "items": { - "enum": [ - "HEAD", - "GET", - "POST", - "PUT", - "PATCH", - "DELETE" - ] - } - }, - "accepts_json": { - "type": "boolean", - "description": "Whether the endpoint accepts a raw JSON POST body for data", - "default": false - } - }, - "required": ["supports"] - } - } - }, - "meta": { - "type": "object", - "description": "Metadata for the Index entity" - } - }, - - "required": ["name", "description", "URL", "routes", "meta"] - }, - "post": { - "title": "Post", - - "type": "object", - "properties": { - "ID": { - "type": "integer", - "description": "Entity ID" - }, - "title": { - "type": "string", - "description": "Post title" - }, - "date": { - "type": "string", - "description": "Post creation datetime in the local timezone" - }, - "date_tz": { - "type": "string", - "description": "Olsen timezone identifier for the date field" - }, - "date_gmt": { - "type": "string", - "description": "Post creation datetime in UTC" - }, - "modified": { - "type": "string", - "description": "Post last modification datetime in the local timezone" - }, - "modified_tz": { - "type": "string", - "description": "Olsen timezone identifier for the modified field" - }, - "modified_gmt": { - "type": "string", - "description":"Post last modification datetime in UTC" - }, - "status": { - "type": "string", - "description": "Post published status", - "enum": [ - "draft", - "pending", - "private", - "publish", - "trash" - ] - }, - "type": { - "type": "string", - "description": "Post type" - }, - "name": { - "type": "string", - "description": "Post slug (URL identifier)" - }, - "author": { - "type": "object", - "description": "Post author details", - "$ref": "#/definitions/user" - }, - "password": { - "type": "string", - "description": "Post password" - }, - "excerpt": { - "type": "string", - "description": "Short version of the post content for display" - }, - "content": { - "type": "string", - "description": "Post content" - }, - "parent": { - "type": "integer", - "description": "Parent post's ID, 0 for no parent" - }, - "link": { - "type": "string", - "format": "uri", - "description": "Full URL to the post" - }, - "guid": { - "type": "string", - "description": "Globally unique identifier for the post" - }, - "menu_order": { - "type": "integer", - "description": "The post's position in menus", - "default": 0 - }, - "comment_status": { - "type": "string", - "description": "Whether the post is open for commenting", - "enum": [ - "open", - "closed" - ] - }, - "ping_status": { - "type": "string", - "description": "Whether the post is open for pingbacks/trackbacks", - "enum": [ - "open", - "closed" - ] - }, - "sticky": { - "type": "boolean", - "description": "Whether the post is stickied (shown at the top of archives)" - }, - "post_thumbnail": { - "type": [ "object", "array" ], - "description": "Thumbnail image representing the post" - }, - "post_format": { - "type": "string", - "description": "Standardized post format", - "enum": [ - "standard", - "aside", - "gallery", - "image", - "link", - "status" - ] - }, - "terms": { - "type": "object", - "description": "Taxonomic terms" - }, - "post_meta": { - "type": "array", - "description": "Post meta fields" - } - }, - - "required": ["ID", "link"] - }, - "user": { - "title": "User", - - "type": "object", - "properties": { - "ID": { - "type": "integer", - "description": "User ID" - }, - "name": { - "type": "string", - "description": "User's display name" - }, - "slug": { - "type": "string", - "description": "Author slug (URL identifier)" - }, - "URL": { - "type": "string", - "description": "URL to the author's site", - "format": "uri" - }, - "avatar": { - "type": "string", - "description": "URL to the author's avatar image", - "format": "uri" - }, - "meta": { - "type": "object", - "description": "Metadata for the Index entity" - } - }, - - "required": ["ID", "slug", "meta"] - } - } -} \ No newline at end of file diff --git a/lib/class-jsonserializable.php b/lib/class-jsonserializable.php old mode 100755 new mode 100644 diff --git a/lib/class-wp-json-comments.php b/lib/class-wp-json-comments.php new file mode 100644 index 0000000000..cc65de3ac6 --- /dev/null +++ b/lib/class-wp-json-comments.php @@ -0,0 +1,239 @@ +\d+)/meta). Must include ID selector. + */ + protected $base = '/posts/(?P\d+)/comments'; + + /** + * Register the comment-related routes + * + * @param array $routes Existing routes + * @return array Modified routes + */ + public function register_routes( $routes ) { + $routes[ $this->base ] = array( + array( array( $this, 'get_comments' ), WP_JSON_Server::READABLE ), + ); + $routes[ $this->base . '/(?P\d+)'] = array( + array( array( $this, 'get_comment' ), WP_JSON_Server::READABLE ), + array( array( $this, 'delete_comment' ), WP_JSON_Server::DELETABLE ), + ); + + return $routes; + } + + /** + * Delete a comment. + * + * @uses wp_delete_comment + * @param int $id Post ID + * @param int $comment Comment ID + * @param boolean $force Skip trash + * @return array + */ + public function delete_comment( $id, $comment, $force = false ) { + $comment = (int) $comment; + + if ( empty( $comment ) ) { + return new WP_Error( 'json_comment_invalid_id', __( 'Invalid comment ID.' ), array( 'status' => 404 ) ); + } + + $comment_array = get_comment( $comment, ARRAY_A ); + + if ( empty( $comment_array ) ) { + return new WP_Error( 'json_comment_invalid_id', __( 'Invalid comment ID.' ), array( 'status' => 404 ) ); + } + + if ( ! current_user_can( 'edit_comment', $comment_array['comment_ID'] ) ) { + return new WP_Error( 'json_user_cannot_delete_comment', __( 'Sorry, you are not allowed to delete this comment.' ), array( 'status' => 401 ) ); + } + + $result = wp_delete_comment( $comment_array['comment_ID'], $force ); + + if ( ! $result ) { + return new WP_Error( 'json_cannot_delete', __( 'The comment cannot be deleted.' ), array( 'status' => 500 ) ); + } + + if ( $force ) { + return array( 'message' => __( 'Permanently deleted comment' ) ); + } else { + // TODO: return a HTTP 202 here instead + return array( 'message' => __( 'Deleted comment' ) ); + } + } + + /** + * Retrieve comments + * + * @param int $id Post ID to retrieve comments for + * @return array List of Comment entities + */ + public function get_comments( $id ) { + //$args = array('status' => $status, 'post_id' => $id, 'offset' => $offset, 'number' => $number )l + $comments = get_comments( array('post_id' => $id) ); + + $post = get_post( $id, ARRAY_A ); + + if ( empty( $post['ID'] ) ) { + return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } + + if ( ! json_check_post_permission( $post, 'read' ) ) { + return new WP_Error( 'json_user_cannot_read', __( 'Sorry, you cannot read this post.' ), array( 'status' => 401 ) ); + } + + $struct = array(); + + foreach ( $comments as $comment ) { + $struct[] = $this->prepare_comment( $comment, array( 'comment', 'meta' ), 'collection' ); + } + + return $struct; + } + + /** + * Retrieve a single comment + * + * @param int $comment Comment ID + * @return array Comment entity + */ + public function get_comment( $comment ) { + $comment = get_comment( $comment ); + + if ( empty( $comment ) ) { + return new WP_Error( 'json_comment_invalid_id', __( 'Invalid comment ID.' ), array( 'status' => 404 ) ); + } + + $data = $this->prepare_comment( $comment ); + + return $data; + } + + /** + * Prepares comment data for returning as a JSON response. + * + * @param stdClass $comment Comment object + * @param array $requested_fields Fields to retrieve from the comment + * @param string $context Where is the comment being loaded? + * @return array Comment data for JSON serialization + */ + protected function prepare_comment( $comment, $requested_fields = array( 'comment', 'meta' ), $context = 'single' ) { + $fields = array( + 'ID' => (int) $comment->comment_ID, + 'post' => (int) $comment->comment_post_ID, + ); + + $post = (array) get_post( $fields['post'] ); + + // Content + $fields['content'] = apply_filters( 'comment_text', $comment->comment_content, $comment ); + // $fields['content_raw'] = $comment->comment_content; + + // Status + switch ( $comment->comment_approved ) { + case 'hold': + case '0': + $fields['status'] = 'hold'; + break; + + case 'approve': + case '1': + $fields['status'] = 'approved'; + break; + + case 'spam': + case 'trash': + default: + $fields['status'] = $comment->comment_approved; + break; + } + + // Type + $fields['type'] = apply_filters( 'get_comment_type', $comment->comment_type ); + + if ( empty( $fields['type'] ) ) { + $fields['type'] = 'comment'; + } + + // Parent + if ( ( 'single' === $context || 'single-parent' === $context ) && (int) $comment->comment_parent ) { + $parent_fields = array( 'meta' ); + + if ( $context === 'single' ) { + $parent_fields[] = 'comment'; + } + $parent = get_comment( $comment->comment_parent ); + + $fields['parent'] = $this->prepare_comment( $parent, $parent_fields, 'single-parent' ); + } + + // Parent + $fields['parent'] = (int) $comment->comment_parent; + + // Author + if ( (int) $comment->user_id !== 0 ) { + $fields['author'] = (int) $comment->user_id; + } else { + $fields['author'] = array( + 'ID' => 0, + 'name' => $comment->comment_author, + 'URL' => $comment->comment_author_url, + 'avatar' => json_get_avatar_url( $comment->comment_author_email ), + ); + } + + // Date + $timezone = json_get_timezone(); + $comment_date = WP_JSON_DateTime::createFromFormat( 'Y-m-d H:i:s', $comment->comment_date, $timezone ); + + $fields['date'] = json_mysql_to_rfc3339( $comment->comment_date ); + $fields['date_tz'] = $comment_date->format( 'e' ); + $fields['date_gmt'] = json_mysql_to_rfc3339( $comment->comment_date_gmt ); + + // Meta + $meta = array( + 'links' => array( + 'up' => json_url( sprintf( '/posts/%d', (int) $comment->comment_post_ID ) ) + ), + ); + + if ( 0 !== (int) $comment->comment_parent ) { + $meta['links']['in-reply-to'] = json_url( sprintf( '/posts/%d/comments/%d', (int) $comment->comment_post_ID, (int) $comment->comment_parent ) ); + } + + if ( 'single' !== $context ) { + $meta['links']['self'] = json_url( sprintf( '/posts/%d/comments/%d', (int) $comment->comment_post_ID, (int) $comment->comment_ID ) ); + } + + // Remove unneeded fields + $data = array(); + + if ( in_array( 'comment', $requested_fields ) ) { + $data = array_merge( $data, $fields ); + } + + if ( in_array( 'meta', $requested_fields ) ) { + $data['meta'] = $meta; + } + + return apply_filters( 'json_prepare_comment', $data, $comment, $context ); + } + + /** + * Call protected method from {@see WP_JSON_Posts}. + * + * WPAPI-1.2 deprecated a bunch of protected methods by moving them to this + * class. This proxy method is added to call those methods. + * + * @param string $method Method name + * @param array $args Method arguments + * @return mixed Return value from the method + */ + public function _deprecated_call( $method, $args ) { + return call_user_func_array( array( $this, $method ), $args ); + } +} diff --git a/lib/class-wp-json-customposttype.php b/lib/class-wp-json-customposttype.php index 8d7dabb55f..02fd1a1d00 100644 --- a/lib/class-wp-json-customposttype.php +++ b/lib/class-wp-json-customposttype.php @@ -38,10 +38,18 @@ public function __construct(WP_JSON_ResponseHandler $server) { return; } + parent::__construct($server); + } + + /** + * Add actions and filters for the post type + * + * This method should be called after instantiation to automatically add the + * required filters for the post type. + */ + public function register_filters() { add_filter( 'json_endpoints', array( $this, 'register_routes' ) ); add_filter( 'json_post_type_data', array( $this, 'type_archive_link' ), 10, 2 ); - - parent::__construct($server); } /** @@ -52,8 +60,8 @@ public function __construct(WP_JSON_ResponseHandler $server) { */ public function register_routes( $routes ) { $routes[ $this->base ] = array( - array( array( $this, 'get_posts' ), WP_JSON_Server::READABLE ), - array( array( $this, 'new_post' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'get_posts' ), WP_JSON_Server::READABLE ), + array( array( $this, 'create_post' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), ); $routes[ $this->base . '/(?P\d+)' ] = array( @@ -72,7 +80,7 @@ public function register_routes( $routes ) { */ public function register_revision_routes( $routes ) { $routes[ $this->base . '/(?P\d+)/revisions' ] = array( - array( '__return_null', WP_JSON_Server::READABLE ), + array( array( $this, 'get_revisions' ), WP_JSON_Server::READABLE ), ); return $routes; } @@ -86,12 +94,10 @@ public function register_revision_routes( $routes ) { public function register_comment_routes( $routes ) { $routes[ $this->base . '/(?P\d+)/comments'] = array( array( array( $this, 'get_comments' ), WP_JSON_Server::READABLE ), - array( '__return_null', WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), ); $routes[ $this->base . '/(?P\d+)/comments/(?P\d+)' ] = array( array( array( $this, 'get_comment' ), WP_JSON_Server::READABLE ), - array( '__return_null', WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), - array( '__return_null', WP_JSON_Server::DELETABLE ), + array( array( $this, 'delete_comment' ), WP_JSON_Server::DELETABLE ), ); return $routes; } @@ -105,8 +111,9 @@ public function register_comment_routes( $routes ) { * @see WP_JSON_Posts::get_posts() */ public function get_posts( $filter = array(), $context = 'view', $type = null, $page = 1 ) { - if ( !empty( $type ) && $type !== $this->type ) + if ( ! empty( $type ) && $type !== $this->type ) { return new WP_Error( 'json_post_invalid_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); + } return parent::get_posts( $filter, $context, $this->type, $page ); } @@ -119,13 +126,15 @@ public function get_posts( $filter = array(), $context = 'view', $type = null, $ public function get_post( $id, $context = 'view' ) { $id = (int) $id; - if ( empty( $id ) ) + if ( empty( $id ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } $post = get_post( $id, ARRAY_A ); - if ( $post['post_type'] !== $this->type ) + if ( $post['post_type'] !== $this->type ) { return new WP_Error( 'json_post_invalid_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); + } return parent::get_post( $id, $context ); } @@ -137,16 +146,20 @@ public function get_post( $id, $context = 'view' ) { */ function edit_post( $id, $data, $_headers = array() ) { $id = (int) $id; - if ( empty( $id ) ) + + if ( empty( $id ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } $post = get_post( $id, ARRAY_A ); - if ( empty( $post['ID'] ) ) + if ( empty( $post['ID'] ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } - if ( $post['post_type'] !== $this->type ) + if ( $post['post_type'] !== $this->type ) { return new WP_Error( 'json_post_invalid_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); + } return parent::edit_post( $id, $data, $_headers ); } @@ -159,13 +172,15 @@ function edit_post( $id, $data, $_headers = array() ) { public function delete_post( $id, $force = false ) { $id = (int) $id; - if ( empty( $id ) ) + if ( empty( $id ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } $post = get_post( $id, ARRAY_A ); - if ( $post['post_type'] !== $this->type ) + if ( $post['post_type'] !== $this->type ) { return new WP_Error( 'json_post_invalid_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); + } return parent::delete_post( $id, $force ); } @@ -174,11 +189,14 @@ public function delete_post( $id, $force = false ) { * Prepare post data * * @param array $post The unprepared post data - * @param array $fields The subset of post type fields to return + * @param string $context The context for the prepared post. (view|view-revision|edit|embed|single-parent) * @return array The prepared post data */ protected function prepare_post( $post, $context = 'view' ) { $_post = parent::prepare_post( $post, $context ); + if ( is_wp_error( $_post ) ) { + return $_post; + } // Override entity meta keys with the correct links $_post['meta'] = array( @@ -191,10 +209,11 @@ protected function prepare_post( $post, $context = 'view' ) { ), ); - if ( ! empty( $post['post_parent'] ) ) + if ( ! empty( $post['post_parent'] ) ) { $_post['meta']['links']['up'] = json_url( $this->base . '/' . $post['ID'] ); + } - return apply_filters( 'json_prepare_{$this->type}', $_post, $post, $context ); + return apply_filters( "json_prepare_{$this->type}", $_post, $post, $context ); } /** @@ -212,4 +231,4 @@ public function type_archive_link( $data, $type ) { $data['meta']['links']['archives'] = json_url( $this->base ); return $data; } -} \ No newline at end of file +} diff --git a/lib/class-wp-json-datetime.php b/lib/class-wp-json-datetime.php index d2e93c15cc..9cd852392b 100644 --- a/lib/class-wp-json-datetime.php +++ b/lib/class-wp-json-datetime.php @@ -4,7 +4,6 @@ * * @package WordPress * @subpackage JSON API - * @version 0.9 */ class WP_JSON_DateTime extends DateTime { /** @@ -17,14 +16,15 @@ class WP_JSON_DateTime extends DateTime { * @param DateTimeZone $timezone A DateTimeZone object representing the desired time zone. * @return Datetime */ - public static function createFromFormat($format, $time, $timezone = null ) { + public static function createFromFormat( $format, $time, $timezone = null ) { if ( is_null( $timezone ) ) { $timezone = new DateTimeZone( date_default_timezone_get() ); } + if ( method_exists( 'DateTime', 'createFromFormat' ) ) { return parent::createFromFormat( $format, $time, $timezone ); } return new DateTime( date( $format, strtotime( $time ) ), $timezone ); } -} \ No newline at end of file +} diff --git a/lib/class-wp-json-media.php b/lib/class-wp-json-media.php index 5ae475ee6c..301029d8be 100644 --- a/lib/class-wp-json-media.php +++ b/lib/class-wp-json-media.php @@ -9,11 +9,10 @@ class WP_JSON_Media extends WP_JSON_Posts { */ public function register_routes( $routes ) { $media_routes = array( - '/media' => array( + '/media' => array( array( array( $this, 'get_posts' ), WP_JSON_Server::READABLE ), array( array( $this, 'upload_attachment' ), WP_JSON_Server::CREATABLE ), ), - '/media/(?P\d+)' => array( array( array( $this, 'get_post' ), WP_JSON_Server::READABLE ), array( array( $this, 'edit_post' ), WP_JSON_Server::EDITABLE ), @@ -32,8 +31,9 @@ public function register_routes( $routes ) { * @see WP_JSON_Posts::get_posts() */ public function get_posts( $filter = array(), $context = 'view', $type = 'attachment', $page = 1 ) { - if ( $type !== 'attachment' ) + if ( $type !== 'attachment' ) { return new WP_Error( 'json_post_invalid_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); + } if ( empty( $filter['post_status'])) { $filter['post_status'] = array( 'publish', 'inherit' ); @@ -68,13 +68,15 @@ public function allow_status_query( $vars ) { public function get_post( $id, $context = 'view' ) { $id = (int) $id; - if ( empty( $id ) ) + if ( empty( $id ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } $post = get_post( $id, ARRAY_A ); - if ( $post['post_type'] !== 'attachment' ) + if ( $post['post_type'] !== 'attachment' ) { return new WP_Error( 'json_post_invalid_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); + } return parent::get_post( $id, $context ); } @@ -88,26 +90,27 @@ public function get_post( $id, $context = 'view' ) { protected function prepare_post( $post, $context = 'single' ) { $data = parent::prepare_post( $post, $context ); - if ( is_wp_error( $data ) ) + if ( is_wp_error( $data ) || $post['post_type'] !== 'attachment' ) { return $data; + } // $thumbnail_size = current_theme_supports( 'post-thumbnail' ) ? 'post-thumbnail' : 'thumbnail'; - $data['source'] = wp_get_attachment_url( $post['ID'] ); - $data['is_image'] = wp_attachment_is_image( $post['ID'] ); - + $data['source'] = wp_get_attachment_url( $post['ID'] ); + $data['is_image'] = wp_attachment_is_image( $post['ID'] ); $data['attachment_meta'] = wp_get_attachment_metadata( $post['ID'] ); // Ensure empty meta is an empty object - if ( empty( $data['attachment_meta'] ) ){ + if ( empty( $data['attachment_meta'] ) ) { $data['attachment_meta'] = new stdClass; - } - elseif ( ! empty( $data['attachment_meta']['sizes'] ) ) { + } elseif ( ! empty( $data['attachment_meta']['sizes'] ) ) { $img_url_basename = wp_basename( $data['source'] ); foreach ($data['attachment_meta']['sizes'] as $size => &$size_data) { // Use the same method image_downsize() does $size_data['url'] = str_replace( $img_url_basename, $size_data['file'], $data['source'] ); } + } else { + $data['attachment_meta']['sizes'] = new stdClass; } // Override entity meta keys with the correct links @@ -121,8 +124,9 @@ protected function prepare_post( $post, $context = 'single' ) { ), ); - if ( ! empty( $post['post_parent'] ) ) + if ( ! empty( $post['post_parent'] ) ) { $data['meta']['links']['up'] = json_url( '/media/' . (int) $post['post_parent'] ); + } return apply_filters( 'json_prepare_attachment', $data, $post, $context ); } @@ -134,16 +138,20 @@ protected function prepare_post( $post, $context = 'single' ) { */ public function edit_post( $id, $data, $_headers = array() ) { $id = (int) $id; - if ( empty( $id ) ) + + if ( empty( $id ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } $post = get_post( $id, ARRAY_A ); - if ( empty( $post['ID'] ) ) + if ( empty( $post['ID'] ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } - if ( $post['post_type'] !== 'attachment' ) + if ( $post['post_type'] !== 'attachment' ) { return new WP_Error( 'json_post_invalid_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); + } return parent::edit_post( $id, $data, $_headers ); } @@ -156,13 +164,15 @@ public function edit_post( $id, $data, $_headers = array() ) { public function delete_post( $id, $force = false ) { $id = (int) $id; - if ( empty( $id ) ) + if ( empty( $id ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } $post = get_post( $id, ARRAY_A ); - if ( $post['post_type'] !== 'attachment' ) + if ( $post['post_type'] !== 'attachment' ) { return new WP_Error( 'json_post_invalid_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); + } return parent::delete_post( $id, $force ); } @@ -178,66 +188,90 @@ public function delete_post( $id, $force = false ) { * @param array $_headers HTTP headers from the request * @return array|WP_Error Attachment data or error */ - public function upload_attachment( $_files, $_headers ) { + public function upload_attachment( $_files, $_headers, $post_id = 0 ) { + $post_type = get_post_type_object( 'attachment' ); - if ( ! $post_type ) + + if ( $post_id == 0 ) { + $post_parent_type = get_post_type_object( 'post' ); + } else { + $post_parent_type = get_post_type_object( get_post_type( $post_id ) ); + } + + // Make sure we have an int or 0 + $post_id = (int) $post_id; + + if ( ! $post_type ) { return new WP_Error( 'json_invalid_post_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); + } - // Permissions check - if ( ! current_user_can( $post_type->cap->create_posts ) || ! current_user_can( $post_type->cap->edit_posts ) ) + // Permissions check - Note: "upload_files" cap is returned for an attachment by $post_type->cap->create_posts + if ( ! current_user_can( $post_type->cap->create_posts ) || ! current_user_can( $post_type->cap->edit_posts ) ) { return new WP_Error( 'json_cannot_create', __( 'Sorry, you are not allowed to post on this site.' ), array( 'status' => 400 ) ); + } + + // If a user is trying to attach to a post make sure they have permissions. Bail early if post_id is not being passed + if ( $post_id !== 0 && ! current_user_can( $post_parent_type->cap->edit_post, $post_id ) ) { + return new WP_Error( 'json_cannot_edit', __( 'Sorry, you are not allowed to edit this post.' ), array( 'status' => 401 ) ); + } // Get the file via $_FILES or raw data if ( empty( $_files ) ) { $file = $this->upload_from_data( $_files, $_headers ); - } - else { + } else { $file = $this->upload_from_file( $_files, $_headers ); } - if ( is_wp_error( $file ) ) + if ( is_wp_error( $file ) ) { return $file; + } - $name = basename( $file['file'] ); + $name = basename( $file['file'] ); $name_parts = pathinfo( $name ); - $name = trim( substr( $name, 0, -(1 + strlen($name_parts['extension'])) ) ); + $name = trim( substr( $name, 0, -(1 + strlen($name_parts['extension'])) ) ); - $url = $file['url']; - $type = $file['type']; - $file = $file['file']; - $title = $name; + $url = $file['url']; + $type = $file['type']; + $file = $file['file']; + $title = $name; $content = ''; // use image exif/iptc data for title and caption defaults if possible if ( $image_meta = @wp_read_image_metadata($file) ) { - if ( trim( $image_meta['title'] ) && ! is_numeric( sanitize_title( $image_meta['title'] ) ) ) + if ( trim( $image_meta['title'] ) && ! is_numeric( sanitize_title( $image_meta['title'] ) ) ) { $title = $image_meta['title']; - if ( trim( $image_meta['caption'] ) ) + } + + if ( trim( $image_meta['caption'] ) ) { $content = $image_meta['caption']; + } } // Construct the attachment array - $post_data = array(); + $post_data = array(); $attachment = array( 'post_mime_type' => $type, - 'guid' => $url, - // 'post_parent' => $post_id, - 'post_title' => $title, - 'post_content' => $content, + 'guid' => $url, + 'post_parent' => $post_id, + 'post_title' => $title, + 'post_content' => $content, ); // This should never be set as it would then overwrite an existing attachment. - if ( isset( $attachment['ID'] ) ) + if ( isset( $attachment['ID'] ) ) { unset( $attachment['ID'] ); + } // Save the data - $id = wp_insert_attachment($attachment, $file /*, $post_id */); + $id = wp_insert_attachment($attachment, $file, $post_id ); + if ( !is_wp_error($id) ) { wp_update_attachment_metadata( $id, wp_generate_attachment_metadata( $id, $file ) ); } $headers = array( 'Location' => json_url( '/media/' . $id ) ); - return new WP_JSON_Response( $this->getPost( $id, 'edit' ), 201, $headers ); + + return new WP_JSON_Response( $this->get_post( $id, 'edit' ), 201, $headers ); } /** @@ -263,16 +297,18 @@ protected function upload_from_data( $_files, $_headers ) { } // Get the filename - $disposition_parts = explode(';', $_headers['CONTENT_DISPOSITION']); + $disposition_parts = explode( ';', $_headers['CONTENT_DISPOSITION'] ); $filename = null; - foreach ($disposition_parts as $part) { - $part = trim($part); - if (strpos($part, 'filename') !== 0) + foreach ( $disposition_parts as $part ) { + $part = trim( $part ); + + if ( strpos( $part, 'filename' ) !== 0 ) { continue; + } - $filenameparts = explode('=', $part); - $filename = trim($filenameparts[1]); + $filenameparts = explode( '=', $part ); + $filename = trim( $filenameparts[1] ); } if ( empty( $filename ) ) { @@ -281,7 +317,8 @@ protected function upload_from_data( $_files, $_headers ) { if ( ! empty( $_headers['CONTENT_MD5'] ) ) { $expected = trim( $_headers['CONTENT_MD5'] ); - $actual = md5( $data ); + $actual = md5( $data ); + if ( $expected !== $actual ) { return new WP_Error( 'json_upload_hash_mismatch', __( 'Content hash did not match expected' ), array( 'status' => 412 ) ); } @@ -294,6 +331,7 @@ protected function upload_from_data( $_files, $_headers ) { $tmpfname = wp_tempnam( $filename ); $fp = fopen( $tmpfname, 'w+' ); + if ( ! $fp ) { return new WP_Error( 'json_upload_file_error', __( 'Could not open file handle' ), array( 'status' => 500 ) ); } @@ -345,10 +383,12 @@ protected function upload_from_file( $_files, $_headers ) { $overrides = array( 'test_form' => false, ); + $file = wp_handle_upload( $_files['file'], $overrides ); - if ( isset($file['error']) ) + if ( isset( $file['error'] ) ) { return new WP_Error( 'json_upload_unknown_error', $file['error'], array( 'status' => 500 ) ); + } return $file; } @@ -368,7 +408,8 @@ public function preinsert_check( $status, $post, $data ) { // Featured image (pre-verification) if ( ! empty( $data['featured_image'] ) ) { - $thumbnail = $this->getPost( (int) $data['featured_image'], 'child' ); + $thumbnail = $this->get_post( (int) $data['featured_image'], 'child' ); + if ( is_wp_error( $thumbnail ) ) { return new WP_Error( 'json_invalid_featured_image', __( 'Invalid featured image.' ), array( 'status' => 400 ) ); } @@ -390,9 +431,12 @@ public function attach_thumbnail( $post, $data, $update ) { } if ( ! empty( $data['featured_image'] ) ) { - // Already verified in preinsertCheck() + // Already verified in preinsert_check() $thumbnail = $this->get_post( $data['featured_image'], 'child' ); - set_post_thumbnail( $post['ID'], $thumbnail['ID'] ); + if ( ! is_wp_error( $thumbnail ) ) { + $data = $thumbnail->get_data(); + set_post_thumbnail( $post['ID'], $data['ID'] ); + } } } @@ -405,13 +449,26 @@ public function attach_thumbnail( $post, $data, $update ) { * @return array Filtered post data */ public function add_thumbnail_data( $data, $post, $context ) { - if( !post_type_supports( $post['post_type'], 'thumbnail' ) ) { + if ( ! post_type_supports( $post['post_type'], 'thumbnail' ) ) { + return $data; + } + + // Don't embed too deeply + if ( $context !== 'view' && $context !== 'edit' ) { + $data['featured_image'] = null; + $thumbnail_id = get_post_thumbnail_id( $post['ID'] ); + + if ( $thumbnail_id ) { + $data['featured_image'] = absint( $thumbnail_id ); + } + return $data; } // Thumbnail $data['featured_image'] = null; $thumbnail_id = get_post_thumbnail_id( $post['ID'] ); + if ( $thumbnail_id ) { $data['featured_image'] = $this->get_post( $thumbnail_id, 'child' ); } @@ -434,4 +491,4 @@ public function type_archive_link( $data, $type ) { $data['meta']['links']['archives'] = json_url( '/media' ); return $data; } -} \ No newline at end of file +} diff --git a/lib/class-wp-json-meta-posts.php b/lib/class-wp-json-meta-posts.php new file mode 100644 index 0000000000..24df5ca21e --- /dev/null +++ b/lib/class-wp-json-meta-posts.php @@ -0,0 +1,128 @@ +\d+)/meta). Must include ID selector. + */ + protected $base = '/posts/(?P\d+)/meta'; + + /** + * Associated object type. + * + * @var string Type slug ("post" or "user") + */ + protected $type = 'post'; + + /** + * Check that the object can be accessed. + * + * @param mixed $id Object ID + * @return boolean|WP_Error + */ + protected function check_object( $id ) { + $id = (int) $id; + + $post = get_post( $id, ARRAY_A ); + + if ( empty( $id ) || empty( $post['ID'] ) ) { + return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } + + if ( ! json_check_post_permission( $post, 'edit' ) ) { + return new WP_Error( 'json_cannot_edit', __( 'Sorry, you cannot edit this post' ), array( 'status' => 403 ) ); + } + + return true; + } + + /** + * Add meta to a post. + * + * Ensures that the correct location header is sent with the response. + * + * @param int $id Post ID + * @param array $data { + * @type string|null $key Meta key + * @type string|null $key Meta value + * } + * @return bool|WP_Error + */ + public function add_meta( $id, $data ) { + $response = parent::add_meta( $id, $data ); + if ( is_wp_error( $response ) ) { + return $response; + } + + $data = (object) $response->get_data(); + + $response = new WP_JSON_Response(); + $response->header( 'Location', json_url( '/posts/' . $id . '/meta/' . $data->ID ) ); + $response->set_data( $data ); + $response = json_ensure_response( $response ); + + return $response; + } + + /** + * Add post meta to post responses. + * + * Adds meta to post responses for the 'edit' context. + * + * @param WP_Error|array $data Post response data (or error) + * @param array $post Post data + * @param string $context Context for the prepared post. + * @return WP_Error|array Filtered data + */ + public function add_post_meta_data( $data, $post, $context ) { + if ( $context !== 'edit' || is_wp_error( $data ) ) { + return $data; + } + + // Permissions have already been checked at this point, so no need to + // check again + $data['post_meta'] = $this->get_all_meta( $post['ID'] ); + if ( is_wp_error( $data['post_meta'] ) ) { + return $data['post_meta']; + } + + return $data; + } + + /** + * Add post meta on post update. + * + * Handles adding/updating post meta when creating or updating posts. + * + * @param array $post New post data + * @param array $data Raw submitted data + * @return array|WP_Error Post data on success, post meta error otherwise + */ + public function insert_post_meta( $post, $data ) { + // Post meta + if ( ! empty( $data['post_meta'] ) ) { + $result = $this->handle_inline_meta( $post['ID'], $data['post_meta'] ); + + if ( is_wp_error( $result ) ) { + return $result; + } + } + + return $post; + } + + /** + * Call protected method from {@see WP_JSON_Posts}. + * + * WPAPI-1.2 deprecated a bunch of protected methods by moving them to this + * class. This proxy method is added to call those methods. + * + * @param string $method Method name + * @param array $args Method arguments + * @return mixed Return value from the method + */ + public function _deprecated_call( $method, $args ) { + return call_user_func_array( array( $this, $method ), $args ); + } +} diff --git a/lib/class-wp-json-meta.php b/lib/class-wp-json-meta.php new file mode 100644 index 0000000000..5a01aa066c --- /dev/null +++ b/lib/class-wp-json-meta.php @@ -0,0 +1,411 @@ +base ) ) { + _doing_it_wrong( 'WP_JSON_Meta::__construct', __( 'The route base must be overridden' ), 'WPAPI-1.2' ); + return; + } + if ( empty( $this->type ) ) { + _doing_it_wrong( 'WP_JSON_Meta::__construct', __( 'The object type must be overridden' ), 'WPAPI-1.2' ); + return; + } + } + + /** + * Register the meta-related routes. + * + * @param array $routes Existing routes + * @return array Modified routes + */ + public function register_routes( $routes ) { + $routes[ $this->base ] = array( + array( array( $this, 'get_all_meta' ), WP_JSON_Server::READABLE ), + array( array( $this, 'add_meta' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + ); + $routes[ $this->base . '/(?P\d+)' ] = array( + array( array( $this, 'get_meta' ), WP_JSON_Server::READABLE ), + array( array( $this, 'update_meta' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'delete_meta' ), WP_JSON_Server::DELETABLE ), + ); + + return $routes; + } + + /** + * Check that the object is valid and can be accessed. + * + * @param mixed $id Object ID (can be any data from the API, will be validated) + * @return boolean|WP_Error True if valid and accessible, error otherwise. + */ + abstract protected function check_object( $id ); + + /** + * Get the meta ID column for the relevant table. + * + * @return string + */ + protected function get_id_column() { + return ( 'user' === $this->type ) ? 'umeta_id' : 'meta_id'; + } + + /** + * Get the object (parent) ID column for the relevant table. + * + * @return string + */ + protected function get_parent_column() { + return ( 'user' === $this->type ) ? 'user_id' : 'post_id'; + } + + /** + * Retrieve custom fields for object. + * + * @param int $id Object ID + * @return (array[]|WP_Error) List of meta object data on success, WP_Error otherwise + */ + public function get_all_meta( $id ) { + $check = $this->check_object( $id ); + if ( is_wp_error( $check ) ) { + return $check; + } + + global $wpdb; + $table = _get_meta_table( $this->type ); + $parent_column = $this->get_parent_column(); + + $results = $wpdb->get_results( $wpdb->prepare( "SELECT meta_id, meta_key, meta_value FROM $table WHERE $parent_column = %d", $id ) ); + + $meta = array(); + + foreach ( $results as $row ) { + $value = $this->prepare_meta( $id, $row, true ); + + if ( is_wp_error( $value ) ) { + continue; + } + + $meta[] = $value; + } + + return apply_filters( 'json_prepare_meta', $meta, $id ); + } + + /** + * Retrieve custom field object. + * + * @param int $id Object ID + * @param int $mid Metadata ID + * @return array|WP_Error Meta object data on success, WP_Error otherwise + */ + public function get_meta( $id, $mid ) { + $check = $this->check_object( $id ); + if ( is_wp_error( $check ) ) { + return $check; + } + + $parent_column = $this->get_parent_column(); + $meta = get_metadata_by_mid( $this->type, $mid ); + + if ( empty( $meta ) ) { + return new WP_Error( 'json_meta_invalid_id', __( 'Invalid meta ID.' ), array( 'status' => 404 ) ); + } + + if ( absint( $meta->$parent_column ) !== (int) $id ) { + return new WP_Error( 'json_meta_' . $this->type . '_mismatch', __( 'Meta does not belong to this object' ), array( 'status' => 400 ) ); + } + + return $this->prepare_meta( $id, $meta ); + } + + /** + * Prepares meta data for return as an object. + * + * @param int $parent_id Object ID + * @param stdClass $data Metadata row from database + * @param boolean $is_raw Is the value field still serialized? (False indicates the value has been unserialized) + * @return array|WP_Error Meta object data on success, WP_Error otherwise + */ + protected function prepare_meta( $parent_id, $data, $is_raw = false ) { + $ID = $data->meta_id; + $key = $data->meta_key; + $value = $data->meta_value; + + // Don't expose protected fields. + if ( is_protected_meta( $key ) ) { + return new WP_Error( 'json_meta_protected', sprintf( __( '%s is marked as a protected field.' ), $key ), array( 'status' => 403 ) ); + } + + // Normalize serialized strings + if ( $is_raw && is_serialized_string( $value ) ) { + $value = unserialize( $value ); + } + + // Don't expose serialized data + if ( is_serialized( $value ) || ! is_string( $value ) ) { + return new WP_Error( 'json_meta_protected', sprintf( __( '%s contains serialized data.' ), $key ), array( 'status' => 403 ) ); + } + + $meta = array( + 'ID' => (int) $ID, + 'key' => $key, + 'value' => $value, + ); + + return apply_filters( 'json_prepare_meta_value', $meta, $parent_id ); + } + + /** + * Update/add/delete meta for an object. + * + * Meta data is expected to be sent in the same format as it's output: + * + * { + * "ID": 42, + * "key" : "meta_key", + * "value" : "meta_value" + * } + * + * If ID is not specified, the meta value will be created; otherwise, the + * value (and key, if it differs) will be updated. If ID is specified, and + * the key is set to `null`, the data will be deleted. + * + * @param array $data + * @param int $parent_id + * @return bool|WP_Error + */ + public function handle_inline_meta( $parent_id, $data ) { + foreach ( $data as $meta_array ) { + if ( empty( $meta_array['ID'] ) ) { + // Creation + $result = $this->add_meta( $parent_id, $meta_array ); + } else { + // Update + $result = $this->update_meta( $parent_id, $meta_array['ID'], $meta_array ); + } + + if ( is_wp_error( $result ) ) { + return $result; + } + } + + return true; + } + + /** + * Add meta to an object. + * + * @param int $id Object ID + * @param array $data { + * @type string|null $key Meta key + * @type string|null $key Meta value + * } + * @return bool|WP_Error + */ + public function update_meta( $id, $mid, $data ) { + $id = (int) $id; + $mid = (int) $mid; + + $check = $this->check_object( $id ); + if ( is_wp_error( $check ) ) { + return $check; + } + + $parent_column = $this->get_parent_column(); + $current = get_metadata_by_mid( $this->type, $mid ); + + if ( empty( $current ) ) { + return new WP_Error( 'json_meta_invalid_id', __( 'Invalid meta ID.' ), array( 'status' => 404 ) ); + } + + if ( absint( $current->$parent_column ) !== $id ) { + return new WP_Error( 'json_meta_' . $this->type . '_mismatch', __( 'Meta does not belong to this object' ), array( 'status' => 400 ) ); + } + + if ( ! array_key_exists( 'key', $data ) ) { + $data['key'] = $current->meta_key; + } + + if ( ! array_key_exists( 'value', $data ) ) { + $data['value'] = $current->meta_value; + } + + if ( empty( $data['key'] ) ) { + return new WP_Error( 'json_meta_invalid_key', __( 'Invalid meta key.' ), array( 'status' => 400 ) ); + } + + // for now let's not allow updating of arrays, objects or serialized values. + if ( ! $this->is_valid_meta_data( $current->meta_value ) ) { + $code = ( $this->type === 'post' ) ? 'json_post_invalid_action' : 'json_meta_invalid_action'; + return new WP_Error( $code, __( 'Invalid existing meta data for action.' ), array( 'status' => 400 ) ); + } + + if ( ! $this->is_valid_meta_data( $data['value'] ) ) { + $code = ( $this->type === 'post' ) ? 'json_post_invalid_action' : 'json_meta_invalid_action'; + return new WP_Error( $code, __( 'Invalid provided meta data for action.' ), array( 'status' => 400 ) ); + } + + if ( is_protected_meta( $current->meta_key ) ) { + return new WP_Error( 'json_meta_protected', sprintf( __( '%s is marked as a protected field.' ), $current->meta_key ), array( 'status' => 403 ) ); + } + + if ( is_protected_meta( $data['key'] ) ) { + return new WP_Error( 'json_meta_protected', sprintf( __( '%s is marked as a protected field.' ), $data['key'] ), array( 'status' => 403 ) ); + } + + // update_metadata_by_mid will return false if these are equal, so check + // first and pass through + if ( $data['value'] === $current->meta_value && $data['key'] === $current->meta_key ) { + return $this->get_meta( $id, $mid ); + } + + $key = wp_slash( $data['key'] ); + $value = wp_slash( $data['value'] ); + + if ( ! update_metadata_by_mid( $this->type, $mid, $value, $key ) ) { + return new WP_Error( 'json_meta_could_not_update', __( 'Could not update meta.' ), array( 'status' => 500 ) ); + } + + return $this->get_meta( $id, $mid ); + } + + /** + * Check if the data provided is valid data. + * + * Excludes serialized data from being sent via the API. + * + * @see https://github.com/WP-API/WP-API/pull/68 + * @param mixed $data Data to be checked + * @return boolean Whether the data is valid or not + */ + protected function is_valid_meta_data( $data ) { + if ( is_array( $data ) || is_object( $data ) || is_serialized( $data ) ) { + return false; + } + + return true; + } + + /** + * Add meta to an object. + * + * @param int $id Object ID + * @param array $data { + * @type string|null $key Meta key + * @type string|null $key Meta value + * } + * @return bool|WP_Error + */ + public function add_meta( $id, $data ) { + $check = $this->check_object( $id ); + if ( is_wp_error( $check ) ) { + return $check; + } + + if ( ! array_key_exists( 'key', $data ) ) { + $code = ( $this->type === 'post' ) ? 'json_post_missing_key' : 'json_meta_missing_key'; + return new WP_Error( $code, __( 'Missing meta key.' ), array( 'status' => 400 ) ); + } + if ( ! array_key_exists( 'value', $data ) ) { + $code = ( $this->type === 'post' ) ? 'json_post_missing_value' : 'json_meta_missing_value'; + return new WP_Error( $code, __( 'Missing meta value.' ), array( 'status' => 400 ) ); + } + + if ( empty( $data['key'] ) ) { + return new WP_Error( 'json_meta_invalid_key', __( 'Invalid meta key.' ), array( 'status' => 400 ) ); + } + + if ( ! $this->is_valid_meta_data( $data['value'] ) ) { + $code = ( $this->type === 'post' ) ? 'json_post_invalid_action' : 'json_meta_invalid_action'; + + // for now let's not allow updating of arrays, objects or serialized values. + return new WP_Error( $code, __( 'Invalid provided meta data for action.' ), array( 'status' => 400 ) ); + } + + if ( is_protected_meta( $data['key'] ) ) { + return new WP_Error( 'json_meta_protected', sprintf( __( '%s is marked as a protected field.' ), $data['key'] ), array( 'status' => 403 ) ); + } + + $meta_key = wp_slash( $data['key'] ); + $value = wp_slash( $data['value'] ); + + $result = add_metadata( $this->type, $id, $meta_key, $value ); + + if ( ! $result ) { + return new WP_Error( 'json_meta_could_not_add', __( 'Could not add meta.' ), array( 'status' => 400 ) ); + } + + $response = json_ensure_response( $this->get_meta( $id, $result ) ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $response->set_status( 201 ); + return $response; + } + + /** + * Delete meta from an object. + * + * @param int $id Object ID + * @param int $mid Metadata ID + * @return array|WP_Error Message on success, WP_Error otherwise + */ + public function delete_meta( $id, $mid ) { + $check = $this->check_object( $id ); + if ( is_wp_error( $check ) ) { + return $check; + } + + $parent_column = $this->get_parent_column(); + $current = get_metadata_by_mid( $this->type, $mid ); + + if ( empty( $current ) ) { + return new WP_Error( 'json_meta_invalid_id', __( 'Invalid meta ID.' ), array( 'status' => 404 ) ); + } + + if ( absint( $current->$parent_column ) !== (int) $id ) { + return new WP_Error( 'json_meta_' . $this->type . '_mismatch', __( 'Meta does not belong to this object' ), array( 'status' => 400 ) ); + } + + // for now let's not allow updating of arrays, objects or serialized values. + if ( ! $this->is_valid_meta_data( $current->meta_value ) ) { + $code = ( $this->type === 'post' ) ? 'json_post_invalid_action' : 'json_meta_invalid_action'; + return new WP_Error( $code, __( 'Invalid existing meta data for action.' ), array( 'status' => 400 ) ); + } + + if ( is_protected_meta( $current->meta_key ) ) { + return new WP_Error( 'json_meta_protected', sprintf( __( '%s is marked as a protected field.' ), $current->meta_key ), array( 'status' => 403 ) ); + } + + if ( ! delete_metadata_by_mid( $this->type, $mid ) ) { + return new WP_Error( 'json_meta_could_not_delete', __( 'Could not delete meta.' ), array( 'status' => 500 ) ); + } + + return array( 'message' => __( 'Deleted meta' ) );; + } +} diff --git a/lib/class-wp-json-pages.php b/lib/class-wp-json-pages.php index 50d3ff2ab7..e460113f9d 100644 --- a/lib/class-wp-json-pages.php +++ b/lib/class-wp-json-pages.php @@ -58,12 +58,16 @@ public function register_routes( $routes ) { * Retrieve a page by path name * * @param string $path + * @param string $context + * + * @return array|WP_Error */ public function get_post_by_path( $path, $context = 'view' ) { $post = get_page_by_path( $path, ARRAY_A ); - if ( empty( $post ) ) + if ( empty( $post ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } return $this->get_post( $post['ID'], $context ); } @@ -71,13 +75,18 @@ public function get_post_by_path( $path, $context = 'view' ) { /** * Edit a page by path name * - * @param string $path + * @param $path + * @param $data + * @param array $_headers + * + * @return true|WP_Error */ public function edit_post_by_path( $path, $data, $_headers = array() ) { $post = get_page_by_path( $path, ARRAY_A ); - if ( empty( $post ) ) + if ( empty( $post ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } return $this->edit_post( $post['ID'], $data, $_headers ); } @@ -85,13 +94,17 @@ public function edit_post_by_path( $path, $data, $_headers = array() ) { /** * Delete a page by path name * - * @param string $path + * @param $path + * @param bool $force + * + * @return true|WP_Error */ public function delete_post_by_path( $path, $force = false ) { $post = get_page_by_path( $path, ARRAY_A ); - if ( empty( $post ) ) + if ( empty( $post ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } return $this->delete_post( $post['ID'], $force ); } @@ -100,7 +113,7 @@ public function delete_post_by_path( $path, $force = false ) { * Prepare post data * * @param array $post The unprepared post data - * @param array $fields The subset of post type fields to return + * @param string $context The context for the prepared post. (view|view-revision|edit|embed|single-parent) * @return array The prepared post data */ protected function prepare_post( $post, $context = 'view' ) { @@ -109,9 +122,10 @@ protected function prepare_post( $post, $context = 'view' ) { // Override entity meta keys with the correct links $_post['meta']['links']['self'] = json_url( $this->base . '/' . get_page_uri( $post['ID'] ) ); - if ( ! empty( $post['post_parent'] ) ) + if ( ! empty( $post['post_parent'] ) ) { $_post['meta']['links']['up'] = json_url( $this->base . '/' . get_page_uri( (int) $post['post_parent'] ) ); + } return apply_filters( 'json_prepare_page', $_post, $post, $context ); } -} \ No newline at end of file +} diff --git a/lib/class-wp-json-posts.php b/lib/class-wp-json-posts.php index e371210ea6..fb16668f75 100644 --- a/lib/class-wp-json-posts.php +++ b/lib/class-wp-json-posts.php @@ -15,6 +15,8 @@ class WP_JSON_Posts { */ public function __construct(WP_JSON_ResponseHandler $server) { $this->server = $server; + + $this->comments = new WP_JSON_Comments(); } /** @@ -26,37 +28,69 @@ public function __construct(WP_JSON_ResponseHandler $server) { public function register_routes( $routes ) { $post_routes = array( // Post endpoints - '/posts' => array( - array( array( $this, 'get_posts' ), WP_JSON_Server::READABLE ), - array( array( $this, 'new_post' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + '/posts' => array( + array( array( $this, 'get_posts' ), WP_JSON_Server::READABLE ), + array( array( $this, 'create_post' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), ), '/posts/(?P\d+)' => array( - array( array( $this, 'get_post' ), WP_JSON_Server::READABLE ), - array( array( $this, 'edit_post' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), - array( array( $this, 'delete_post' ), WP_JSON_Server::DELETABLE ), - ), - '/posts/(?P\d+)/revisions' => array( '__return_null', WP_JSON_Server::READABLE ), - - // Comments - '/posts/(?P\d+)/comments' => array( - array( array( $this, 'get_comments' ), WP_JSON_Server::READABLE ), - array( '__return_null', WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'get_post' ), WP_JSON_Server::READABLE ), + array( array( $this, 'edit_post' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'delete_post' ), WP_JSON_Server::DELETABLE ), ), - '/posts/(?P\d+)/comments/(?P\d+)' => array( - array( array( $this, 'get_comment' ), WP_JSON_Server::READABLE ), - array( '__return_null', WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), - array( '__return_null', WP_JSON_Server::DELETABLE ), + '/posts/(?P\d+)/revisions' => array( + array( $this, 'get_revisions' ), WP_JSON_Server::READABLE ), // Meta-post endpoints - '/posts/types' => array( array( $this, 'get_post_types' ), WP_JSON_Server::READABLE ), - '/posts/types/(?P\w+)' => array( array( $this, 'get_post_type' ), WP_JSON_Server::READABLE ), - '/posts/statuses' => array( array( $this, 'get_post_statuses' ), WP_JSON_Server::READABLE ), + '/posts/types' => array( + array( $this, 'get_post_types' ), WP_JSON_Server::READABLE + ), + '/posts/types/(?P\w+)' => array( + array( $this, 'get_post_type' ), WP_JSON_Server::READABLE + ), + '/posts/statuses' => array( + array( $this, 'get_post_statuses' ), WP_JSON_Server::READABLE + ), ); + $post_routes = $this->comments->register_routes( $post_routes ); + return array_merge( $routes, $post_routes ); } + /** + * Get revisions for a specific post. + * + * @param int $id Post ID + * @uses wp_get_post_revisions + * @return WP_JSON_Response + */ + public function get_revisions( $id ) { + $id = (int) $id; + + $parent = get_post( $id, ARRAY_A ); + + if ( empty( $id ) || empty( $parent['ID'] ) ) { + return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } + + if ( ! json_check_post_permission( $parent, 'edit' ) ) { + return new WP_Error( 'json_cannot_view', __( 'Sorry, you cannot view the revisions for this post.' ), array( 'status' => 403 ) ); + } + + // Todo: Query args filter for wp_get_post_revisions + $revisions = wp_get_post_revisions( $id ); + + $struct = array(); + foreach ( $revisions as $revision ) { + $post = get_object_vars( $revision ); + + $struct[] = $this->prepare_post( $post, 'view-revision' ); + } + + return $struct; + } + /** * Retrieve posts. * @@ -66,27 +100,33 @@ public function register_routes( $routes ) { * Accepted keys are 'post_type', 'post_status', 'number', 'offset', * 'orderby', and 'order'. * - * The optional $fields parameter specifies what fields will be included - * in the response array. - * * @uses wp_get_recent_posts() - * @see WP_JSON_Posts::get_post() for more on $fields * @see get_posts() for more on $filter values * - * @param array $filter optional - * @param array $fields optional - * @return array contains a collection of Post entities. + * @param array $filter Parameters to pass through to `WP_Query` + * @param string $context The context; 'view' (default) or 'edit'. + * @param string|array $type Post type slug, or array of slugs + * @param int $page Page number (1-indexed) + * @return stdClass[] Collection of Post entities */ public function get_posts( $filter = array(), $context = 'view', $type = 'post', $page = 1 ) { $query = array(); - $post_type = get_post_type_object( $type ); - if ( ! ( (bool) $post_type ) ) - return new WP_Error( 'json_invalid_post_type', __( 'The post type specified is not valid' ), array( 'status' => 403 ) ); + // Validate post types and permissions + $query['post_type'] = array(); + + foreach ( (array) $type as $type_name ) { + $post_type = get_post_type_object( $type_name ); - $query['post_type'] = $post_type->name; + if ( ! ( (bool) $post_type ) || ! $post_type->show_in_json ) { + return new WP_Error( 'json_invalid_post_type', sprintf( __( 'The post type "%s" is not valid' ), $type_name ), array( 'status' => 403 ) ); + } + + $query['post_type'][] = $post_type->name; + } global $wp; + // Allow the same as normal WP $valid_vars = apply_filters('query_vars', $wp->public_query_vars); @@ -96,23 +136,23 @@ public function get_posts( $filter = array(), $context = 'view', $type = 'post', // To disable anyway, use `add_filter('json_private_query_vars', '__return_empty_array');` if ( current_user_can( $post_type->cap->edit_posts ) ) { - $private = apply_filters('json_private_query_vars', $wp->private_query_vars); - $valid_vars = array_merge($valid_vars, $private); + $private = apply_filters( 'json_private_query_vars', $wp->private_query_vars ); + $valid_vars = array_merge( $valid_vars, $private ); } // Define our own in addition to WP's normal vars - $json_valid = array('posts_per_page'); - $valid_vars = array_merge($valid_vars, $json_valid); + $json_valid = array( 'posts_per_page' ); + $valid_vars = array_merge( $valid_vars, $json_valid ); // Filter and flip for querying - $valid_vars = apply_filters('json_query_vars', $valid_vars); - $valid_vars = array_flip($valid_vars); + $valid_vars = apply_filters( 'json_query_vars', $valid_vars ); + $valid_vars = array_flip( $valid_vars ); // Exclude the post_type query var to avoid dodging the permission // check above - unset($valid_vars['post_type']); + unset( $valid_vars['post_type'] ); - foreach ($valid_vars as $var => $index) { + foreach ( $valid_vars as $var => $index ) { if ( isset( $filter[ $var ] ) ) { $query[ $var ] = apply_filters( 'json_query_var-' . $var, $filter[ $var ] ); } @@ -123,7 +163,7 @@ public function get_posts( $filter = array(), $context = 'view', $type = 'post', $post_query = new WP_Query(); $posts_list = $post_query->query( $query ); - $response = new WP_JSON_Response(); + $response = new WP_JSON_Response(); $response->query_navigation_headers( $post_query ); if ( ! $posts_list ) { @@ -140,47 +180,21 @@ public function get_posts( $filter = array(), $context = 'view', $type = 'post', $post = get_object_vars( $post ); // Do we have permission to read this post? - if ( ! $this->check_read_permission( $post ) ) + if ( ! json_check_post_permission( $post, 'read' ) ) { continue; + } $response->link_header( 'item', json_url( '/posts/' . $post['ID'] ), array( 'title' => $post['post_title'] ) ); - $struct[] = $this->prepare_post( $post, $context ); - } - $response->set_data( $struct ); - - return $response; - } - - /** - * Check if we can read a post - * - * Correctly handles posts with the inherit status. - * @param array $post Post data - * @return boolean Can we read it? - */ - protected function check_read_permission( $post ) { - // Can we read the post? - $post_type = get_post_type_object( $post['post_type'] ); - if ( 'publish' === $post['post_status'] || current_user_can( $post_type->cap->read_post, $post['ID'] ) ) { - return true; - } - - // Can we read the parent if we're inheriting? - if ( 'inherit' === $post['post_status'] && $post['post_parent'] > 0 ) { - $parent = get_post( $post['post_parent'], ARRAY_A ); - - if ( $this->check_read_permission( $parent ) ) { - return true; + $post_data = $this->prepare_post( $post, $context ); + if ( is_wp_error( $post_data ) ) { + continue; } - } - // If we don't have a parent, but the status is set to inherit, assume - // it's published (as per get_post_status()) - if ( 'inherit' === $post['post_status'] ) { - return true; + $struct[] = $post_data; } + $response->set_data( $struct ); - return false; + return $response; } /** @@ -210,7 +224,7 @@ protected function check_read_permission( $post ) { * - any other fields supported by wp_insert_post() * @return array Post data (see {@see WP_JSON_Posts::get_post}) */ - function new_post( $data ) { + public function create_post( $data ) { unset( $data['ID'] ); $result = $this->insert_post( $data ); @@ -218,34 +232,58 @@ function new_post( $data ) { return $result; } - $response = $this->get_post( $result ); + $response = json_ensure_response( $this->get_post( $result, 'edit' ) ); $response->set_status( 201 ); $response->header( 'Location', json_url( '/posts/' . $result ) ); + return $response; } + /** + * Create a new post for any registered post type. + * + * @deprecated + * @internal 'data' is used here rather than 'content', as get_default_post_to_edit uses $_REQUEST['content'] + * + * @param array $content Content data. (see {@see WP_JSON_Posts::create_post}) + * @return array Post data (see {@see WP_JSON_Posts::get_post}) + */ + public function new_post( $data ) { + _deprecated_function( __CLASS__ . '::' . __METHOD__, 'WPAPI-1.2', 'WP_JSON_Posts::create_post' ); + + return $this->create_post( $data ); + } + /** * Retrieve a post. * * @uses get_post() * @param int $id Post ID - * @param array $fields Post fields to return (optional) + * @param string $context The context; 'view' (default) or 'edit'. * @return array Post entity */ public function get_post( $id, $context = 'view' ) { $id = (int) $id; - if ( empty( $id ) ) - return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); - $post = get_post( $id, ARRAY_A ); - if ( empty( $post['ID'] ) ) + if ( empty( $id ) || empty( $post['ID'] ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } - $post_type = get_post_type_object( $post['post_type'] ); - if ( ! $this->check_read_permission( $post ) ) + $checked_permission = 'read'; + if ( 'inherit' === $post['post_status'] && $post['post_parent'] > 0 ) { + $checked_post = get_post( $post['post_parent'], ARRAY_A ); + if ( 'revision' === $post['post_type'] ) { + $checked_permission = 'edit'; + } + } else { + $checked_post = $post; + } + + if ( ! json_check_post_permission( $checked_post, $checked_permission ) ) { return new WP_Error( 'json_user_cannot_read', __( 'Sorry, you cannot read this post.' ), array( 'status' => 401 ) ); + } // Link headers (see RFC 5988) @@ -253,15 +291,18 @@ public function get_post( $id, $context = 'view' ) { $response->header( 'Last-Modified', mysql2date( 'D, d M Y H:i:s', $post['post_modified_gmt'] ) . 'GMT' ); $post = $this->prepare_post( $post, $context ); - if ( is_wp_error( $post ) ) + + if ( is_wp_error( $post ) ) { return $post; + } foreach ( $post['meta']['links'] as $rel => $url ) { $response->link_header( $rel, $url ); } - $response->link_header( 'alternate', get_permalink( $id ), array( 'type' => 'text/html' ) ); + $response->link_header( 'alternate', get_permalink( $id ), array( 'type' => 'text/html' ) ); $response->set_data( $post ); + return $response; } @@ -275,30 +316,30 @@ public function get_post( $id, $context = 'view' ) { * @internal 'data' is used here rather than 'content', as get_default_post_to_edit uses $_REQUEST['content'] * * @param int $id Post ID to edit - * @param array $data Data construct, see {@see WP_JSON_Posts::new_post} + * @param array $data Data construct, see {@see WP_JSON_Posts::create_post} * @param array $_headers Header data * @return true on success */ - function edit_post( $id, $data, $_headers = array() ) { + public function edit_post( $id, $data, $_headers = array() ) { $id = (int) $id; - if ( empty( $id ) ) - return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); - $post = get_post( $id, ARRAY_A ); - if ( empty( $post['ID'] ) ) + if ( empty( $id ) || empty( $post['ID'] ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } if ( isset( $_headers['IF_UNMODIFIED_SINCE'] ) ) { // As mandated by RFC2616, we have to check all of RFC1123, RFC1036 // and C's asctime() format (and ignore invalid headers) $formats = array( DateTime::RFC1123, DateTime::RFC1036, 'D M j H:i:s Y' ); + foreach ( $formats as $format ) { $check = WP_JSON_DateTime::createFromFormat( $format, $_headers['IF_UNMODIFIED_SINCE'] ); - if ( $check !== false ) + if ( $check !== false ) { break; + } } // If the post has been modified since the date provided, return an error. @@ -314,7 +355,7 @@ function edit_post( $id, $data, $_headers = array() ) { return $retval; } - return $this->getPost( $id ); + return $this->get_post( $id, 'edit' ); } /** @@ -327,61 +368,30 @@ function edit_post( $id, $data, $_headers = array() ) { public function delete_post( $id, $force = false ) { $id = (int) $id; - if ( empty( $id ) ) - return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); - $post = get_post( $id, ARRAY_A ); - if ( empty( $post['ID'] ) ) + if ( empty( $id ) || empty( $post['ID'] ) ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404 ) ); + } - $post_type = get_post_type_object( $post['post_type'] ); - if ( ! current_user_can( $post_type->cap->delete_post, $id ) ) + if ( ! json_check_post_permission( $post, 'delete' ) ) { return new WP_Error( 'json_user_cannot_delete_post', __( 'Sorry, you are not allowed to delete this post.' ), array( 'status' => 401 ) ); + } $result = wp_delete_post( $id, $force ); - if ( ! $result ) + if ( ! $result ) { return new WP_Error( 'json_cannot_delete', __( 'The post cannot be deleted.' ), array( 'status' => 500 ) ); + } if ( $force ) { return array( 'message' => __( 'Permanently deleted post' ) ); - } - else { + } else { // TODO: return a HTTP 202 here instead return array( 'message' => __( 'Deleted post' ) ); } } - /** - * Retrieve comments - * - * @param int $id Post ID to retrieve comments for - * @return array List of Comment entities - */ - public function get_comments( $id ) { - //$args = array('status' => $status, 'post_id' => $id, 'offset' => $offset, 'number' => $number )l - $comments = get_comments( array('post_id' => $id) ); - - $struct = array(); - foreach ( $comments as $comment ) { - $struct[] = $this->prepare_comment( $comment, array( 'comment', 'meta' ), 'collection' ); - } - return $struct; - } - - /** - * Retrieve a single comment - * - * @param int $comment Comment ID - * @return array Comment entity - */ - public function get_comment( $comment ) { - $comment = get_comment( $comment ); - $data = $this->prepare_comment( $comment ); - return $data; - } - /** * Get all public post types * @@ -392,10 +402,12 @@ public function get_post_types() { $data = get_post_types( array(), 'objects' ); $types = array(); + foreach ($data as $name => $type) { $type = $this->get_post_type( $type, true ); - if ( is_wp_error( $type ) ) + if ( is_wp_error( $type ) ) { continue; + } $types[ $name ] = $type; } @@ -407,42 +419,54 @@ public function get_post_types() { * Get a post type * * @param string|object $type Type name, or type object (internal use) - * @param boolean $_in_collection Is this in a collection? (internal use) + * @param boolean $context What context are we in? * @return array Post type data */ - public function get_post_type( $type, $_in_collection = false ) { - if ( ! is_object( $type ) ) - $type = get_post_type_object($type); + public function get_post_type( $type, $context = 'view' ) { + if ( ! is_object( $type ) ) { + $type = get_post_type_object( $type ); + } - if ( $type->public === false ) + if ( $type->show_in_json === false ) { return new WP_Error( 'json_cannot_read_type', __( 'Cannot view post type' ), array( 'status' => 403 ) ); + } + + if ( $context === true ) { + $context = 'embed'; + _deprecated_argument( __CLASS__ . '::' . __FUNCTION__, 'WPAPI-1.1', '$context should be set to "embed" rather than true' ); + } $data = array( - 'name' => $type->label, - 'slug' => $type->name, - 'description' => $type->description, - 'labels' => $type->labels, - 'queryable' => $type->publicly_queryable, - 'searchable' => ! $type->exclude_from_search, + 'name' => $type->label, + 'slug' => $type->name, + 'description' => $type->description, + 'labels' => $type->labels, + 'queryable' => $type->publicly_queryable, + 'searchable' => ! $type->exclude_from_search, 'hierarchical' => $type->hierarchical, - 'meta' => array( - 'links' => array() + 'meta' => array( + 'links' => array( + 'self' => json_url( '/posts/types/' . $type->name ), + 'collection' => json_url( '/posts/types' ), + ), ), ); - if ( $_in_collection ) - $data['meta']['links']['self'] = json_url( '/posts/types/' . $type->name ); - else - $data['meta']['links']['collection'] = json_url( '/posts/types' ); + // Add taxonomy link + $relation = 'http://wp-api.org/1.1/collections/taxonomy/'; + $url = json_url( '/taxonomies' ); + $url = add_query_arg( 'type', $type->name, $url ); + $data['meta']['links'][ $relation ] = $url; if ( $type->publicly_queryable ) { - if ($type->name === 'post') + if ( $type->name === 'post' ) { $data['meta']['links']['archives'] = json_url( '/posts' ); - else + } else { $data['meta']['links']['archives'] = json_url( add_query_arg( 'type', $type->name, '/posts' ) ); + } } - return apply_filters( 'json_post_type_data', $data, $type ); + return apply_filters( 'json_post_type_data', $data, $type, $context ); } /** @@ -454,27 +478,30 @@ public function get_post_statuses() { $statuses = get_post_stati(array(), 'objects'); $data = array(); + foreach ($statuses as $status) { - if ( $status->internal === true || ! $status->show_in_admin_status_list ) + if ( $status->internal === true || ! $status->show_in_admin_status_list ) { continue; + } $data[ $status->name ] = array( - 'name' => $status->label, - 'slug' => $status->name, - 'public' => $status->public, - 'protected' => $status->protected, - 'private' => $status->private, - 'queryable' => $status->publicly_queryable, + 'name' => $status->label, + 'slug' => $status->name, + 'public' => $status->public, + 'protected' => $status->protected, + 'private' => $status->private, + 'queryable' => $status->publicly_queryable, 'show_in_list' => $status->show_in_admin_all_list, - 'meta' => array( + 'meta' => array( 'links' => array() ), ); if ( $status->publicly_queryable ) { - if ($status->name === 'publish') + if ( $status->name === 'publish' ) { $data[ $status->name ]['meta']['links']['archives'] = json_url( '/posts' ); - else + } else { $data[ $status->name ]['meta']['links']['archives'] = json_url( add_query_arg( 'status', $status->name, '/posts' ) ); + } } } @@ -487,30 +514,54 @@ public function get_post_statuses() { * @access protected * * @param array $post The unprepared post data - * @param array $fields The subset of post type fields to return + * @param string $context The context for the prepared post. (view|view-revision|edit|embed|single-parent) * @return array The prepared post data */ protected function prepare_post( $post, $context = 'view' ) { - // holds the data for this post. built up based on $fields - $_post = array( - 'ID' => (int) $post['ID'], - ); + // Holds the data for this post. + $_post = array( 'ID' => (int) $post['ID'] ); $post_type = get_post_type_object( $post['post_type'] ); - if ( ! $this->check_read_permission( $post ) ) + + if ( ! json_check_post_permission( $post, 'read' ) ) { return new WP_Error( 'json_user_cannot_read', __( 'Sorry, you cannot read this post.' ), array( 'status' => 401 ) ); + } + + $previous_post = null; + if ( ! empty( $GLOBALS['post'] ) ) { + $previous_post = $GLOBALS['post']; + } + $post_obj = get_post( $post['ID'] ); + + // Don't allow unauthenticated users to read password-protected posts + if ( ! empty( $post['post_password'] ) ) { + if ( ! json_check_post_permission( $post, 'edit' ) ) { + return new WP_Error( 'json_user_cannot_read', __( 'Sorry, you cannot read this post.' ), array( 'status' => 403 ) ); + } + + // 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( $post['post_password'] ); + $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] = wp_slash( $value ); + } + + $GLOBALS['post'] = $post_obj; + setup_postdata( $post_obj ); // prepare common post fields $post_fields = array( - 'title' => get_the_title( $post['ID'] ), // $post['post_title'], - 'status' => $post['post_status'], - 'type' => $post['post_type'], - 'author' => (int) $post['post_author'], - 'content' => apply_filters( 'the_content', $post['post_content'] ), - 'parent' => (int) $post['post_parent'], - #'post_mime_type' => $post['post_mime_type'], - 'link' => get_permalink( $post['ID'] ), + 'title' => get_the_title( $post['ID'] ), // $post['post_title'], + 'status' => $post['post_status'], + 'type' => $post['post_type'], + 'author' => (int) $post['post_author'], + 'content' => apply_filters( 'the_content', $post['post_content'] ), + 'parent' => (int) $post['post_parent'], + #'post_mime_type' => $post['post_mime_type'], + 'link' => get_permalink( $post['ID'] ), ); + $post_fields_extended = array( 'slug' => $post['post_name'], 'guid' => apply_filters( 'get_the_guid', $post['guid'] ), @@ -520,49 +571,71 @@ protected function prepare_post( $post, $context = 'view' ) { 'ping_status' => $post['ping_status'], 'sticky' => ( $post['post_type'] === 'post' && is_sticky( $post['ID'] ) ), ); + $post_fields_raw = array( 'title_raw' => $post['post_title'], 'content_raw' => $post['post_content'], + 'excerpt_raw' => $post['post_excerpt'], 'guid_raw' => $post['guid'], + 'post_meta' => $this->handle_get_post_meta( $post['ID'] ), ); // Dates - $timezone = $this->server->get_timezone(); + $timezone = json_get_timezone(); - $date = WP_JSON_DateTime::createFromFormat( 'Y-m-d H:i:s', $post['post_date'], $timezone ); - $post_fields['date'] = $date->format( 'c' ); - $post_fields_extended['date_tz'] = $date->format( 'e' ); - $post_fields_extended['date_gmt'] = date( 'c', strtotime( $post['post_date_gmt'] ) ); + if ( $post['post_date_gmt'] === '0000-00-00 00:00:00' ) { + $post_fields['date'] = null; + $post_fields_extended['date_tz'] = null; + $post_fields_extended['date_gmt'] = null; + } + else { + $post_date = WP_JSON_DateTime::createFromFormat( 'Y-m-d H:i:s', $post['post_date'], $timezone ); + $post_fields['date'] = json_mysql_to_rfc3339( $post['post_date'] ); + $post_fields_extended['date_tz'] = $post_date->format( 'e' ); + $post_fields_extended['date_gmt'] = json_mysql_to_rfc3339( $post['post_date_gmt'] ); + } - $modified = WP_JSON_DateTime::createFromFormat( 'Y-m-d H:i:s', $post['post_modified'], $timezone ); - $post_fields['modified'] = $modified->format( 'c' ); - $post_fields_extended['modified_tz'] = $modified->format( 'e' ); - $post_fields_extended['modified_gmt'] = date( 'c', strtotime( $post['post_modified_gmt'] ) ); + if ( $post['post_modified_gmt'] === '0000-00-00 00:00:00' ) { + $post_fields['modified'] = null; + $post_fields_extended['modified_tz'] = null; + $post_fields_extended['modified_gmt'] = null; + } + else { + $modified_date = WP_JSON_DateTime::createFromFormat( 'Y-m-d H:i:s', $post['post_modified'], $timezone ); + $post_fields['modified'] = json_mysql_to_rfc3339( $post['post_modified'] ); + $post_fields_extended['modified_tz'] = $modified_date->format( 'e' ); + $post_fields_extended['modified_gmt'] = json_mysql_to_rfc3339( $post['post_modified_gmt'] ); + } // Authorized fields // TODO: Send `Vary: Authorization` to clarify that the data can be // changed by the user's auth status - if ( current_user_can( $post_type->cap->edit_post, $post['ID'] ) ) { + if ( json_check_post_permission( $post, 'edit' ) ) { $post_fields_extended['password'] = $post['post_password']; } // Consider future posts as published - if ( $post_fields['status'] === 'future' ) + if ( $post_fields['status'] === 'future' ) { $post_fields['status'] = 'publish'; + } // Fill in blank post format $post_fields['format'] = get_post_format( $post['ID'] ); - if ( empty( $post_fields['format'] ) ) + + if ( empty( $post_fields['format'] ) ) { $post_fields['format'] = 'standard'; + } - $post_fields['author'] = $this->prepare_author( $post['post_author'] ); + if ( 0 === $post['post_parent'] ) { + $post_fields['parent'] = null; + } - if ( 'view' === $context && 0 !== $post['post_parent'] ) { + if ( ( 'view' === $context || 'view-revision' == $context ) && 0 !== $post['post_parent'] ) { // Avoid nesting too deeply // This gives post + post-extended + meta for the main post, // post + meta for the parent and just meta for the grandparent $parent = get_post( $post['post_parent'], ARRAY_A ); - $post_fields['parent'] = $this->prepare_post( $parent, 'parent' ); + $post_fields['parent'] = $this->prepare_post( $parent, 'embed' ); } // Merge requested $post_fields fields into $_post @@ -571,28 +644,50 @@ protected function prepare_post( $post, $context = 'view' ) { // Include extended fields. We might come back to this. $_post = array_merge( $_post, $post_fields_extended ); - if ( 'edit' === $context && current_user_can( $post_type->cap->edit_post, $post['ID'] ) ) - $_post = array_merge( $_post, $post_fields_raw ); - elseif ( 'edit' === $context ) - return new WP_Error( 'json_cannot_edit', __( 'Sorry, you cannot edit this post' ), array( 'status' => 403 ) ); - - // Post meta - $_post['post_meta'] = $this->prepare_meta( $post['ID'] ); + if ( 'edit' === $context ) { + if ( json_check_post_permission( $post, 'edit' ) ) { + $_post = array_merge( $_post, $post_fields_raw ); + } else { + $GLOBALS['post'] = $previous_post; + if ( $previous_post ) { + setup_postdata( $previous_post ); + } + return new WP_Error( 'json_cannot_edit', __( 'Sorry, you cannot edit this post' ), array( 'status' => 403 ) ); + } + } elseif ( 'view-revision' == $context ) { + if ( json_check_post_permission( $post, 'edit' ) ) { + $_post = array_merge( $_post, $post_fields_raw ); + } else { + $GLOBALS['post'] = $previous_post; + if ( $previous_post ) { + setup_postdata( $previous_post ); + } + return new WP_Error( 'json_cannot_view', __( 'Sorry, you cannot view this revision' ), array( 'status' => 403 ) ); + } + } // Entity meta - $_post['meta'] = array( - 'links' => array( - 'self' => json_url( '/posts/' . $post['ID'] ), - 'author' => json_url( '/users/' . $post['post_author'] ), - 'collection' => json_url( '/posts' ), - 'replies' => json_url( '/posts/' . $post['ID'] . '/comments' ), - 'version-history' => json_url( '/posts/' . $post['ID'] . '/revisions' ), - ), + $links = array( + 'self' => json_url( '/posts/' . $post['ID'] ), + 'author' => json_url( '/users/' . $post['post_author'] ), + 'collection' => json_url( '/posts' ), ); - if ( ! empty( $post['post_parent'] ) ) + if ( 'view-revision' != $context ) { + $links['replies'] = json_url( '/posts/' . $post['ID'] . '/comments' ); + $links['version-history'] = json_url( '/posts/' . $post['ID'] . '/revisions' ); + } + + $_post['meta'] = array( 'links' => $links ); + + if ( ! empty( $post['post_parent'] ) ) { $_post['meta']['links']['up'] = json_url( '/posts/' . (int) $post['post_parent'] ); + } + $GLOBALS['post'] = $previous_post; + if ( $previous_post ) { + setup_postdata( $previous_post ); + } return apply_filters( 'json_prepare_post', $_post, $post, $context ); } @@ -607,169 +702,153 @@ protected function prepare_excerpt( $excerpt ) { } $excerpt = apply_filters( 'the_excerpt', apply_filters( 'get_the_excerpt', $excerpt ) ); + if ( empty( $excerpt ) ) { return null; } + return $excerpt; } /** - * Retrieve custom fields for post. + * Embed post type data into taxonomy data * - * @since 2.5.0 - * - * @param int $post_id Post ID. - * @return array Custom fields, if exist. + * @uses self::get_post_type() + * @param array $data Taxonomy data + * @param array $taxonomy Internal taxonomy data + * @param string $context Context (view|embed) + * @return array Filtered data */ - protected function prepare_meta( $post_id ) { - $post_id = (int) $post_id; - - $custom_fields = (array) get_post_meta( $post_id ); - - foreach ( $custom_fields as $meta_key => $meta_value ) { - // Don't expose protected fields. - if ( is_protected_meta( $meta_key ) ) - unset( $custom_fields[$meta_key] ); - } - - return apply_filters( 'json_prepare_meta', $custom_fields ); - } - - protected function prepare_author( $author ) { - $user = get_user_by( 'id', $author ); - - if (! $author || ! is_object( $user ) ) { - return null; + public function add_post_type_data( $data, $taxonomy, $context = 'view' ) { + if ( $context !== 'embed' ) { + $data['types'] = array(); + foreach( $taxonomy->object_type as $type ) { + $data['types'][ $type ] = $this->get_post_type( $type, 'embed' ); + } } - - $author = array( - 'ID' => $user->ID, - 'name' => $user->display_name, - 'slug' => $user->user_nicename, - 'URL' => $user->user_url, - 'avatar' => $this->server->get_avatar_url( $user->user_email ), - 'meta' => array( - 'links' => array( - 'self' => json_url( '/users/' . $user->ID ), - 'archives' => json_url( '/users/' . $user->ID . '/posts' ), - ), - ), - ); - - if ( current_user_can( 'edit_user', $user->ID ) ) { - $author['first_name'] = $user->first_name; - $author['last_name'] = $user->last_name; - } - return $author; + return $data; } /** - * Helper method for wp_newPost and wp_editPost, containing shared logic. + * Helper method for {@see create_post} and {@see edit_post}, containing shared logic. * - * @since 3.4.0 - * @uses wp_insert_post() * - * @param WP_User $user The post author if post_author isn't set in $content_struct. - * @param array $content_struct Post data to insert. + * @param array $data Post data to insert. + * + * @return int|WP_Error */ - protected function insert_post( $data ) { - $post = array(); + protected function insert_post( array $data ) { + $post = array(); $update = ! empty( $data['ID'] ); if ( $update ) { $current_post = get_post( absint( $data['ID'] ) ); - if ( ! $current_post ) + + if ( ! $current_post ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 400 ) ); + } + $post['ID'] = absint( $data['ID'] ); - } - else { + } else { // Defaults - $post['post_author'] = 0; + $post['post_author'] = 0; $post['post_password'] = ''; - $post['post_excerpt'] = ''; - $post['post_content'] = ''; - $post['post_title'] = ''; + $post['post_excerpt'] = ''; + $post['post_content'] = ''; + $post['post_title'] = ''; } // Post type if ( ! empty( $data['type'] ) ) { // Changing post type $post_type = get_post_type_object( $data['type'] ); - if ( ! $post_type ) + + if ( ! $post_type ) { return new WP_Error( 'json_invalid_post_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); + } $post['post_type'] = $data['type']; - } - elseif ( $update ) { + } elseif ( $update ) { // Updating post, use existing post type $current_post = get_post( $data['ID'] ); - if ( ! $current_post ) + + if ( ! $current_post ) { return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 400 ) ); + } $post_type = get_post_type_object( $current_post->post_type ); - } - else { + $post['post_type'] = $current_post->post_type; + } else { // Creating new post, use default type $post['post_type'] = apply_filters( 'json_insert_default_post_type', 'post' ); $post_type = get_post_type_object( $post['post_type'] ); - if ( ! $post_type ) + + if ( ! $post_type ) { return new WP_Error( 'json_invalid_post_type', __( 'Invalid post type' ), array( 'status' => 400 ) ); + } } // Permissions check if ( $update ) { - if ( ! current_user_can( $post_type->cap->edit_post, $data['ID'] ) ) + if ( ! json_check_post_permission( $post, 'edit' ) ) { return new WP_Error( 'json_cannot_edit', __( 'Sorry, you are not allowed to edit this post.' ), array( 'status' => 401 ) ); - if ( $post_type->name != get_post_type( $data['ID'] ) ) + } + + if ( $post_type->name != get_post_type( $data['ID'] ) ) { return new WP_Error( 'json_cannot_change_post_type', __( 'The post type may not be changed.' ), array( 'status' => 400 ) ); + } } else { - if ( ! current_user_can( $post_type->cap->create_posts ) || ! current_user_can( $post_type->cap->edit_posts ) ) - return new WP_Error( 'json_cannot_create', __( 'Sorry, you are not allowed to post on this site.' ), array( 'status' => 400 ) ); + if ( ! json_check_post_permission( $post, 'create' ) ) { + return new WP_Error( 'json_cannot_create', __( 'Sorry, you are not allowed to post on this site.' ), array( 'status' => 403 ) ); + } } // Post status if ( ! empty( $data['status'] ) ) { $post['post_status'] = $data['status']; + switch ( $post['post_status'] ) { case 'draft': case 'pending': break; case 'private': - if ( ! current_user_can( $post_type->cap->publish_posts ) ) + if ( ! json_check_post_permission( $post, 'publish_posts' ) ) { return new WP_Error( 'json_cannot_create_private', __( 'Sorry, you are not allowed to create private posts in this post type' ), array( 'status' => 403 ) ); + } break; case 'publish': case 'future': - if ( ! current_user_can( $post_type->cap->publish_posts ) ) + if ( ! json_check_post_permission( $post, 'publish_posts' ) ) { return new WP_Error( 'json_cannot_publish', __( 'Sorry, you are not allowed to publish posts in this post type' ), array( 'status' => 403 ) ); + } break; default: - if ( ! get_post_status_object( $post['post_status'] ) ) + if ( ! get_post_status_object( $post['post_status'] ) ) { $post['post_status'] = 'draft'; - break; + } + break; } } // Post title - if ( ! empty( $data['title'] ) ) { + if ( isset( $data['title'] ) ) { $post['post_title'] = $data['title']; } // Post date if ( ! empty( $data['date'] ) ) { - list( $post['post_date'], $post['post_date_gmt'] ) = $this->server->get_date_with_gmt( $data['date'] ); - } - elseif ( ! empty( $data['date_gmt'] ) ) { - list( $post['post_date'], $post['post_date_gmt'] ) = $this->server->get_date_with_gmt( $data['date_gmt'], true ); - } + $date_data = json_get_date_with_gmt( $data['date'] ); - // Post modified - if ( ! empty( $data['modified'] ) ) { - list( $post['post_modified'], $post['post_modified_gmt'] ) = $this->server->get_date_with_gmt( $data['modified'] ); - } - elseif ( ! empty( $data['modified_gmt'] ) ) { - list( $post['post_modified'], $post['post_modified_gmt'] ) = $this->server->get_date_with_gmt( $data['modified_gmt'], true ); + if ( ! empty( $date_data ) ) { + list( $post['post_date'], $post['post_date_gmt'] ) = $date_data; + } + } elseif ( ! empty( $data['date_gmt'] ) ) { + $date_data = json_get_date_with_gmt( $data['date_gmt'], true ); + + if ( ! empty( $date_data ) ) { + list( $post['post_date'], $post['post_date_gmt'] ) = $date_data; + } } // Post slug @@ -784,21 +863,22 @@ protected function insert_post( $data ) { if ( empty( $data['author']->ID ) ) { return new WP_Error( 'json_invalid_author', __( 'Invalid author object.' ), array( 'status' => 400 ) ); } - $data['author'] = absint( $data['author']->ID ); - } - else { - $data['author'] = absint( $data['author'] ); + $data['author'] = (int) $data['author']->ID; + } else { + $data['author'] = (int) $data['author']; } // Only check edit others' posts if we are another user if ( $data['author'] !== get_current_user_id() ) { - if ( ! current_user_can( $post_type->cap->edit_others_posts ) ) + if ( ! json_check_post_permission( $post, 'edit_others_posts' ) ) { return new WP_Error( 'json_cannot_edit_others', __( 'You are not allowed to edit posts as this user.' ), array( 'status' => 401 ) ); + } $author = get_userdata( $data['author'] ); - if ( ! $author ) + if ( ! $author ) { return new WP_Error( 'json_invalid_author', __( 'Invalid author ID.' ), array( 'status' => 400 ) ); + } } $post['post_author'] = $data['author']; @@ -807,14 +887,17 @@ protected function insert_post( $data ) { // Post password if ( ! empty( $data['password'] ) ) { $post['post_password'] = $data['password']; - if ( ! current_user_can( $post_type->cap->publish_posts ) ) + + if ( ! json_check_post_permission( $post, 'publish_posts' ) ) { return new WP_Error( 'json_cannot_create_passworded', __( 'Sorry, you are not allowed to create password protected posts in this post type' ), array( 'status' => 401 ) ); + } } // Content and excerpt if ( ! empty( $data['content_raw'] ) ) { $post['post_content'] = $data['content_raw']; } + if ( ! empty( $data['excerpt_raw'] ) ) { $post['post_excerpt'] = $data['excerpt_raw']; } @@ -822,7 +905,11 @@ protected function insert_post( $data ) { // Parent if ( ! empty( $data['parent'] ) ) { $parent = get_post( $data['parent'] ); - $post['post_parent'] = $data['post_parent']; + if ( empty( $parent ) ) { + return new WP_Error( 'json_post_invalid_id', __( 'Invalid post parent ID.' ), array( 'status' => 400 ) ); + } + + $post['post_parent'] = $parent->ID; } // Menu order @@ -843,6 +930,7 @@ protected function insert_post( $data ) { // Post format if ( ! empty( $data['post_format'] ) ) { $formats = get_post_format_slugs(); + if ( ! in_array( $data['post_format'], $formats ) ) { return new WP_Error( 'json_invalid_post_format', __( 'Invalid post format.' ), array( 'status' => 400 ) ); } @@ -851,6 +939,7 @@ protected function insert_post( $data ) { // Pre-insert hook $can_insert = apply_filters( 'json_pre_insert_post', true, $post, $data, $update ); + if ( is_wp_error( $can_insert ) ) { return $can_insert; } @@ -863,12 +952,26 @@ protected function insert_post( $data ) { return $post_ID; } + // If this is a new post, add the post ID to $post + if ( ! $update ) { + $post['ID'] = $post_ID; + } + + // Post meta + if ( ! empty( $data['post_meta'] ) ) { + $result = $this->handle_post_meta_action( $post_ID, $data ); + if ( is_wp_error( $result ) ) { + return $result; + } + } + // Sticky - if ( isset( $post['sticky'] ) ) { - if ( $post['sticky'] ) - stick_post( $data['ID'] ); - else - unstick_post( $data['ID'] ); + if ( isset( $data['sticky'] ) ) { + if ( $data['sticky'] ) { + stick_post( $post_ID ); + } else { + unstick_post( $post_ID ); + } } do_action( 'json_insert_post', $post, $data, $update ); @@ -877,167 +980,232 @@ protected function insert_post( $data ) { } /** - * Parse an RFC3339 timestamp into a DateTime + * Delete a comment. + * + * @deprecated WPAPI-1.2 * - * @param string $date RFC3339 timestamp - * @param boolean $force_utc Force UTC timezone instead of using the timestamp's TZ? - * @return DateTime + * @param int $id + * @param int $comment + * @param boolean $force + * @return array|WP_Error */ - protected function parse_date( $date, $force_utc = false ) { - // Default timezone to the server's current one - $timezone = self::get_timezone(); - if ( $force_utc ) { - $date = preg_replace( '/[+-]\d+:?\d+$/', '+00:00', $date ); - $timezone = new DateTimeZone( 'UTC' ); - } + public function delete_comment( $id, $comment, $force = false ) { + _deprecated_function( __CLASS__ . '::' . __METHOD__, 'WPAPI-1.2', 'WP_JSON_Comments::delete_comment' ); - // Strip millisecond precision (a full stop followed by one or more digits) - if ( strpos( $date, '.' ) !== false ) { - $date = preg_replace( '/\.\d+/', '', $date ); - } - $datetime = WP_JSON_DateTime::createFromFormat( DateTime::RFC3339, $date ); - - return $datetime; + return $this->comments->delete_comment( $id, $comment, $force ); } /** - * Get a local date with its GMT equivalent, in MySQL datetime format + * Retrieve comments. + * + * @deprecated WPAPI-1.2 * - * @param string $date RFC3339 timestamp - * @param boolean $force_utc Should we force UTC timestamp? - * @return array Local and UTC datetime strings, in MySQL datetime format (Y-m-d H:i:s) + * @param int $id + * @return array */ - protected function get_date_with_gmt( $date, $force_utc = false ) { - $datetime = $this->server->parse_date( $date, $force_utc ); + public function get_comments( $id ) { + _deprecated_function( __CLASS__ . '::' . __METHOD__, 'WPAPI-1.2', 'WP_JSON_Comments::get_comments' ); - $datetime->setTimezone( self::get_timezone() ); - $local = $datetime->format( 'Y-m-d H:i:s' ); + return $this->comments->get_comments( $id ); + } - $datetime->setTimezone( new DateTimeZone( 'UTC' ) ); - $utc = $datetime->format('Y-m-d H:i:s'); + /** + * Retrieve comments. + * + * @deprecated WPAPI-1.2 + * + * @param int $id + * @return array + */ + public function get_comment( $comment ) { + _deprecated_function( __CLASS__ . '::' . __METHOD__, 'WPAPI-1.2', 'WP_JSON_Comments::get_comment' ); - return array( $local, $utc ); + return $this->comments->get_comment( $comment ); } /** * Prepares comment data for returning as a JSON response. * - * @param stdClass $comment Comment object - * @param array $requested_fields Fields to retrieve from the comment - * @param string $context Where is the comment being loaded? - * @return array Comment data for JSON serialization + * @param stdClass $comment + * @param array $requested_fields + * @param string $context + * @return array */ protected function prepare_comment( $comment, $requested_fields = array( 'comment', 'meta' ), $context = 'single' ) { - $fields = array( - 'ID' => (int) $comment->comment_ID, - 'post' => (int) $comment->comment_post_ID, - ); + _deprecated_function( __CLASS__ . '::' . __METHOD__, 'WPAPI-1.2', 'WP_JSON_Comments::prepare_comment' ); - $post = (array) get_post( $fields['post'] ); + return $this->comments->_deprecated_call( 'prepare_comment', array( $comment, $requested_fields, $context ) ); + } - // Content - $fields['content'] = apply_filters( 'comment_text', $comment->comment_content, $comment ); - // $fields['content_raw'] = $comment->comment_content; + /** + * Update/add/delete meta for a post. + * + * @param int $post_id + * @param array $data + * @return bool|WP_Error + */ + protected function handle_post_meta_action( $post_id, $data ) { + $handler = new WP_JSON_Meta_Posts( $this->server ); - // Status - switch ( $comment->comment_approved ) { - case 'hold': - case '0': - $fields['status'] = 'hold'; - break; + return $handler->handle_inline_meta( $post_id, $data['post_meta'] ); + } - case 'approve': - case '1': - $fields['status'] = 'approved'; - break; + /** + * Retrieve all meta for a post. + * + * @param int $post_id Post ID + * @return (array[]|WP_Error) List of meta object data on success, WP_Error otherwise + */ + protected function handle_get_post_meta( $post_id ) { + $handler = new WP_JSON_Meta_Posts( $this->server ); - case 'spam': - case 'trash': - default: - $fields['status'] = $comment->comment_approved; - } + return $handler->get_all_meta( $post_id ); + } - // Type - $fields['type'] = apply_filters( 'get_comment_type', $comment->comment_type ); - if ( empty( $fields['type'] ) ) { - $fields['type'] = 'comment'; - } + /** + * Retrieve custom fields for object + * + * @deprecated WPAPI-1.2 + * + * @param int $id Object ID + * @return (array[]|WP_Error) List of meta object data on success, WP_Error otherwise + */ + public function get_all_meta( $id ) { + _deprecated_function( 'WP_JSON_Posts::get_all_meta', 'WPAPI-1.2', 'WP_JSON_Meta_Posts::get_all_meta' ); - // Post - if ( 'single' === $context ) { - $parent = get_post( $post['post_parent'], ARRAY_A ); - $fields['parent'] = $this->prepare_post( $parent, 'single-parent' ); - } + $handler = new WP_JSON_Meta_Posts( $this->server ); + return $handler->get_all_meta( $id ); + } - // Parent - if ( ( 'single' === $context || 'single-parent' === $context ) && (int) $comment->comment_parent ) { - $parent_fields = array( 'meta' ); - if ( $context === 'single' ) - $parent_fields[] = 'comment'; - $parent = get_comment( $post['post_parent'] ); - $fields['parent'] = $this->prepare_comment( $parent, $parent_fields, 'single-parent' ); - } + /** + * Add meta to a post + * + * Ensures that the correct location header is sent with the response. + * + * @deprecated WPAPI-1.2 + * + * @param int $id Post ID + * @param array $data { + * @type string|null $key Meta key + * @type string|null $key Meta value + * } + * @return bool|WP_Error + */ + public function add_meta( $id, $data ) { + _deprecated_function( 'WP_JSON_Posts::add_meta', 'WPAPI-1.2', 'WP_JSON_Meta_Posts::add_meta' ); - // Parent - $fields['parent'] = (int) $comment->comment_parent; + $handler = new WP_JSON_Meta_Posts( $this->server ); + return $handler->add_meta( $id, $data ); + } - // Author - if ( (int) $comment->user_id !== 0 ) { - $fields['author'] = $this->prepare_author( (int) $comment->user_id ); - } - else { - $fields['author'] = array( - 'ID' => 0, - 'name' => $comment->comment_author, - 'URL' => $comment->comment_author_url, - 'avatar' => $this->server->get_avatar_url( $comment->comment_author_email ), - ); - } + /** + * Retrieve custom field object. + * + * @deprecated WPAPI-1.2 + * + * @param int $id Object ID + * @param int $mid Metadata ID + * @return array|WP_Error Meta object data on success, WP_Error otherwise + */ + public function get_meta( $id, $mid ) { + _deprecated_function( 'WP_JSON_Posts::get_meta', 'WPAPI-1.2', 'WP_JSON_Meta_Posts::get_meta' ); - // Date - $timezone = $this->server->get_timezone(); + $handler = new WP_JSON_Meta_Posts( $this->server ); + return $handler->get_meta( $id, $mid ); + } - $date = WP_JSON_DateTime::createFromFormat( 'Y-m-d H:i:s', $comment->comment_date, $timezone ); - $fields['date'] = $date->format( 'c' ); - $fields['date_tz'] = $date->format( 'e' ); - $fields['date_gmt'] = date( 'c', strtotime( $comment->comment_date_gmt ) ); + /** + * Add meta to an object + * + * @deprecated WPAPI-1.2 + * + * @param int $id Object ID + * @param array $data { + * @type string|null $key Meta key + * @type string|null $key Meta value + * } + * @return bool|WP_Error + */ + public function update_meta( $id, $mid, $data ) { + _deprecated_function( 'WP_JSON_Posts::update_meta', 'WPAPI-1.2', 'WP_JSON_Meta_Posts::update_meta' ); - // Meta - $meta = array( - 'links' => array( - 'up' => json_url( sprintf( '/posts/%d', (int) $comment->comment_post_ID ) ) - ), - ); - if ( 0 !== (int) $comment->comment_parent ) { - $meta['links']['in-reply-to'] = json_url( sprintf( '/posts/%d/comments/%d', (int) $comment->comment_post_ID, (int) $comment->comment_parent ) ); - } - if ( 'single' !== $context ) { - $meta['links']['self'] = json_url( sprintf( '/posts/%d/comments/%d', (int) $comment->comment_post_ID, (int) $comment->comment_ID ) ); - } + $handler = new WP_JSON_Meta_Posts( $this->server ); + return $handler->update_meta( $id, $mid, $data ); + } - // Remove unneeded fields - $data = array(); - if ( in_array( 'comment', $requested_fields ) ) - $data = array_merge( $data, $fields ); + /** + * Delete meta from an object + * + * @deprecated WPAPI-1.2 + * + * @param int $id Object ID + * @param int $mid Metadata ID + * @return array|WP_Error Message on success, WP_Error otherwise + */ + public function delete_meta( $id, $mid ) { + _deprecated_function( 'WP_JSON_Posts::delete_meta', 'WPAPI-1.2', 'WP_JSON_Meta_Posts::delete_meta' ); - if ( in_array( 'meta', $requested_fields ) ) - $data['meta'] = $meta; + $handler = new WP_JSON_Meta_Posts( $this->server ); + return $handler->delete_meta( $id, $mid, $data ); + } - return $data; + /** + * Prepares meta data for return as an object + * + * @deprecated WPAPI-1.2 + * + * @param int $post Object ID + * @param stdClass $data Metadata row from database + * @param boolean $is_raw Is the value field still serialized? (False indicates the value has been unserialized) + * @return array|WP_Error Meta object data on success, WP_Error otherwise + */ + protected function prepare_meta( $post, $data, $is_raw = false ) { + _deprecated_function( 'WP_JSON_Posts::prepare_meta', 'WPAPI-1.2', 'WP_JSON_Meta_Posts::prepare_meta' ); + + $handler = new WP_JSON_Meta_Posts( $this->server ); + return $handler->_deprecated_call( 'prepare_meta', array( $post, $data, $is_raw ) ); } /** - * Magic method used to temporaly deprecate camelcase functions + * Check if the data provided is valid data * - * @param string $name Function name - * @param array $arguments Function arguments - * @return mixed + * @deprecated WPAPI-1.2 + * + * @param mixed $data Data to be checked + * @return boolean Whether the data is valid or not */ - public function __call($name, $arguments) { - $underscored = strtolower(preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '_$0', $name)); - if ( method_exists( $this, $underscored ) ) { - _deprecated_function( __CLASS__ . '->' . $name, 'WPAPI-0.9', __CLASS__ . '->' . $underscored ); - return call_user_func_array( array( $this, $underscored ), $arguments ); - } + protected function is_valid_meta_data( $data ) { + _deprecated_function( 'WP_JSON_Posts::is_valid_meta_data', 'WPAPI-1.2', 'WP_JSON_Meta_Posts::is_valid_meta_data' ); + + $handler = new WP_JSON_Meta_Posts( $this->server ); + return $handler->_deprecated_call( 'is_valid_meta_data', array( $post, $data, $is_raw ) ); + } + + /** + * Check if we can read a post + * + * @deprecated WPAPI-1.2 + * + * @param array $post Post data + * @return boolean Can we read it? + */ + protected function check_read_permission( $post ) { + _deprecated_function( 'WP_JSON_Posts::check_read_permission', 'WPAPI-1.2', 'json_check_post_permission' ); + + return json_check_post_permission( $post, 'read' ); + } + + /** + * Check if we can edit a post + * + * @deprecated WPAPI-1.2 + * + * @param array $post Post data + * @return boolean Can we edit it? + */ + protected function check_edit_permission( $post ) { + _deprecated_function( 'WP_JSON_Posts::check_edit_permission', 'WPAPI-1.2', 'json_check_post_permission' ); + + return json_check_post_permission( $post, 'edit' ); } } diff --git a/lib/class-wp-json-response.php b/lib/class-wp-json-response.php index f3e9f7182c..2758a66417 100644 --- a/lib/class-wp-json-response.php +++ b/lib/class-wp-json-response.php @@ -28,7 +28,7 @@ public function get_headers() { * * @param array $headers Map of header name to header value */ - public function set_headers($headers) { + public function set_headers( $headers ) { $this->headers = $headers; } @@ -39,11 +39,10 @@ public function set_headers($headers) { * @param string $value Header value * @param boolean $replace Replace an existing header of the same name? */ - public function header($key, $value, $replace = true) { + public function header( $key, $value, $replace = true ) { if ( $replace || ! isset( $this->headers[ $key ] ) ) { $this->headers[ $key ] = $value; - } - else { + } else { $this->headers[ $key ] .= ', ' . $value; } } @@ -62,9 +61,11 @@ public function header($key, $value, $replace = true) { */ public function link_header( $rel, $link, $other = array() ) { $header = '<' . $link . '>; rel="' . $rel . '"'; + foreach ( $other as $key => $value ) { - if ( 'title' == $key ) + if ( 'title' == $key ) { $value = '"' . $value . '"'; + } $header .= '; ' . $key . '=' . $value; } return $this->header( 'Link', $header, false ); @@ -77,10 +78,11 @@ public function link_header( $rel, $link, $other = array() ) { */ public function query_navigation_headers( $query ) { $max_page = $query->max_num_pages; - $paged = $query->get('paged'); + $paged = $query->get('paged'); - if ( !$paged ) + if ( ! $paged ) { $paged = 1; + } $nextpage = intval($paged) + 1; diff --git a/lib/class-wp-json-responsehandler.php b/lib/class-wp-json-responsehandler.php index a3fe4b345b..fb68477d6b 100644 --- a/lib/class-wp-json-responsehandler.php +++ b/lib/class-wp-json-responsehandler.php @@ -2,39 +2,12 @@ interface WP_JSON_ResponseHandler { /** - * Send a HTTP status code + * Handle serving an API request * - * @param int $code HTTP status + * Matches the current server URI to a route and runs the first matching + * callback then outputs a JSON representation of the returned value. */ - public function send_status( $code ); - - /** - * Send a HTTP header - * - * @param string $key Header key - * @param string $value Header value - * @param boolean $replace Should we replace the existing header? - */ - public function header( $key, $value, $replace = true ); - - /** - * Send a Link header - * - * @link http://tools.ietf.org/html/rfc5988 - * @link http://www.iana.org/assignments/link-relations/link-relations.xml - * - * @param string $rel Link relation. Either a registered type, or an absolute URL - * @param string $link Target IRI for the link - * @param array $other Other parameters to send, as an assocative array - */ - public function link_header( $rel, $link, $other = array() ); - - /** - * Send navigation-related headers for post collections - * - * @param WP_Query $query - */ - public function query_navigation_headers( $query ); + public function serve_request( $path = null ); /** * Retrieve the raw request entity (body) @@ -42,4 +15,4 @@ public function query_navigation_headers( $query ); * @return string */ public function get_raw_data(); -} \ No newline at end of file +} diff --git a/lib/class-wp-json-server.php b/lib/class-wp-json-server.php index cb4a53daf9..359a39a0af 100644 --- a/lib/class-wp-json-server.php +++ b/lib/class-wp-json-server.php @@ -5,10 +5,9 @@ * Contains the WP_JSON_Server class. * * @package WordPress - * @version 0.8 */ -require_once ABSPATH . 'wp-admin/includes/admin.php'; +require_once ( ABSPATH . 'wp-admin/includes/admin.php' ); /** * WordPress JSON API server handler @@ -93,26 +92,31 @@ class WP_JSON_Server implements WP_JSON_ResponseHandler { /** * Check the authentication headers if supplied * - * @return WP_Error|WP_User|null WP_User object indicates successful login, WP_Error indicates unsuccessful login and null indicates no authentication provided + * @return WP_Error|null WP_Error indicates unsuccessful login, null indicates successful or no authentication provided */ public function check_authentication() { - $user = apply_filters( 'json_check_authentication', null ); - if ( is_a( $user, 'WP_User' ) ) - return $user; - - if ( !isset( $_SERVER['PHP_AUTH_USER'] ) ) - return; - - $username = $_SERVER['PHP_AUTH_USER']; - $password = $_SERVER['PHP_AUTH_PW']; - - $user = wp_authenticate( $username, $password ); - - if ( is_wp_error( $user ) ) - return $user; - - wp_set_current_user( $user->ID ); - return $user; + /** + * Pass an authentication error to the API + * + * This is used to pass a {@see WP_Error} from an authentication method + * back to the API. + * + * Authentication methods should check first if they're being used, as + * multiple authentication methods can be enabled on a site (cookies, + * HTTP basic auth, OAuth). If the authentication method hooked in is + * not actually being attempted, null should be returned to indicate + * another authentication method should check instead. Similarly, + * callbacks should ensure the value is `null` before checking for + * errors. + * + * A {@see WP_Error} instance can be returned if an error occurs, and + * this should match the format used by API methods internally (that is, + * the `status` data should be used). A callback can return `true` to + * indicate that the authentication method was used, and it succeeded. + * + * @param WP_Error|null|boolean WP_Error if authentication error, null if authentication method wasn't used, true if authentication succeeded + */ + return apply_filters( 'json_authentication_errors', null ); } /** @@ -129,8 +133,7 @@ protected function error_to_response( $error ) { $error_data = $error->get_error_data(); if ( is_array( $error_data ) && isset( $error_data['status'] ) ) { $status = $error_data['status']; - } - else { + } else { $status = 500; } @@ -145,23 +148,6 @@ protected function error_to_response( $error ) { return $response; } - /** - * Convert an error to an array - * - * This iterates over all error codes and messages to change it into a flat - * array. This enables simpler client behaviour, as it is represented as a - * list in JSON rather than an object/map - * - * @param WP_Error $error - * @return array List of associative arrays with code and message keys - */ - protected function error_to_array( $error ) { - _deprecated_function( 'WP_JSON_Server::error_to_array', 'WPAPI-0.8', 'WP_JSON_Server::error_to_response' ); - - $response = $this->error_to_response( $error ); - return $response->get_data(); - } - /** * Get an appropriate error representation in JSON * @@ -177,10 +163,11 @@ protected function error_to_array( $error ) { * @return string JSON representation of the error */ protected function json_error( $code, $message, $status = null ) { - if ( $status ) - $this->send_status( $status ); - + if ( $status ) { + $this->set_status( $status ); + } $error = compact( 'code', 'message' ); + return json_encode( array( $error ) ); } @@ -193,42 +180,49 @@ protected function json_error( $code, $message, $status = null ) { * @uses WP_JSON_Server::dispatch() */ public function serve_request( $path = null ) { - $this->send_header( 'Content-Type', 'application/json; charset=' . get_option( 'blog_charset' ), true ); + $content_type = isset( $_GET['_jsonp'] ) ? 'application/javascript' : 'application/json'; + $this->send_header( 'Content-Type', $content_type . '; charset=' . get_option( 'blog_charset' ), true ); + + // Mitigate possible JSONP Flash attacks + // http://miki.it/blog/2014/7/8/abusing-jsonp-with-rosetta-flash/ + $this->send_header( 'X-Content-Type-Options', 'nosniff' ); // Proper filter for turning off the JSON API. It is on by default. $enabled = apply_filters( 'json_enabled', true ); + $jsonp_enabled = apply_filters( 'json_jsonp_enabled', true ); if ( ! $enabled ) { echo $this->json_error( 'json_disabled', __( 'The JSON API is disabled on this site.' ), 404 ); return false; } - if ( isset($_GET['_jsonp']) ) { + if ( isset( $_GET['_jsonp'] ) ) { if ( ! $jsonp_enabled ) { echo $this->json_error( 'json_callback_disabled', __( 'JSONP support is disabled on this site.' ), 400 ); return false; } // Check for invalid characters (only alphanumeric allowed) - if ( preg_match( '/\W/', $_GET['_jsonp'] ) ) { + if ( ! is_string( $_GET['_jsonp'] ) || preg_match( '/[^a-zA-Z0-9._]/', $_GET['_jsonp'] ) ) { echo $this->json_error( 'json_callback_invalid', __( 'The JSONP callback function is invalid.' ), 400 ); return false; } } if ( empty( $path ) ) { - if ( isset( $_SERVER['PATH_INFO'] ) ) + if ( isset( $_SERVER['PATH_INFO'] ) ) { $path = $_SERVER['PATH_INFO']; - else + } else { $path = '/'; + } } - $this->path = $path; - $this->method = $_SERVER['REQUEST_METHOD']; - $this->params['GET'] = $_GET; + $this->path = $path; + $this->method = $_SERVER['REQUEST_METHOD']; + $this->params['GET'] = $_GET; $this->params['POST'] = $_POST; - $this->headers = $this->get_headers( $_SERVER ); - $this->files = $_FILES; + $this->headers = $this->get_headers( $_SERVER ); + $this->files = $_FILES; // Compatibility for clients that can't use PUT/PATCH/DELETE if ( isset( $_GET['_method'] ) ) { @@ -238,6 +232,19 @@ public function serve_request( $path = null ) { $result = $this->check_authentication(); if ( ! is_wp_error( $result ) ) { + /** + * Allow hijacking the request before dispatching + * + * If `$result` is non-empty, this value will be used to serve the + * request instead. + * + * @param mixed $result Response to replace the requested version with. Can be anything a normal endpoint can return, or null to not hijack the request. + * @param WP_JSON_ResponseHandler $this ResponseHandler instance (usually WP_JSON_Server) + */ + $result = apply_filters( 'json_pre_dispatch', null, $this ); + } + + if ( empty( $result ) ) { $result = $this->dispatch(); } @@ -255,20 +262,43 @@ public function serve_request( $path = null ) { $this->set_status( $code ); } - // This is a filter rather than an action, since this is designed to be - // re-entrant if needed - $served = apply_filters( 'json_serve_request', false, $result, $path, $this->method ); + /** + * Allow sending the request manually + * + * If `$served` is true, the result will not be sent to the client. + * + * This is a filter rather than an action, since this is designed to be + * re-entrant if needed. + * + * @param bool $served Whether the request has already been served + * @param mixed $result Result to send to the client. JsonSerializable, or other value to pass to `json_encode` + * @param string $path Route requested + * @param string $method HTTP request method (HEAD/GET/POST/PUT/PATCH/DELETE) + * @param WP_JSON_ResponseHandler $this ResponseHandler instance (usually WP_JSON_Server) + */ + $served = apply_filters( 'json_serve_request', false, $result, $path, $this->method, $this ); if ( ! $served ) { - if ( 'HEAD' === $this->method ) + if ( 'HEAD' === $this->method ) { return; + } $result = json_encode( $this->prepare_response( $result ) ); - if ( isset($_GET['_jsonp']) ) - echo $_GET['_jsonp'] . '(' . $result . ')'; - else + $json_error_message = $this->get_json_last_error(); + if ( $json_error_message ) { + $json_error_obj = new WP_Error( 'json_encode_error', $json_error_message, array( 'status' => 500 ) ); + $result = $this->error_to_response( $json_error_obj ); + $result = json_encode( $result->data[0] ); + } + + if ( isset( $_GET['_jsonp'] ) ) { + // Prepend '/**/' to mitigate possible JSONP Flash attacks + // http://miki.it/blog/2014/7/8/abusing-jsonp-with-rosetta-flash/ + echo '/**/' . $_GET['_jsonp'] . '(' . $result . ')'; + } else { echo $result; + } } } @@ -293,18 +323,6 @@ public function get_routes() { $endpoints = array( // Meta endpoints '/' => array( array( $this, 'get_index' ), self::READABLE ), - - // Users - '/users' => array( - array( '__return_null', self::READABLE ), - array( '__return_null', self::CREATABLE | self::ACCEPT_JSON ), - ), - // /users/me is an alias, and simply redirects to /users/ - '/users/me' => array( '__return_null', self::ALLMETHODS ), - '/users/(?P\d+)' => array( - array( '__return_null', self::READABLE ), - array( '__return_null', self::CREATABLE | self::ACCEPT_JSON ), - ), ); $endpoints = apply_filters( 'json_endpoints', $endpoints ); @@ -350,32 +368,58 @@ public function dispatch() { default: return new WP_Error( 'json_unsupported_method', __( 'Unsupported request method' ), array( 'status' => 400 ) ); } + foreach ( $this->get_routes() as $route => $handlers ) { foreach ( $handlers as $handler ) { $callback = $handler[0]; $supported = isset( $handler[1] ) ? $handler[1] : self::METHOD_GET; - if ( !( $supported & $method ) ) + if ( ! ( $supported & $method ) ) { continue; + } $match = preg_match( '@^' . $route . '$@i', $this->path, $args ); - if ( !$match ) + if ( ! $match ) { continue; + } - if ( ! is_callable( $callback ) ) + if ( ! is_callable( $callback ) ) { return new WP_Error( 'json_invalid_handler', __( 'The handler for the route is invalid' ), array( 'status' => 500 ) ); + } $args = array_merge( $args, $this->params['GET'] ); + if ( $method & self::METHOD_POST ) { $args = array_merge( $args, $this->params['POST'] ); } + if ( $supported & self::ACCEPT_JSON ) { - $data = json_decode( $this->get_raw_data(), true ); - $args = array_merge( $args, array( 'data' => $data ) ); - } - elseif ( $supported & self::ACCEPT_RAW ) { + $raw_data = $this->get_raw_data(); + $data = json_decode( $raw_data, true ); + + // test for json_decode() error + $json_error_message = $this->get_json_last_error(); + if ( $json_error_message ) { + + $data = array(); + parse_str( $raw_data, $data ); + + if ( empty( $data ) ) { + + return new WP_Error( 'json_decode_error', $json_error_message, array( 'status' => 500 ) ); + } + } + + if ( $data !== null ) { + $args = array_merge( $args, array( 'data' => $data ) ); + } + } elseif ( $supported & self::ACCEPT_RAW ) { $data = $this->get_raw_data(); + + if ( ! empty( $data ) ) { + $args = array_merge( $args, array( 'data' => $data ) ); + } } $args['_method'] = $method; @@ -392,8 +436,10 @@ public function dispatch() { } $params = $this->sort_callback_params( $callback, $args ); - if ( is_wp_error( $params ) ) + + if ( is_wp_error( $params ) ) { return $params; + } return call_user_func_array( $callback, $params ); } @@ -402,6 +448,26 @@ public function dispatch() { return new WP_Error( 'json_no_route', __( 'No route was found matching the URL and request method' ), array( 'status' => 404 ) ); } + /** + * Returns if an error occurred during most recent JSON encode/decode + * Strings to be translated will be in format like "Encoding error: Maximum stack depth exceeded" + * + * @return boolean|string Boolean false or string error message + */ + protected function get_json_last_error( ) { + // see https://core.trac.wordpress.org/ticket/27799 + if ( ! function_exists( 'json_last_error' ) ) { + return false; + } + + $last_error_code = json_last_error(); + if ( ( defined( 'JSON_ERROR_NONE' ) && $last_error_code === JSON_ERROR_NONE ) || empty( $last_error_code ) ) { + return false; + } + + return json_last_error_msg(); + } + /** * Sort parameters by order specified in method declaration * @@ -413,10 +479,11 @@ public function dispatch() { * @return array */ protected function sort_callback_params( $callback, $provided ) { - if ( is_array( $callback ) ) + if ( is_array( $callback ) ) { $ref_func = new ReflectionMethod( $callback[0], $callback[1] ); - else + } else { $ref_func = new ReflectionFunction( $callback ); + } $wanted = $ref_func->getParameters(); $ordered_parameters = array(); @@ -425,12 +492,10 @@ protected function sort_callback_params( $callback, $provided ) { if ( isset( $provided[ $param->getName() ] ) ) { // We have this parameters in the list to choose from $ordered_parameters[] = $provided[ $param->getName() ]; - } - elseif ( $param->isDefaultValueAvailable() ) { + } elseif ( $param->isDefaultValueAvailable() ) { // We don't have this parameter, but it's optional $ordered_parameters[] = $param->getDefaultValue(); - } - else { + } else { // We don't have this parameter and it wasn't optional, abort! return new WP_Error( 'json_missing_callback_param', sprintf( __( 'Missing parameter %s' ), $param->getName() ), array( 'status' => 400 ) ); } @@ -450,14 +515,15 @@ protected function sort_callback_params( $callback, $provided ) { public function get_index() { // General site data $available = array( - 'name' => get_option( 'blogname' ), - 'description' => get_option( 'blogdescription' ), - 'URL' => get_option( 'siteurl' ), - 'routes' => array(), - 'meta' => array( + 'name' => get_option( 'blogname' ), + 'description' => get_option( 'blogdescription' ), + 'URL' => get_option( 'siteurl' ), + 'routes' => array(), + 'authentication' => array(), + 'meta' => array( 'links' => array( - 'help' => 'https://github.com/rmccue/WP-API', - 'profile' => 'https://raw.github.com/rmccue/WP-API/master/docs/schema.json', + 'help' => 'https://github.com/WP-API/WP-API', + 'profile' => 'https://raw.github.com/WP-API/WP-API/master/docs/schema.json', ), ), ); @@ -468,17 +534,21 @@ public function get_index() { $route = preg_replace( '#\(\?P(<\w+?>).*?\)#', '$1', $route ); $methods = array(); + foreach ( self::$method_map as $name => $bitmask ) { foreach ( $callbacks as $callback ) { // Skip to the next route if any callback is hidden - if ( $callback[1] & self::HIDDEN_ENDPOINT ) + if ( $callback[1] & self::HIDDEN_ENDPOINT ) { continue 3; + } - if ( $callback[1] & $bitmask ) + if ( $callback[1] & $bitmask ) { $data['supports'][] = $name; + } - if ( $callback[1] & self::ACCEPT_JSON ) + if ( $callback[1] & self::ACCEPT_JSON ) { $data['accepts_json'] = true; + } // For non-variable routes, generate links if ( strpos( $route, '<' ) === false ) { @@ -488,22 +558,11 @@ public function get_index() { } } } - $available['routes'][$route] = apply_filters( 'json_endpoints_description', $data ); + $available['routes'][ $route ] = apply_filters( 'json_endpoints_description', $data ); } return apply_filters( 'json_index', $available ); } - /** - * Send a HTTP status code - * - * @param int $code HTTP status - */ - public function send_status( $code ) { - _deprecated_function( 'WP_JSON_Server::send_status', 'WPAPI-0.8', 'WP_JSON_Response' ); - - status_header( $code ); - } - /** * Send a HTTP status code * @@ -518,27 +577,6 @@ protected function set_status( $code ) { * * @param string $key Header key * @param string $value Header value - * @param boolean $replace Should we replace the existing header? - */ - public function header( $key, $value, $replace = true ) { - _deprecated_function( 'WP_JSON_Server::header', 'WPAPI-0.8', 'WP_JSON_Response' ); - - // Sanitize as per RFC2616 (Section 4.2): - // Any LWS that occurs between field-content MAY be replaced with a - // single SP before interpreting the field value or forwarding the - // message downstream. - $value = preg_replace( '/\s+/', ' ', $value ); - header( sprintf( '%s: %s', $key, $value ), $replace ); - } - - /** - * Send a HTTP header - * - * This is set via the response object, unlike {@see self::header()}, which - * was called directly before response objects were added. - * - * @param string $key Header key - * @param string $value Header value */ protected function send_header( $key, $value ) { // Sanitize as per RFC2616 (Section 4.2): @@ -560,68 +598,6 @@ protected function send_headers( $headers ) { } } - /** - * Send a Link header - * - * @todo Make this safe for <>"';, - * @internal The $rel parameter is first, as this looks nicer when sending multiple - * - * @link http://tools.ietf.org/html/rfc5988 - * @link http://www.iana.org/assignments/link-relations/link-relations.xml - * - * @param string $rel Link relation. Either a registered type, or an absolute URL - * @param string $link Target IRI for the link - * @param array $other Other parameters to send, as an assocative array - */ - public function link_header( $rel, $link, $other = array() ) { - _deprecated_function( 'WP_JSON_Server::link_header', 'WPAPI-0.8', 'WP_JSON_Response' ); - - $header = '<' . $link . '>; rel="' . $rel . '"'; - foreach ( $other as $key => $value ) { - if ( 'title' == $key ) - $value = '"' . $value . '"'; - $header .= '; ' . $key . '=' . $value; - } - $this->header( 'Link', $header, false ); - } - - /** - * Send navigation-related headers for post collections - * - * @param WP_Query $query - */ - public function query_navigation_headers( $query ) { - _deprecated_function( 'WP_JSON_Server::query_navigation_headers', 'WPAPI-0.8', 'WP_JSON_Response' ); - - $max_page = $query->max_num_pages; - $paged = $query->get('paged'); - - if ( !$paged ) - $paged = 1; - - $nextpage = intval($paged) + 1; - - if ( ! $query->is_single() ) { - if ( $paged > 1 ) { - $request = remove_query_arg( 'page' ); - $request = add_query_arg( 'page', $paged - 1, $request ); - $this->link_header( 'prev', $request ); - } - - if ( $nextpage <= $max_page ) { - $request = remove_query_arg( 'page' ); - $request = add_query_arg( 'page', $nextpage, $request ); - $this->link_header( 'next', $request ); - } - } - - $this->header( 'X-WP-Total', $query->found_posts ); - $this->header( 'X-WP-TotalPages', $max_page ); - - do_action('json_query_navigation_headers', $this, $query); - } - - /** * Retrieve the raw request entity (body) * @@ -647,7 +623,7 @@ public function get_raw_data() { * @param mixed $data Native representation * @return array|string Data ready for `json_encode()` */ - public function prepare_response($data) { + public function prepare_response( $data ) { if ( ! defined( 'WP_JSON_SERIALIZE_COMPATIBLE' ) || WP_JSON_SERIALIZE_COMPATIBLE === false ) { return $data; } @@ -668,8 +644,7 @@ public function prepare_response($data) { case 'object': if ( $data instanceof JsonSerializable ) { $data = $data->jsonSerialize(); - } - else { + } else { $data = get_object_vars( $data ); } @@ -685,44 +660,29 @@ public function prepare_response($data) { /** * Parse an RFC3339 timestamp into a DateTime * + * @deprecated * @param string $date RFC3339 timestamp * @param boolean $force_utc Force UTC timezone instead of using the timestamp's TZ? * @return DateTime */ public function parse_date( $date, $force_utc = false ) { - // Default timezone to the server's current one - $timezone = self::get_timezone(); - if ( $force_utc ) { - $date = preg_replace( '/[+-]\d+:?\d+$/', '+00:00', $date ); - $timezone = new DateTimeZone( 'UTC' ); - } - - // Strip millisecond precision (a full stop followed by one or more digits) - if ( strpos( $date, '.' ) !== false ) { - $date = preg_replace( '/\.\d+/', '', $date ); - } - $datetime = WP_JSON_DateTime::createFromFormat( DateTime::RFC3339, $date, $timezone ); + _deprecated_function( __CLASS__ . '::' . __METHOD__, 'WPAPI-1.1', 'json_parse_date' ); - return $datetime; + return json_parse_date( $date, $force_utc ); } /** * Get a local date with its GMT equivalent, in MySQL datetime format * + * @deprecated * @param string $date RFC3339 timestamp * @param boolean $force_utc Should we force UTC timestamp? - * @return array Local and UTC datetime strings, in MySQL datetime format (Y-m-d H:i:s) + * @return array|null Local and UTC datetime strings, in MySQL datetime format (Y-m-d H:i:s), null on failure */ public function get_date_with_gmt( $date, $force_utc = false ) { - $datetime = $this->parse_date( $date, $force_utc ); - - $datetime->setTimezone( self::get_timezone() ); - $local = $datetime->format( 'Y-m-d H:i:s' ); + _deprecated_function( __CLASS__ . '::' . __METHOD__, 'WPAPI-1.1', 'json_get_date_with_gmt' ); - $datetime->setTimezone( new DateTimeZone( 'UTC' ) ); - $utc = $datetime->format('Y-m-d H:i:s'); - - return array( $local, $utc ); + return json_get_date_with_gmt( $date, $force_utc ); } /** @@ -731,45 +691,26 @@ public function get_date_with_gmt( $date, $force_utc = false ) { * {@see get_avatar()} doesn't return just the URL, so we have to * extract it here. * + * @deprecated * @param string $email Email address * @return string url for the user's avatar */ public function get_avatar_url( $email ) { - $avatar_html = get_avatar( $email ); - // strip the avatar url from the get_avatar img tag. - preg_match('/src=["|\'](.+)[\&|"|\']/U', $avatar_html, $matches); - - if ( isset( $matches[1] ) && ! empty( $matches[1] ) ) { - - return esc_url_raw( $matches[1] ); - } + _deprecated_function( __CLASS__ . '::' . __METHOD__, 'WPAPI-1.1', 'json_get_avatar_url' ); - return ''; + return json_get_avatar_url( $email ); } /** * Get the timezone object for the site * + * @deprecated * @return DateTimeZone */ public function get_timezone() { - static $zone = null; - if ($zone !== null) - return $zone; - - $tzstring = get_option( 'timezone_string' ); - if ( ! $tzstring ) { - // Create a UTC+- zone if no timezone string exists - $current_offset = get_option( 'gmt_offset' ); - if ( 0 == $current_offset ) - $tzstring = 'UTC'; - elseif ($current_offset < 0) - $tzstring = 'Etc/GMT' . $current_offset; - else - $tzstring = 'Etc/GMT+' . $current_offset; - } - $zone = new DateTimeZone( $tzstring ); - return $zone; + _deprecated_function( __CLASS__ . '::' . __METHOD__, 'WPAPI-1.1', 'json_get_timezone' ); + + return json_get_timezone(); } /** @@ -778,16 +719,16 @@ public function get_timezone() { * @param array $server Associative array similar to $_SERVER * @return array Headers extracted from the input */ - public function get_headers($server) { + public function get_headers( $server ) { $headers = array(); + // CONTENT_* headers are not prefixed with HTTP_ - $additional = array('CONTENT_LENGTH' => true, 'CONTENT_MD5' => true, 'CONTENT_TYPE' => true); + $additional = array( 'CONTENT_LENGTH' => true, 'CONTENT_MD5' => true, 'CONTENT_TYPE' => true ); - foreach ($server as $key => $value) { - if ( strpos( $key, 'HTTP_' ) === 0) { + foreach ( $server as $key => $value ) { + if ( strpos( $key, 'HTTP_' ) === 0 ) { $headers[ substr( $key, 5 ) ] = $value; - } - elseif ( isset( $additional[ $key ] ) ) { + } elseif ( isset( $additional[ $key ] ) ) { $headers[ $key ] = $value; } } diff --git a/lib/class-wp-json-taxonomies.php b/lib/class-wp-json-taxonomies.php index a4424113dd..127439d8e6 100644 --- a/lib/class-wp-json-taxonomies.php +++ b/lib/class-wp-json-taxonomies.php @@ -9,95 +9,164 @@ class WP_JSON_Taxonomies { */ public function register_routes( $routes ) { $tax_routes = array( - '/posts/types/(?P\w+)/taxonomies' => array( + '/posts/types/(?P[\w-]+)/taxonomies' => array( + array( array( $this, 'get_taxonomies_for_type' ), WP_JSON_Server::READABLE | WP_JSON_Server::HIDDEN_ENDPOINT ), + ), + '/posts/types/(?P[\w-]+)/taxonomies/(?P[\w-]+)' => array( + array( array( $this, 'get_taxonomy' ), WP_JSON_Server::READABLE | WP_JSON_Server::HIDDEN_ENDPOINT ), + ), + '/posts/types/(?P[\w-]+)/taxonomies/(?P[\w-]+)/terms' => array( + array( array( $this, 'get_terms' ), WP_JSON_Server::READABLE | WP_JSON_Server::HIDDEN_ENDPOINT ), + ), + '/posts/types/(?P[\w-]+)/taxonomies/(?P[\w-]+)/terms/(?P[\w-]+)' => array( + array( array( $this, 'get_term' ), WP_JSON_Server::READABLE | WP_JSON_Server::HIDDEN_ENDPOINT ), + ), + '/taxonomies' => array( array( array( $this, 'get_taxonomies' ), WP_JSON_Server::READABLE ), ), - '/posts/types/(?P\w+)/taxonomies/(?P\w+)' => array( - array( array( $this, 'get_taxonomy' ), WP_JSON_Server::READABLE ), + '/taxonomies/(?P[\w-]+)' => array( + array( array( $this, 'get_taxonomy_object' ), WP_JSON_Server::READABLE ), ), - '/posts/types/(?P\w+)/taxonomies/(?P\w+)/terms' => array( - array( array( $this, 'get_terms' ), WP_JSON_Server::READABLE ), - array( '__return_null', WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + '/taxonomies/(?P[\w-]+)/terms' => array( + array( array( $this, 'get_taxonomy_terms' ), WP_JSON_Server::READABLE ), ), - '/posts/types/(?P\w+)/taxonomies/(?P\w+)/terms/(?P\w+)' => array( - array( array( $this, 'get_term' ), WP_JSON_Server::READABLE ), - array( '__return_null', WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), - array( '__return_null', WP_JSON_Server::DELETABLE ), + '/taxonomies/(?P[\w-]+)/terms/(?P[\w-]+)' => array( + array( array( $this, 'get_taxonomy_term' ), WP_JSON_Server::READABLE ), ), ); + return array_merge( $routes, $tax_routes ); } + /** + * Get taxonomies for a post type + * @param string $type Post type + * @param string $context Context (view|embed) + * @return array Taxonomy data + */ + public function get_taxonomies_for_type( $type, $context = 'view' ) { + _deprecated_function( __CLASS__ . '::' . __FUNCTION__, 'WPAPI-1.1', __CLASS__ . '::get_taxonomies( $type )' ); + + return $this->get_taxonomies( $type, $context ); + } + /** * Get taxonomies * - * @param string $type Post type to get taxonomies for + * @param string|null $type A specific post type for which to retrieve taxonomies (optional) * @return array Taxonomy data */ - public function get_taxonomies( $type ) { - $taxonomies = get_object_taxonomies( $type, 'objects' ); + public function get_taxonomies( $type = null, $context = 'view' ) { + if ( null === $type ) { + $taxonomies = get_taxonomies( '', 'objects' ); + } else { + $taxonomies = get_object_taxonomies( $type, 'objects' ); + } $data = array(); - foreach ($taxonomies as $tax_type => $value) { - $tax = $this->prepare_taxonomy( $value, $type, true ); - if ( is_wp_error( $tax ) ) + + foreach ( $taxonomies as $tax_type => $value ) { + $tax = $this->prepare_taxonomy_object( $value, $context ); + if ( is_wp_error( $tax ) ) { continue; + } - $data[ $tax_type ] = $tax; + $data[] = $tax; } return $data; } /** - * Get taxonomies + * Get taxonomies (legacy route with support for passing $type) + * + * @deprecated + * @see get_taxonomy_object * - * @param string $type Post type to get taxonomies for + * @param string $type Post type to get taxonomies for (deprecated) + * @param string $taxonomy Taxonomy slug * @return array Taxonomy data */ public function get_taxonomy( $type, $taxonomy ) { + _deprecated_function( __CLASS__ . '::' . __FUNCTION__, 'WPAPI-1.1', __CLASS__ . '::get_taxonomy_object' ); + + return $this->get_taxonomy_object( $taxonomy ); + } + + /** + * Get taxonomies + * + * @param string $taxonomy Taxonomy slug + * @return array Taxonomy data + */ + public function get_taxonomy_object( $taxonomy ) { $tax = get_taxonomy( $taxonomy ); - if ( empty( $tax ) ) + + if ( empty( $tax ) ) { return new WP_Error( 'json_taxonomy_invalid_id', __( 'Invalid taxonomy ID.' ), array( 'status' => 404 ) ); + } - return $this->prepare_taxonomy( $tax, $type ); + return $this->prepare_taxonomy_object( $tax ); } /** * Prepare a taxonomy for serialization * + * @deprecated + * @see prepare_taxonomy_object + * + * @param stdClass $taxonomy Taxonomy data + * @param string|null $type Post type to get taxonomies for (deprecated) + * @param string $context Context (view|embed). True/false are allowed for backwards compatibility, and map to embed/view respectively. + * @return array Taxonomy data + */ + protected function prepare_taxonomy( $taxonomy, $type = null, $context = 'view' ) { + _deprecated_function( __CLASS__ . '::' . __FUNCTION__, 'WPAPI-1.1', __CLASS__ . '::prepare_taxonomy_object' ); + + return $this->prepare_taxonomy_object( $taxonomy, $context ); + } + + /** + * Prepare a taxonomy object for serialization + * * @param stdClass $taxonomy Taxonomy data - * @param boolean $_in_collection Are we in a collection? + * @param string $context Context (view|embed). True/false are allowed for backwards compatibility, and map to embed/view respectively. * @return array Taxonomy data */ - protected function prepare_taxonomy( $taxonomy, $type, $_in_collection = false ) { - if ( $taxonomy->public === false ) + protected function prepare_taxonomy_object( $taxonomy, $context = 'view' ) { + if ( $taxonomy->public === false ) { return new WP_Error( 'json_cannot_read_taxonomy', __( 'Cannot view taxonomy' ), array( 'status' => 403 ) ); + } + + // Backwards compatibility with _in_collection parameter + if ( $context === true ) { + $context = 'embed'; + _deprecated_argument( __CLASS__ . '::' . __FUNCTION__, 'WPAPI-1.1', '$context should be set to "embed" rather than true' ); + } + elseif ( $context === false ) { + $context = 'view'; + _deprecated_argument( __CLASS__ . '::' . __FUNCTION__, 'WPAPI-1.1', '$context should be set to "view" rather than false' ); + } - $base_url = '/posts/types/' . $type . '/taxonomies/' . $taxonomy->name; + $base_url = '/taxonomies/' . $taxonomy->name; $data = array( - 'name' => $taxonomy->label, - 'slug' => $taxonomy->name, - 'labels' => $taxonomy->labels, - 'types' => array(), - 'show_cloud' => $taxonomy->show_tagcloud, + 'name' => $taxonomy->label, + 'slug' => $taxonomy->name, + 'labels' => $taxonomy->labels, + 'types' => $taxonomy->object_type, + 'show_cloud' => $taxonomy->show_tagcloud, 'hierarchical' => $taxonomy->hierarchical, - 'meta' => array( + 'meta' => array( 'links' => array( - 'archives' => json_url( $base_url . '/terms' ) + 'archives' => json_url( $base_url . '/terms' ), + 'collection' => json_url( '/taxonomies' ), + 'self' => json_url( $base_url ) ) ), ); - if ( $_in_collection ) { - $data['meta']['links']['self'] = json_url( $base_url ); - } - else { - $data['meta']['links']['collection'] = json_url( $base_url ); - } - - return apply_filters( 'json_prepare_taxonomy', $data ); + return apply_filters( 'json_prepare_taxonomy', $data, $taxonomy, $context ); } /** @@ -105,57 +174,127 @@ protected function prepare_taxonomy( $taxonomy, $type, $_in_collection = false ) * * @param array $data Type data * @param array $post Internal type data + * @param boolean $_in_taxonomy The record being filtered is a taxonomy object (internal use) * @return array Filtered data */ - public function add_taxonomy_data( $data, $type ) { - $data['taxonomies'] = $this->get_taxonomies( $type->name ); + public function add_taxonomy_data( $data, $type, $context = 'view' ) { + if ( $context !== 'embed' ) { + $data['taxonomies'] = $this->get_taxonomies( $type->name, 'embed' ); + } return $data; } /** - * Get terms for a post type + * Get terms for a post type (legacy route with support for passing $type) * - * @param string $type Post type + * @deprecated + * @see get_taxonomy_terms + * + * @param string $type Post type for which to fetch taxonomies (deprecated) * @param string $taxonomy Taxonomy slug * @return array Term collection */ public function get_terms( $type, $taxonomy ) { - if ( ! taxonomy_exists( $taxonomy ) ) + _deprecated_function( __CLASS__ . '::' . __FUNCTION__, 'WPAPI-1.1', __CLASS__ . '::get_taxonomy_terms' ); + + return $this->get_taxonomy_terms( $taxonomy ); + } + + /** + * Get all terms for a post type + * + * @param string $taxonomy Taxonomy slug + * @return array Term collection + */ + public function get_taxonomy_terms( $taxonomy, $filter = array() ) { + if ( ! taxonomy_exists( $taxonomy ) ) { return new WP_Error( 'json_taxonomy_invalid_id', __( 'Invalid taxonomy ID.' ), array( 'status' => 404 ) ); + } $args = array( 'hide_empty' => false, ); + + // Allow args in get_terms function. This is a partial list and does not include hide_empty and cache_domain. + $valid_vars = array( + 'orderby', + 'order', + 'exclude', + 'exclude_tree', + 'include', + 'number', + 'fields', + 'slug', + 'parent', + 'hierarchical', + 'child_of', + 'get', + 'name__like', + 'description__like', + 'pad_counts', + 'offset', + 'search', + ); + + foreach ( $valid_vars as $var ) { + if ( isset( $filter[ $var ] ) ) { + $args[ $var ] = apply_filters( 'json_tax_query_var-' . $var, $filter[ $var ] ); + } + } + $terms = get_terms( $taxonomy, $args ); - if ( is_wp_error( $terms ) ) + + if ( is_wp_error( $terms ) ) { return $terms; + } $data = array(); - foreach ($terms as $term) { - $data[ $term->slug ] = $this->prepare_term( $term, $type ); + foreach ( $terms as $term ) { + $data[] = $this->prepare_taxonomy_term( $term ); } + return $data; } /** * Get term for a post type * - * @param string $type Post type + * @deprecated + * @see get_taxonomy_term + * + * @param string $type Post type (deprecated) * @param string $taxonomy Taxonomy slug * @param string $term Term slug * @param string $context Context (view/view-parent) * @return array Term entity */ public function get_term( $type, $taxonomy, $term, $context = 'view' ) { - if ( ! taxonomy_exists( $taxonomy ) ) + _deprecated_function( __CLASS__ . '::' . __FUNCTION__, 'WPAPI-1.1', __CLASS__ . '::get_taxonomy_term( $taxonomy, $term )' ); + + return $this->get_taxonomy_term( $taxonomy, $term, $context ); + } + + /** + * Get term for a post type + * + * @param string $taxonomy Taxonomy slug + * @param string $term Term slug + * @param string $context Context (view/view-parent) + * @return array Term entity + */ + public function get_taxonomy_term( $taxonomy, $term, $context = 'view' ) { + if ( ! taxonomy_exists( $taxonomy ) ) { return new WP_Error( 'json_taxonomy_invalid_id', __( 'Invalid taxonomy ID.' ), array( 'status' => 404 ) ); + } $data = get_term( $term, $taxonomy ); - if ( empty( $data ) ) + + if ( empty( $data ) ) { return new WP_Error( 'json_taxonomy_invalid_term', __( 'Invalid term ID.' ), array( 'status' => 404 ) ); + } - return $this->prepare_term( $data, $type, $context ); + return $this->prepare_taxonomy_term( $data, $context ); } /** @@ -170,58 +309,63 @@ public function add_term_data( $data, $post, $context ) { $post_type_taxonomies = get_object_taxonomies( $post['post_type'] ); $terms = wp_get_object_terms( $post['ID'], $post_type_taxonomies ); $data['terms'] = array(); + foreach ( $terms as $term ) { - $data['terms'][ $term->taxonomy ][] = $this->prepare_term( $term, $post['post_type'] ); + $data['terms'][ $term->taxonomy ][] = $this->prepare_taxonomy_term( $term ); } return $data; } + /** + * Prepare term data for serialization + * + * @deprecated + * @see prepare_taxonomy_term + * + * @param array|object $term The unprepared term data + * @param string|null $type Post type to get taxonomies for (deprecated) + * @return array The prepared term data + */ + protected function prepare_term( $term, $type = null, $context = 'view' ) { + _deprecated_function( __CLASS__ . '::' . __FUNCTION__, 'WPAPI-1.1', __CLASS__ . '::prepare_taxonomy_term( $term )' ); + + return $this->prepare_taxonomy_term( $term, $context ); + } + + /** * Prepare term data for serialization * * @param array|object $term The unprepared term data * @return array The prepared term data */ - protected function prepare_term( $term, $type, $context = 'view' ) { - $base_url = '/posts/types/' . $type . '/taxonomies/' . $term->taxonomy . '/terms'; + protected function prepare_taxonomy_term( $term, $context = 'view' ) { + $base_url = '/taxonomies/' . $term->taxonomy . '/terms'; + $data = array( - 'ID' => (int) $term->term_taxonomy_id, - 'name' => $term->name, - 'slug' => $term->slug, - 'parent' => (int) $term->parent, - 'count' => (int) $term->count, - 'link' => get_term_link( $term, $term->taxonomy ), - 'meta' => array( + 'ID' => (int) $term->term_taxonomy_id, + 'name' => $term->name, + 'slug' => $term->slug, + 'description' => $term->description, + 'taxonomy' => $term->taxonomy, + 'parent' => (int) $term->parent, + 'count' => (int) $term->count, + 'link' => get_term_link( $term, $term->taxonomy ), + 'meta' => array( 'links' => array( 'collection' => json_url( $base_url ), - 'self' => json_url( $base_url . '/' . $term->term_id ), + 'self' => json_url( $base_url . '/' . $term->term_id ), ), ), ); if ( ! empty( $data['parent'] ) && $context === 'view' ) { - $data['parent'] = $this->get_term( $type, $term->taxonomy, $data['parent'], 'view-parent' ); - } - elseif ( empty( $data['parent'] ) ) { + $data['parent'] = $this->get_taxonomy_term( $term->taxonomy, $data['parent'], 'view-parent' ); + } elseif ( empty( $data['parent'] ) ) { $data['parent'] = null; } - return apply_filters( 'json_prepare_term', $data, $term ); - } - - /** - * Magic method used to temporaly deprecate camelcase functions - * - * @param string $name Function name - * @param array $arguments Function arguments - * @return mixed - */ - public function __call($name, $arguments) { - $underscored = strtolower(preg_replace('/(?!^)[[:upper:]][[:lower:]]/', '_$0', $name)); - if ( method_exists( $this, $underscored ) ) { - _deprecated_function( __CLASS__ . '->' . $name, 'WPAPI-0.9', __CLASS__ . '->' . $underscored ); - return call_user_func_array( array( $this, $underscored ), $arguments ); - } + return apply_filters( 'json_prepare_term', $data, $term, $context ); } } diff --git a/lib/class-wp-json-users.php b/lib/class-wp-json-users.php new file mode 100644 index 0000000000..97129c3fd3 --- /dev/null +++ b/lib/class-wp-json-users.php @@ -0,0 +1,507 @@ +server = $server; + } + + /** + * Register the user-related routes + * + * @param array $routes Existing routes + * @return array Modified routes + */ + public function register_routes( $routes ) { + $user_routes = array( + // User endpoints + '/users' => array( + array( array( $this, 'get_users' ), WP_JSON_Server::READABLE ), + array( array( $this, 'create_user' ), WP_JSON_Server::CREATABLE | WP_JSON_Server::ACCEPT_JSON ), + ), + '/users/(?P\d+)' => array( + array( array( $this, 'get_user' ), WP_JSON_Server::READABLE ), + array( array( $this, 'edit_user' ), WP_JSON_Server::EDITABLE | WP_JSON_Server::ACCEPT_JSON ), + array( array( $this, 'delete_user' ), WP_JSON_Server::DELETABLE ), + ), + // /users/me is an alias, and simply redirects to /users/ + '/users/me' => array( + array( array( $this, 'get_current_user' ), WP_JSON_Server::READABLE ), + ), + ); + return array_merge( $routes, $user_routes ); + } + + /** + * Retrieve users. + * + * @param array $filter Extra query parameters for {@see WP_User_Query} + * @param string $context optional + * @param int $page Page number (1-indexed) + * @return array contains a collection of User entities. + */ + public function get_users( $filter = array(), $context = 'view', $page = 1 ) { + if ( ! current_user_can( 'list_users' ) ) { + return new WP_Error( 'json_user_cannot_list', __( 'Sorry, you are not allowed to list users.' ), array( 'status' => 403 ) ); + } + + $args = array( + 'orderby' => 'user_login', + 'order' => 'ASC' + ); + $args = array_merge( $args, $filter ); + + $args = apply_filters( 'json_user_query', $args, $filter, $context, $page ); + + // Pagination + $args['number'] = empty( $args['number'] ) ? 10 : absint( $args['number'] ); + $page = absint( $page ); + $args['offset'] = ( $page - 1 ) * $args['number']; + + $user_query = new WP_User_Query( $args ); + + if ( empty( $user_query->results ) ) { + return array(); + } + + $struct = array(); + + foreach ( $user_query->results as $user ) { + $struct[] = $this->prepare_user( $user, $context ); + } + + return $struct; + } + + /** + * Retrieve the current user + * + * @param string $context + * @return mixed See + */ + public function get_current_user( $context = 'view' ) { + $current_user_id = get_current_user_id(); + + if ( empty( $current_user_id ) ) { + return new WP_Error( 'json_not_logged_in', __( 'You are not currently logged in.' ), array( 'status' => 401 ) ); + } + + $response = $this->get_user( $current_user_id, $context ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + if ( ! ( $response instanceof WP_JSON_ResponseInterface ) ) { + $response = new WP_JSON_Response( $response ); + } + + $data = $response->get_data(); + + $response->header( 'Location', $data['meta']['links']['self'] ); + $response->set_status( 302 ); + + return $response; + } + + /** + * Retrieve a user. + * + * @param int $id User ID + * @param string $context + * @return response + */ + public function get_user( $id, $context = 'view' ) { + $id = (int) $id; + $current_user_id = get_current_user_id(); + + if ( $current_user_id !== $id && ! current_user_can( 'list_users' ) ) { + return new WP_Error( 'json_user_cannot_list', __( 'Sorry, you are not allowed to view this user.' ), array( 'status' => 403 ) ); + } + + $user = get_userdata( $id ); + + if ( empty( $user->ID ) ) { + return new WP_Error( 'json_user_invalid_id', __( 'Invalid user ID.' ), array( 'status' => 400 ) ); + } + + return $this->prepare_user( $user, $context ); + } + + /** + * + * Prepare a User entity from a WP_User instance. + * + * @param WP_User $user + * @param string $context One of 'view', 'edit', 'embed' + * @return array + */ + protected function prepare_user( $user, $context = 'view' ) { + $user_fields = array( + 'ID' => $user->ID, + 'username' => $user->user_login, + 'name' => $user->display_name, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'nickname' => $user->nickname, + 'slug' => $user->user_nicename, + 'URL' => $user->user_url, + 'avatar' => json_get_avatar_url( $user->user_email ), + 'description' => $user->description, + ); + + $user_fields['registered'] = date( 'c', strtotime( $user->user_registered ) ); + + if ( $context === 'view' || $context === 'edit' ) { + $user_fields['roles'] = $user->roles; + $user_fields['capabilities'] = $user->allcaps; + $user_fields['email'] = false; + } + + if ( $context === 'edit' ) { + // The user's specific caps should only be needed if you're editing + // the user, as allcaps should handle most uses + $user_fields['email'] = $user->user_email; + $user_fields['extra_capabilities'] = $user->caps; + } + + $user_fields['meta'] = array( + 'links' => array( + 'self' => json_url( '/users/' . $user->ID ), + 'archives' => json_url( '/users/' . $user->ID . '/posts' ), + ), + ); + + return apply_filters( 'json_prepare_user', $user_fields, $user, $context ); + } + + /** + * Add author data to post data + * + * @param array $data Post data + * @param array $post Internal post data + * @param string $context Post context + * @return array Filtered data + */ + public function add_post_author_data( $data, $post, $context ) { + $author = get_userdata( $post['post_author'] ); + + if ( ! empty( $author ) ) { + $data['author'] = $this->prepare_user( $author, 'embed' ); + } + + return $data; + } + + /** + * Add author data to comment data + * + * @param array $data Comment data + * @param array $comment Internal comment data + * @param string $context Data context + * @return array Filtered data + */ + public function add_comment_author_data( $data, $comment, $context ) { + if ( (int) $comment->user_id !== 0 ) { + $author = get_userdata( $comment->user_id ); + + if ( ! empty( $author ) ) { + $data['author'] = $this->prepare_user( $author, 'embed' ); + } + } + + return $data; + } + + protected function insert_user( $data ) { + $user = new stdClass; + + if ( ! empty( $data['ID'] ) ) { + $existing = get_userdata( $data['ID'] ); + + if ( ! $existing ) { + return new WP_Error( 'json_user_invalid_id', __( 'Invalid user ID.' ), array( 'status' => 404 ) ); + } + + if ( ! current_user_can( 'edit_user', $data['ID'] ) ) { + return new WP_Error( 'json_user_cannot_edit', __( 'Sorry, you are not allowed to edit this user.' ), array( 'status' => 403 ) ); + } + + $user->ID = $existing->ID; + $update = true; + } else { + if ( ! current_user_can( 'create_users' ) ) { + return new WP_Error( 'json_cannot_create', __( 'Sorry, you are not allowed to create users.' ), array( 'status' => 403 ) ); + } + + $required = array( 'username', 'password', 'email' ); + + foreach ( $required as $arg ) { + if ( empty( $data[ $arg ] ) ) { + return new WP_Error( 'json_missing_callback_param', sprintf( __( 'Missing parameter %s' ), $arg ), array( 'status' => 400 ) ); + } + } + + $update = false; + } + + // Basic authentication details + if ( isset( $data['username'] ) ) { + $user->user_login = $data['username']; + } + + if ( isset( $data['password'] ) ) { + $user->user_pass = $data['password']; + } + + // Names + if ( isset( $data['name'] ) ) { + $user->display_name = $data['name']; + } + + if ( isset( $data['first_name'] ) ) { + $user->first_name = $data['first_name']; + } + + if ( isset( $data['last_name'] ) ) { + $user->last_name = $data['last_name']; + } + + if ( isset( $data['nickname'] ) ) { + $user->nickname = $data['nickname']; + } + + if ( ! empty( $data['slug'] ) ) { + $user->user_nicename = $data['slug']; + } + + // URL + if ( ! empty( $data['URL'] ) ) { + $escaped = esc_url_raw( $user->user_url ); + + if ( $escaped !== $user->user_url ) { + return new WP_Error( 'json_invalid_url', __( 'Invalid user URL.' ), array( 'status' => 400 ) ); + } + + $user->user_url = $data['URL']; + } + + // Description + if ( ! empty( $data['description'] ) ) { + $user->description = $data['description']; + } + + // Email + if ( ! empty( $data['email'] ) ) { + $user->user_email = $data['email']; + } + + // Role + if ( ! empty( $data['role'] ) ) { + if ( $update ) { + $check_permission = $this->check_role_update( $user->ID, $data['role'] ); + if ( is_wp_error( $check_permission ) ) { + return $check_permission; + } + } + + $user->role = $data['role']; + } + + // Pre-flight check + $user = apply_filters( 'json_pre_insert_user', $user, $data ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + $user_id = $update ? wp_update_user( $user ) : wp_insert_user( $user ); + + if ( is_wp_error( $user_id ) ) { + return $user_id; + } + + $user->ID = $user_id; + + do_action( 'json_insert_user', $user, $data, $update ); + + return $user_id; + } + + /** + * Determine if the current user is allowed to make the desired role change. + * + * @param integer $user_id + * @param string $role + * @return boolen|WP_Error + */ + protected function check_role_update( $user_id, $role ) { + global $wp_roles; + + if ( ! isset( $wp_roles->role_objects[ $role ] ) ) { + return new WP_Error( 'json_user_invalid_role', __( 'Role is invalid.' ), array( 'status' => 400 ) ); + } + + $potential_role = $wp_roles->role_objects[ $role ]; + + // Don't let anyone with 'edit_users' (admins) edit their own role to something without it. + // Multisite super admins can freely edit their blog roles -- they possess all caps. + if ( ( is_multisite() && current_user_can( 'manage_sites' ) ) || get_current_user_id() !== $user_id || $potential_role->has_cap( 'edit_users' ) ) { + // The new role must be editable by the logged-in user. + $editable_roles = get_editable_roles(); + if ( empty( $editable_roles[ $role ] ) ) { + return new WP_Error( 'json_user_invalid_role', __( 'You cannot give users that role.' ), array( 'status' => 403 ) ); + } + + return true; + } + + return new WP_Error( 'rest_user_invalid_role', __( 'You cannot give users that role.' ), array( 'status' => 403 ) ); + } + + /** + * Edit a user. + * + * The $data parameter only needs to contain fields that should be changed. + * All other fields will retain their existing values. + * + * @param int $id User ID to edit + * @param array $data Data construct + * @param array $_headers Header data + * @return true on success + */ + public function edit_user( $id, $data, $_headers = array() ) { + $id = absint( $id ); + + if ( empty( $id ) ) { + return new WP_Error( 'json_user_invalid_id', __( 'User ID must be supplied.' ), array( 'status' => 400 ) ); + } + + // Permissions check + if ( ! current_user_can( 'edit_user', $id ) ) { + return new WP_Error( 'json_user_cannot_edit', __( 'Sorry, you are not allowed to edit this user.' ), array( 'status' => 403 ) ); + } + if ( ! empty( $data['role'] ) && ! current_user_can( 'edit_users' ) ) { + return new WP_Error( 'json_cannot_edit_roles', __( 'Sorry, you are not allowed to edit roles of users.' ), array( 'status' => 403 ) ); + } + + $user = get_userdata( $id ); + if ( ! $user ) { + return new WP_Error( 'json_user_invalid_id', __( 'User ID is invalid.' ), array( 'status' => 400 ) ); + } + + $data['ID'] = $user->ID; + + // Update attributes of the user from $data + $retval = $this->insert_user( $data ); + + if ( is_wp_error( $retval ) ) { + return $retval; + } + + return $this->get_user( $id ); + } + + /** + * Create a new user. + * + * @param $data + * @return mixed + */ + public function create_user( $data ) { + if ( ! current_user_can( 'create_users' ) ) { + return new WP_Error( 'json_cannot_create', __( 'Sorry, you are not allowed to create users.' ), array( 'status' => 403 ) ); + } + + if ( ! empty( $data['ID'] ) ) { + return new WP_Error( 'json_user_exists', __( 'Cannot create existing user.' ), array( 'status' => 400 ) ); + } + + $user_id = $this->insert_user( $data ); + + if ( is_wp_error( $user_id ) ) { + return $user_id; + } + + $response = $this->get_user( $user_id ); + + if ( ! $response instanceof WP_JSON_ResponseInterface ) { + $response = new WP_JSON_Response( $response ); + } + + $response->set_status( 201 ); + $response->header( 'Location', json_url( '/users/' . $user_id ) ); + + return $response; + } + + /** + * Create a new user. + * + * @deprecated + * + * @param $data + * @return mixed + */ + public function new_user( $data ) { + _deprecated_function( __CLASS__ . '::' . __METHOD__, 'WPAPI-1.2', 'WP_JSON_Users::create_user' ); + + return $this->create_user( $data ); + } + + /** + * Delete a user. + * + * @param int $id + * @param bool force + * @return true on success + */ + public function delete_user( $id, $force = false, $reassign = null ) { + $id = absint( $id ); + + if ( empty( $id ) ) { + return new WP_Error( 'json_user_invalid_id', __( 'Invalid user ID.' ), array( 'status' => 400 ) ); + } + + // Permissions check + if ( ! current_user_can( 'delete_user', $id ) ) { + return new WP_Error( 'json_user_cannot_delete', __( 'Sorry, you are not allowed to delete this user.' ), array( 'status' => 403 ) ); + } + + $user = get_userdata( $id ); + + if ( ! $user ) { + return new WP_Error( 'json_user_invalid_id', __( 'Invalid user ID.' ), array( 'status' => 400 ) ); + } + + if ( ! empty( $reassign ) ) { + $reassign = absint( $reassign ); + + // Check that reassign is valid + if ( empty( $reassign ) || $reassign === $id || ! get_userdata( $reassign ) ) { + return new WP_Error( 'json_user_invalid_reassign', __( 'Invalid user ID.' ), array( 'status' => 400 ) ); + } + } else { + $reassign = null; + } + + $result = wp_delete_user( $id, $reassign ); + + if ( ! $result ) { + return new WP_Error( 'json_cannot_delete', __( 'The user cannot be deleted.' ), array( 'status' => 500 ) ); + } else { + return array( 'message' => __( 'Deleted user' ) ); + } + } +} diff --git a/lib/wp-json.php b/lib/wp-json.php index 1e6b467009..6c0b1d8e4f 100644 --- a/lib/wp-json.php +++ b/lib/wp-json.php @@ -11,25 +11,26 @@ * @var bool * @todo Remove me in favour of JSON_REQUEST */ -define('XMLRPC_REQUEST', true); +define( 'XMLRPC_REQUEST', true ); /** * Whether this is a JSON Request * * @var bool */ -define('JSON_REQUEST', true); +define( 'JSON_REQUEST', true ); /** Include the bootstrap for setting up WordPress environment */ -include('./wp-load.php'); +include( './wp-load.php' ); -include_once(ABSPATH . 'wp-admin/includes/admin.php'); -include_once(ABSPATH . WPINC . '/class-wp-xmlrpc-server.php'); -include_once(ABSPATH . WPINC . '/class-wp-json-datetime.php'); -include_once(ABSPATH . WPINC . '/class-wp-json-server.php'); +include_once( ABSPATH . 'wp-admin/includes/admin.php' ); +include_once( ABSPATH . WPINC . '/class-wp-xmlrpc-server.php' ); +include_once( ABSPATH . WPINC . '/class-wp-json-datetime.php' ); +include_once( ABSPATH . WPINC . '/class-wp-json-server.php' ); // Allow for a plugin to insert a different class to handle requests. -$wp_json_server_class = apply_filters('wp_json_server_class', 'WP_JSON_Server'); +$wp_json_server_class = apply_filters( 'wp_json_server_class', 'WP_JSON_Server' ); + $wp_json_server = new $wp_json_server_class; // Fire off the request diff --git a/license.txt b/license.txt new file mode 100644 index 0000000000..211bee3c5f --- /dev/null +++ b/license.txt @@ -0,0 +1,281 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 51 Franklin St, Fifth Floor, Boston, MA 02110, USA + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 66723a23d1..e000b76677 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,8 +1,29 @@ - + - tests + tests + + + + + + + + . + + + ./lib + + \ No newline at end of file diff --git a/plugin.php b/plugin.php index 1f5cc573c5..916f78c6c6 100644 --- a/plugin.php +++ b/plugin.php @@ -1,77 +1,139 @@ add_query_var('json_route'); + $wp->add_query_var( 'json_route' ); } -add_action( 'init', 'json_api_init' ); +add_action( 'init', 'json_api_init', 11 ); // Prioritized over core rewrites +/** + * Add rewrite rules. + */ function json_api_register_rewrites() { - add_rewrite_rule( '^wp-json/?$','index.php?json_route=/','top' ); - add_rewrite_rule( '^wp-json(.*)?','index.php?json_route=$matches[1]','top' ); + add_rewrite_rule( '^' . json_get_url_prefix() . '/?$','index.php?json_route=/','top' ); + add_rewrite_rule( '^' . json_get_url_prefix() . '/(.*)?','index.php?json_route=/$matches[1]','top' ); } /** - * Register the default JSON API filters + * Determine if the rewrite rules should be flushed. + */ +function json_api_maybe_flush_rewrites() { + $version = get_option( 'json_api_plugin_version', null ); + + if ( empty( $version ) || $version !== JSON_API_VERSION ) { + flush_rewrite_rules(); + update_option( 'json_api_plugin_version', JSON_API_VERSION ); + } + +} +add_action( 'init', 'json_api_maybe_flush_rewrites', 999 ); + +/** + * Register the default JSON API filters. * * @internal This will live in default-filters.php + * + * @global WP_JSON_Posts $wp_json_posts + * @global WP_JSON_Pages $wp_json_pages + * @global WP_JSON_Media $wp_json_media + * @global WP_JSON_Taxonomies $wp_json_taxonomies + * + * @param WP_JSON_ResponseHandler $server Server object. */ -function json_api_default_filters($server) { +function json_api_default_filters( $server ) { global $wp_json_posts, $wp_json_pages, $wp_json_media, $wp_json_taxonomies; - // Posts - $wp_json_posts = new WP_JSON_Posts($server); + // Posts. + $wp_json_posts = new WP_JSON_Posts( $server ); add_filter( 'json_endpoints', array( $wp_json_posts, 'register_routes' ), 0 ); + add_filter( 'json_prepare_taxonomy', array( $wp_json_posts, 'add_post_type_data' ), 10, 3 ); - // Pages - $wp_json_pages = new WP_JSON_Pages($server); - add_filter( 'json_endpoints', array( $wp_json_pages, 'register_routes' ), 1 ); - add_filter( 'json_post_type_data', array( $wp_json_pages, 'type_archive_link' ), 10, 2 ); + // Users. + $wp_json_users = new WP_JSON_Users( $server ); + add_filter( 'json_endpoints', array( $wp_json_users, 'register_routes' ), 0 ); + add_filter( 'json_prepare_post', array( $wp_json_users, 'add_post_author_data' ), 10, 3 ); + add_filter( 'json_prepare_comment', array( $wp_json_users, 'add_comment_author_data' ), 10, 3 ); - // Media - $wp_json_media = new WP_JSON_Media($server); - add_filter( 'json_endpoints', array( $wp_json_media, 'register_routes' ), 1 ); - add_filter( 'json_prepare_post', array( $wp_json_media, 'add_thumbnail_data' ), 10, 3 ); - add_filter( 'json_pre_insert_post', array( $wp_json_media, 'preinsert_check' ), 10, 3 ); - add_filter( 'json_insert_post', array( $wp_json_media, 'attach_thumbnail' ), 10, 3 ); - add_filter( 'json_post_type_data', array( $wp_json_media, 'type_archive_link' ), 10, 2 ); + // Pages. + $wp_json_pages = new WP_JSON_Pages( $server ); + $wp_json_pages->register_filters(); + + // Post meta. + $wp_json_post_meta = new WP_JSON_Meta_Posts( $server ); + add_filter( 'json_endpoints', array( $wp_json_post_meta, 'register_routes' ), 0 ); - // Posts - $wp_json_taxonomies = new WP_JSON_Taxonomies($server); - add_filter( 'json_endpoints', array( $wp_json_taxonomies, 'register_routes' ), 2 ); - add_filter( 'json_post_type_data', array( $wp_json_taxonomies, 'add_taxonomy_data' ), 10, 2 ); - add_filter( 'json_prepare_post', array( $wp_json_taxonomies, 'add_term_data' ), 10, 3 ); + // Media. + $wp_json_media = new WP_JSON_Media( $server ); + add_filter( 'json_endpoints', array( $wp_json_media, 'register_routes' ), 1 ); + add_filter( 'json_prepare_post', array( $wp_json_media, 'add_thumbnail_data' ), 10, 3 ); + add_filter( 'json_pre_insert_post', array( $wp_json_media, 'preinsert_check' ), 10, 3 ); + add_filter( 'json_insert_post', array( $wp_json_media, 'attach_thumbnail' ), 10, 3 ); + add_filter( 'json_post_type_data', array( $wp_json_media, 'type_archive_link' ), 10, 2 ); + + // Posts. + $wp_json_taxonomies = new WP_JSON_Taxonomies( $server ); + add_filter( 'json_endpoints', array( $wp_json_taxonomies, 'register_routes' ), 2 ); + add_filter( 'json_post_type_data', array( $wp_json_taxonomies, 'add_taxonomy_data' ), 10, 3 ); + add_filter( 'json_prepare_post', array( $wp_json_taxonomies, 'add_term_data' ), 10, 3 ); + + // Deprecated reporting. + add_action( 'deprecated_function_run', 'json_handle_deprecated_function', 10, 3 ); + add_filter( 'deprecated_function_trigger_error', '__return_false' ); + add_action( 'deprecated_argument_run', 'json_handle_deprecated_argument', 10, 3 ); + add_filter( 'deprecated_argument_trigger_error', '__return_false' ); + + // Default serving + add_filter( 'json_serve_request', 'json_send_cors_headers' ); + add_filter( 'json_pre_dispatch', 'json_handle_options_request', 10, 2 ); } add_action( 'wp_json_server_before_serve', 'json_api_default_filters', 10, 1 ); /** - * Load the JSON API + * Load the JSON API. * * @todo Extract code that should be unit tested into isolated methods such as * the wp_json_server_class filter and serving requests. This would also @@ -82,75 +144,74 @@ function json_api_loaded() { if ( empty( $GLOBALS['wp']->query_vars['json_route'] ) ) return; - include_once( dirname( __FILE__ ) . '/lib/class-wp-json-server.php' ); - /** - * Whether this is a XMLRPC Request + * Whether this is a XML-RPC Request. * * @var bool * @todo Remove me in favour of JSON_REQUEST */ - define('XMLRPC_REQUEST', true); + define( 'XMLRPC_REQUEST', true ); /** - * Whether this is a JSON Request + * Whether this is a JSON Request. * * @var bool */ - define('JSON_REQUEST', true); + define( 'JSON_REQUEST', true ); global $wp_json_server; // Allow for a plugin to insert a different class to handle requests. - $wp_json_server_class = apply_filters('wp_json_server_class', 'WP_JSON_Server'); + $wp_json_server_class = apply_filters( 'wp_json_server_class', 'WP_JSON_Server' ); $wp_json_server = new $wp_json_server_class; /** - * Prepare to serve an API request. + * 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. * - * @param WP_JSON_ResponseHandler $wp_json_server Response handler object + * @param WP_JSON_ResponseHandler $wp_json_server Response handler object. */ - do_action('wp_json_server_before_serve', $wp_json_server); + do_action( 'wp_json_server_before_serve', $wp_json_server ); - // Fire off the request + // Fire off the request. $wp_json_server->serve_request( $GLOBALS['wp']->query_vars['json_route'] ); - // Finish off our request + // We're done. die(); } add_action( 'template_redirect', 'json_api_loaded', -100 ); /** * Register routes and flush the rewrite rules on activation. + * + * @param bool $network_wide ? */ function json_api_activation( $network_wide ) { if ( function_exists( 'is_multisite' ) && is_multisite() && $network_wide ) { - $mu_blogs = wp_get_sites(); foreach ( $mu_blogs as $mu_blog ) { - switch_to_blog( $mu_blog['blog_id'] ); + json_api_register_rewrites(); - flush_rewrite_rules(); + update_option( 'json_api_plugin_version', null ); } restore_current_blog(); - } else { - json_api_register_rewrites(); - flush_rewrite_rules(); + update_option( 'json_api_plugin_version', null ); } } register_activation_hook( __FILE__, 'json_api_activation' ); /** - * Flush the rewrite rules on deactivation + * Flush the rewrite rules on deactivation. + * + * @param bool $network_wide ? */ function json_api_deactivation( $network_wide ) { if ( function_exists( 'is_multisite' ) && is_multisite() && $network_wide ) { @@ -158,93 +219,605 @@ function json_api_deactivation( $network_wide ) { $mu_blogs = wp_get_sites(); foreach ( $mu_blogs as $mu_blog ) { - switch_to_blog( $mu_blog['blog_id'] ); - flush_rewrite_rules(); + delete_option( 'json_api_plugin_version' ); } restore_current_blog(); - } else { - - flush_rewrite_rules(); + delete_option( 'json_api_plugin_version' ); } } register_deactivation_hook( __FILE__, 'json_api_deactivation' ); /** - * Register our API Javascript helpers + * Register API Javascript helpers. + * + * @see wp_register_scripts() */ function json_register_scripts() { - wp_register_script( 'wp-api', plugins_url( '/wp-api.js', __FILE__ ), array( 'jquery', 'backbone', 'underscore' ), '0.6', true ); - wp_localize_script( 'wp-api', 'wpApiOptions', array( 'base' => json_url() ) ); + wp_register_script( 'wp-api', esc_url_raw( plugins_url( 'wp-api.js', __FILE__ ) ), array( 'jquery', 'backbone', 'underscore' ), '1.1', true ); + + $settings = array( 'root' => esc_url_raw( get_json_url() ), 'nonce' => wp_create_nonce( 'wp_json' ) ); + wp_localize_script( 'wp-api', 'WP_API_Settings', $settings ); } add_action( 'wp_enqueue_scripts', 'json_register_scripts', -100 ); +add_action( 'admin_enqueue_scripts', 'json_register_scripts', -100 ); /** - * Add the API URL to the WP RSD endpoint + * Add the API URL to the WP RSD endpoint. */ function json_output_rsd() { -?> + ?> -\n"; } add_action( 'wp_head', 'json_output_link_wp_head', 10, 0 ); /** - * Send a Link header for the API + * Send a Link header for the API. */ function json_output_link_header() { - if ( headers_sent() ) + if ( headers_sent() ) { return; + } $api_root = get_json_url(); - if ( empty($api_root) ) + if ( empty($api_root) ) { return; + } - header('Link: <' . $api_root . '>; rel="https://github.com/WP-API/WP-API"', false); + header( 'Link: <' . $api_root . '>; rel="https://github.com/WP-API/WP-API"', false ); } add_action( 'template_redirect', 'json_output_link_header', 11, 0 ); /** - * Get URL to a JSON endpoint on a site + * Add 'show_in_json' {@see register_post_type()} argument. + * + * Adds the 'show_in_json' post type argument to {@see register_post_type()}. + * This value controls whether the post type is available via API endpoints, + * and defaults to the value of $publicly_queryable. + * + * @global array $wp_post_types Post types list. + * + * @param string $post_type Post type to register. + * @param stdClass $args Post type arguments. + */ +function json_register_post_type( $post_type, $args ) { + global $wp_post_types; + + $type = &$wp_post_types[ $post_type ]; + + // Exception for pages. + if ( $post_type === 'page' ) { + $type->show_in_json = true; + } + + // Exception for revisions. + if ( $post_type === 'revision' ) { + $type->show_in_json = true; + } + + // Default to the value of $publicly_queryable. + if ( ! isset( $type->show_in_json ) ) { + $type->show_in_json = $type->publicly_queryable; + } +} +add_action( 'registered_post_type', 'json_register_post_type', 10, 2 ); + +/** + * Check for errors when using cookie-based authentication. + * + * WordPress' built-in cookie authentication is always active + * for logged in users. However, the API has to check nonces + * for each request to ensure users are not vulnerable to CSRF. + * + * @global mixed $wp_json_auth_cookie + * + * @param WP_Error|mixed $result Error from another authentication handler, + * null if we should handle it, or another + * value if not + * @return WP_Error|mixed|bool WP_Error if the cookie is invalid, the $result, + * otherwise true. + */ +function json_cookie_check_errors( $result ) { + if ( ! empty( $result ) ) { + return $result; + } + + global $wp_json_auth_cookie; + + /* + * Is cookie authentication being used? (If we get an auth + * error, but we're still logged in, another authentication + * must have been used.) + */ + if ( $wp_json_auth_cookie !== true && is_user_logged_in() ) { + return $result; + } + + // Is there a nonce? + $nonce = null; + if ( isset( $_REQUEST['_wp_json_nonce'] ) ) { + $nonce = $_REQUEST['_wp_json_nonce']; + } elseif ( isset( $_SERVER['HTTP_X_WP_NONCE'] ) ) { + $nonce = $_SERVER['HTTP_X_WP_NONCE']; + } + + if ( $nonce === null ) { + // No nonce at all, so act as if it's an unauthenticated request. + wp_set_current_user( 0 ); + return true; + } + + // Check the nonce. + $result = wp_verify_nonce( $nonce, 'wp_json' ); + if ( ! $result ) { + return new WP_Error( 'json_cookie_invalid_nonce', __( 'Cookie nonce is invalid' ), array( 'status' => 403 ) ); + } + + return true; +} +add_filter( 'json_authentication_errors', 'json_cookie_check_errors', 100 ); + +/** + * Collect cookie authentication status. + * + * Collects errors from {@see wp_validate_auth_cookie} for + * use by {@see json_cookie_check_errors}. + * + * @see current_action() + * @global mixed $wp_json_auth_cookie + */ +function json_cookie_collect_status() { + global $wp_json_auth_cookie; + + $status_type = current_action(); + + if ( $status_type !== 'auth_cookie_valid' ) { + $wp_json_auth_cookie = substr( $status_type, 12 ); + return; + } + + $wp_json_auth_cookie = true; +} +add_action( 'auth_cookie_malformed', 'json_cookie_collect_status' ); +add_action( 'auth_cookie_expired', 'json_cookie_collect_status' ); +add_action( 'auth_cookie_bad_username', 'json_cookie_collect_status' ); +add_action( 'auth_cookie_bad_hash', 'json_cookie_collect_status' ); +add_action( 'auth_cookie_valid', 'json_cookie_collect_status' ); + +/** + * Get the URL prefix for any API resource. + * + * @return string Prefix. + */ +function json_get_url_prefix() { + /** + * Filter the JSON URL prefix. + * + * @since 1.0 + * + * @param string $prefix URL prefix. Default 'wp-json'. + */ + return apply_filters( 'json_url_prefix', 'wp-json' ); +} + +/** + * Get URL to a JSON endpoint on a site. * * @todo Check if this is even necessary - * @param int $blog_id Blog ID - * @param string $path JSON route - * @param string $scheme Sanitization scheme (usually 'json') - * @return string Full URL to the endpoint + * + * @param int $blog_id Blog ID. + * @param string $path Optional. JSON route. Default empty. + * @param string $scheme Optional. Sanitization scheme. Default 'json'. + * @return string Full URL to the endpoint. */ function get_json_url( $blog_id = null, $path = '', $scheme = 'json' ) { - $url = get_home_url( $blog_id, 'wp-json', $scheme ); + if ( get_option( 'permalink_structure' ) ) { + $url = get_home_url( $blog_id, json_get_url_prefix(), $scheme ); + + if ( ! empty( $path ) && is_string( $path ) && strpos( $path, '..' ) === false ) + $url .= '/' . ltrim( $path, '/' ); + } else { + $url = trailingslashit( get_home_url( $blog_id, '', $scheme ) ); - if ( !empty( $path ) && is_string( $path ) && strpos( $path, '..' ) === false ) - $url .= '/' . ltrim( $path, '/' ); + if ( empty( $path ) ) { + $path = '/'; + } else { + $path = '/' . ltrim( $path, '/' ); + } + + $url = add_query_arg( 'json_route', $path, $url ); + } - return apply_filters( 'json_url', $url, $path, $blog_id ); + /** + * Filter the JSON URL. + * + * @since 1.0 + * + * @param string $url JSON URL. + * @param string $path JSON route. + * @param int $blod_ig Blog ID. + * @param string $scheme Sanitization scheme. + */ + return apply_filters( 'json_url', $url, $path, $blog_id, $scheme ); } /** - * Get URL to a JSON endpoint + * Get URL to a JSON endpoint. * - * @param string $path JSON route - * @param string $scheme Sanitization scheme (usually 'json') - * @return string Full URL to the endpoint + * @param string $path Optional. JSON route. Default empty. + * @param string $scheme Optional. Sanitization scheme. Default 'json'. + * @return string Full URL to the endpoint. */ function json_url( $path = '', $scheme = 'json' ) { return get_json_url( null, $path, $scheme ); } + +/** + * Ensure a JSON response is a response object. + * + * This ensures that the response is consistent, and implements + * {@see WP_JSON_ResponseInterface}, allowing usage of + * `set_status`/`header`/etc without needing to double-check the object. Will + * also allow {@see WP_Error} to indicate error responses, so users should + * immediately check for this value. + * + * @param WP_Error|WP_JSON_ResponseInterface|mixed $response Response to check. + * @return WP_Error|WP_JSON_ResponseInterface WP_Error if present, WP_JSON_ResponseInterface + * instance otherwise. + */ +function json_ensure_response( $response ) { + if ( is_wp_error( $response ) ) { + return $response; + } + + if ( $response instanceof WP_JSON_ResponseInterface ) { + return $response; + } + + return new WP_JSON_Response( $response ); +} + +/** + * Check if we have permission to interact with the post object. + * + * @param WP_Post $post Post object. + * @param string $capability Permission to check. + * @return boolean Can we interact with it? + */ +function json_check_post_permission( $post, $capability = 'read' ) { + $permission = false; + $post_type = get_post_type_object( $post['post_type'] ); + + switch ( $capability ) { + case 'read' : + if ( ! $post_type->show_in_json ) { + return false; + } + + if ( 'publish' === $post['post_status'] || current_user_can( $post_type->cap->read_post, $post['ID'] ) ) { + $permission = true; + } + + // Can we read the parent if we're inheriting? + if ( 'inherit' === $post['post_status'] && $post['post_parent'] > 0 ) { + $parent = get_post( $post['post_parent'], ARRAY_A ); + return json_check_post_permission( $parent, 'read' ); + } + + // If we don't have a parent, but the status is set to inherit, assume + // it's published (as per get_post_status()) + if ( 'inherit' === $post['post_status'] ) { + $permission = true; + } + break; + + case 'edit' : + if ( current_user_can( $post_type->cap->edit_post, $post['ID'] ) ) { + $permission = true; + } + break; + + case 'create' : + if ( current_user_can( $post_type->cap->create_posts ) || current_user_can( $post_type->cap->edit_posts ) ) { + $permission = true; + } + break; + + case 'delete' : + if ( current_user_can( $post_type->cap->delete_post, $post['ID'] ) ) { + $permission = true; + } + break; + + default : + if ( current_user_can( $post_type->cap->$capability ) ) { + $permission = true; + } + } + + return apply_filters( "json_check_post_{$capability}_permission", $permission, $post ); +} + +/** + * Parse an RFC3339 timestamp into a DateTime. + * + * @param string $date RFC3339 timestamp. + * @param bool $force_utc Force UTC timezone instead of using the timestamp's TZ. + * @return DateTime DateTime instance. + */ +function json_parse_date( $date, $force_utc = false ) { + if ( $force_utc ) { + $date = preg_replace( '/[+-]\d+:?\d+$/', '+00:00', $date ); + } + + $regex = '#^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}(?::\d{2})?)?$#'; + + if ( ! preg_match( $regex, $date, $matches ) ) { + return false; + } + + return strtotime( $date ); +} + +/** + * Get a local date with its GMT equivalent, in MySQL datetime format. + * + * @param string $date RFC3339 timestamp + * @param bool $force_utc Whether a UTC timestamp should be forced. + * @return array|null Local and UTC datetime strings, in MySQL datetime format (Y-m-d H:i:s), + * null on failure. + */ +function json_get_date_with_gmt( $date, $force_utc = false ) { + $date = json_parse_date( $date, $force_utc ); + + if ( empty( $date ) ) { + return null; + } + + $utc = date( 'Y-m-d H:i:s', $date ); + $local = get_date_from_gmt( $utc ); + + return array( $local, $utc ); +} + +/** + * Parses and formats a MySQL datetime (Y-m-d H:i:s) for ISO8601/RFC3339 + * + * Explicitly strips timezones, as datetimes are not saved with any timezone + * information. Including any information on the offset could be misleading. + * + * @param string $date_string + * + * @return mixed + */ +function json_mysql_to_rfc3339( $date_string ) { + $formatted = mysql2date( 'c', $date_string, false ); + + // Strip timezone information + return preg_replace( '/(?:Z|[+-]\d{2}(?::\d{2})?)$/', '', $formatted ); +} + +/** + * Retrieve the avatar url for a user who provided a user ID or email address. + * + * {@see get_avatar()} doesn't return just the URL, so we have to + * extract it here. + * + * @param string $email Email address. + * @return string URL for the user's avatar, empty string otherwise. +*/ +function json_get_avatar_url( $email ) { + $avatar_html = get_avatar( $email ); + + // Strip the avatar url from the get_avatar img tag. + preg_match('/src=["|\'](.+)[\&|"|\']/U', $avatar_html, $matches); + + if ( isset( $matches[1] ) && ! empty( $matches[1] ) ) { + return esc_url_raw( $matches[1] ); + } + + return ''; +} + +/** + * Get the timezone object for the site. + * + * @return DateTimeZone DateTimeZone instance. + */ +function json_get_timezone() { + static $zone = null; + + if ( $zone !== null ) { + return $zone; + } + + $tzstring = get_option( 'timezone_string' ); + + if ( ! $tzstring ) { + // Create a UTC+- zone if no timezone string exists + $current_offset = get_option( 'gmt_offset' ); + if ( 0 == $current_offset ) { + $tzstring = 'UTC'; + } elseif ( $current_offset < 0 ) { + $tzstring = 'Etc/GMT' . $current_offset; + } else { + $tzstring = 'Etc/GMT+' . $current_offset; + } + } + $zone = new DateTimeZone( $tzstring ); + + return $zone; +} + +/** + * Handle {@see _deprecated_function()} errors. + * + * @param string $function Function name. + * @param string $replacement Replacement function name. + * @param string $version Version. + */ +function json_handle_deprecated_function( $function, $replacement, $version ) { + if ( ! empty( $replacement ) ) { + $string = sprintf( __('%1$s (since %2$s; use %3$s instead)'), $function, $version, $replacement ); + } + else { + $string = sprintf( __('%1$s (since %2$s; no alternative available)'), $function, $version ); + } + + header( sprintf( 'X-WP-DeprecatedFunction: %s', $string ) ); +} + +/** + * Handle {@see _deprecated_function} errors. + * + * @param string $function Function name. + * @param string $replacement Replacement function name. + * @param string $version Version. + */ +function json_handle_deprecated_argument( $function, $message, $version ) { + if ( ! empty( $message ) ) { + $string = sprintf( __('%1$s (since %2$s; %3$s)'), $function, $version, $message ); + } + else { + $string = sprintf( __('%1$s (since %2$s; no alternative available)'), $function, $version ); + } + + header( sprintf( 'X-WP-DeprecatedParam: %s', $string ) ); +} + +/** + * Send Cross-Origin Resource Sharing headers with API requests + * + * @param mixed $value Response data + * @return mixed Response data + */ +function json_send_cors_headers( $value ) { + $origin = get_http_origin(); + + if ( $origin ) { + header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) ); + header( 'Access-Control-Allow-Methods: POST, GET, OPTIONS, PUT, DELETE' ); + header( 'Access-Control-Allow-Credentials: true' ); + } + + return $value; +} + +/** + * Handle OPTIONS requests for the server + * + * This is handled outside of the server code, as it doesn't obey normal route + * mapping. + * + * @param mixed $response Current response, either response or `null` to indicate pass-through + * @param WP_JSON_Server $handler ResponseHandler instance (usually WP_JSON_Server) + * @return WP_JSON_ResponseHandler Modified response, either response or `null` to indicate pass-through + */ +function json_handle_options_request( $response, $handler ) { + if ( ! empty( $response ) || $handler->method !== 'OPTIONS' ) { + return $response; + } + + $response = new WP_JSON_Response(); + + $accept = array(); + + $handler_class = get_class( $handler ); + $class_vars = get_class_vars( $handler_class ); + $map = $class_vars['method_map']; + + foreach ( $handler->get_routes() as $route => $endpoints ) { + $match = preg_match( '@^' . $route . '$@i', $handler->path, $args ); + + if ( ! $match ) { + continue; + } + + foreach ( $endpoints as $endpoint ) { + foreach ( $map as $type => $bitmask ) { + if ( $endpoint[1] & $bitmask ) { + $accept[] = $type; + } + } + } + break; + } + $accept = array_unique( $accept ); + + $response->header( 'Accept', implode( ', ', $accept ) ); + + return $response; +} + +if ( ! function_exists( 'json_last_error_msg' ) ): +/** + * Returns the error string of the last json_encode() or json_decode() call + * + * @internal This is a compatibility function for PHP <5.5 + * + * @return boolean|string Returns the error message on success, "No Error" if no error has occurred, or FALSE on failure. + */ +function json_last_error_msg() { + // see https://core.trac.wordpress.org/ticket/27799 + if ( ! function_exists( 'json_last_error' ) ) { + return false; + } + + $last_error_code = json_last_error(); + + // just in case JSON_ERROR_NONE is not defined + $error_code_none = defined( 'JSON_ERROR_NONE' ) ? JSON_ERROR_NONE : 0; + + switch ( true ) { + case $last_error_code === $error_code_none: + return 'No error'; + + case defined( 'JSON_ERROR_DEPTH' ) && JSON_ERROR_DEPTH === $last_error_code: + return 'Maximum stack depth exceeded'; + + case defined( 'JSON_ERROR_STATE_MISMATCH' ) && JSON_ERROR_STATE_MISMATCH === $last_error_code: + return 'State mismatch (invalid or malformed JSON)'; + + case defined( 'JSON_ERROR_CTRL_CHAR' ) && JSON_ERROR_CTRL_CHAR === $last_error_code: + return 'Control character error, possibly incorrectly encoded'; + + case defined( 'JSON_ERROR_SYNTAX' ) && JSON_ERROR_SYNTAX === $last_error_code: + return 'Syntax error'; + + case defined( 'JSON_ERROR_UTF8' ) && JSON_ERROR_UTF8 === $last_error_code: + return 'Malformed UTF-8 characters, possibly incorrectly encoded'; + + case defined( 'JSON_ERROR_RECURSION' ) && JSON_ERROR_RECURSION === $last_error_code: + return 'Recursion detected'; + + case defined( 'JSON_ERROR_INF_OR_NAN' ) && JSON_ERROR_INF_OR_NAN === $last_error_code: + return 'Inf and NaN cannot be JSON encoded'; + + case defined( 'JSON_ERROR_UNSUPPORTED_TYPE' ) && JSON_ERROR_UNSUPPORTED_TYPE === $last_error_code: + return 'Type is not supported'; + + default: + return 'An unknown error occurred'; + } +} +endif; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 42cd9587a1..016e1390f5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,22 +1,31 @@ array( 'WP-API/plugin.php' ), -); +// Support for: +// 1. `WP_DEVELOP_DIR` environment variable +// 2. Plugin installed inside of WordPress.org developer checkout +// 3. Tests checked out to /tmp +if( false !== getenv( 'WP_DEVELOP_DIR' ) ) { + $test_root = getenv( 'WP_DEVELOP_DIR' ); +} else if ( file_exists( '../../../../tests/phpunit/includes/bootstrap.php' ) ) { + $test_root = '../../../../tests/phpunit'; +} else if ( file_exists( '/tmp/wordpress-tests-lib/includes/bootstrap.php' ) ) { + $test_root = '/tmp/wordpress-tests-lib'; +} -// If the develop repo location is defined (as WP_DEVELOP_DIR), use that -// location. Otherwise, we'll just assume that this plugin is installed in a -// WordPress develop SVN checkout. +require $test_root . '/includes/functions.php'; -if( false !== getenv( 'WP_DEVELOP_DIR' ) ) { - require getenv( 'WP_DEVELOP_DIR' ) . '/tests/phpunit/includes/bootstrap.php'; -} else { - require '../../../../tests/phpunit/includes/bootstrap.php'; +function _manually_load_plugin() { + require dirname( __FILE__ ) . '/../plugin.php'; } +tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' ); + +require $test_root . '/includes/bootstrap.php'; + +// Helper classes +require_once dirname( __FILE__ ) . '/test-json-testcase.php'; diff --git a/testhelper.php b/tests/helper.php similarity index 100% rename from testhelper.php rename to tests/helper.php diff --git a/tests/taxonomies_caller.php b/tests/taxonomies_caller.php new file mode 100644 index 0000000000..3a68391934 --- /dev/null +++ b/tests/taxonomies_caller.php @@ -0,0 +1,7 @@ +assertTrue( is_plugin_active( 'WP-API/plugin.php' ) ); + $this->assertTrue( class_exists( 'WP_JSON_Posts' ) ); } /** * The json_api_init hook should have been registered with init, and should - * have a default priority of 10. + * have a default priority of 11. */ function test_init_action_added() { - $this->assertEquals( 10, has_action( 'init', 'json_api_init' ) ); + $this->assertEquals( 11, has_action( 'init', 'json_api_init' ) ); } /** * The json_route query variable should be registered. */ function test_json_route_query_var() { + json_api_init(); global $wp; $this->assertTrue( in_array( 'json_route', $wp->public_query_vars ) ); } diff --git a/tests/test-json-post-comments.php b/tests/test-json-post-comments.php new file mode 100644 index 0000000000..a22d530314 --- /dev/null +++ b/tests/test-json-post-comments.php @@ -0,0 +1,62 @@ +post_id = $this->factory->post->create( array( + 'post_title' => 'some-post', + 'post_content' => 'fascinating information', + 'post_type' => 'post', + ) ); + $this->fake_server = $this->getMock( 'WP_JSON_Server' ); + $this->endpoint = new WP_JSON_Comments( $this->fake_server ); + } + + public function test_get_comments() { + $comments = $this->factory->comment->create_post_comments( $this->post_id, 10 ); + + $response = $this->endpoint->get_comments( $this->post_id ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 10, count( $data ) ); + } + + private function _create_comment( $args = array() ) { + $default_args = array( + 'comment_post_ID' => $this->post_id, + ); + $comment_args = wp_parse_args( $args, $default_args ); + + return $this->factory->comment->create_object( $comment_args ); + } + + public function test_get_comment() { + $comment_id = $this->_create_comment(); + + $response = $this->endpoint->get_comment( (int) $comment_id ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + $this->assertEquals( 200, $response->get_status() ); + } + + public function test_get_comment_with_parent() { + $comment_parent_id = $this->_create_comment(); + $comment_id = $this->_create_comment( array( 'comment_parent' => (int) $comment_parent_id ) ); + + $response = $this->endpoint->get_comment( $comment_id ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + $this->assertEquals( 200, $response->get_status() ); + } + +} diff --git a/tests/test-json-post-revisions.php b/tests/test-json-post-revisions.php new file mode 100644 index 0000000000..ed85b724e8 --- /dev/null +++ b/tests/test-json-post-revisions.php @@ -0,0 +1,95 @@ +author = $this->factory->user->create( array( 'role' => 'author' ) ); + $this->contributor = $this->factory->user->create( array( 'role' => 'contributor' ) ); + $this->post_id = $this->factory->post->create( array( + 'post_author' => $this->author, + 'post_title' => md5( wp_generate_password() ), + 'post_content' => md5( wp_generate_password() ) + ) ); + $this->revision_id = wp_save_post_revision( $this->post_id ); + + $this->fake_server = $this->getMock('WP_JSON_Server'); + $this->endpoint = new WP_JSON_Posts( $this->fake_server ); + + } + + protected function assertErrorResponse( $code, $response, $status = null ) { + $this->assertInstanceOf( 'WP_Error', $response ); + $this->assertEquals( $code, $response->get_error_code() ); + + if ( $status !== null ) { + $data = $response->get_error_data(); + $this->assertArrayHasKey( 'status', $data ); + $this->assertEquals( $status, $data['status'] ); + } + } + + public function test_revisions_access_by_user() { + + // Contributor shouldn't have access to another user's revisions + wp_set_current_user( $this->contributor ); + $response = $this->endpoint->get_revisions( $this->post_id ); + $this->assertErrorResponse( 'json_cannot_view', $response, 403 ); + + // Logged out users shouldn't have access to any revisions + wp_set_current_user( 0 ); + $response = $this->endpoint->get_revisions( $this->post_id ); + $this->assertErrorResponse( 'json_cannot_view', $response, 403 ); + + // Authors should have access to their own revisions + wp_set_current_user( $this->author ); + $response = $this->endpoint->get_revisions( $this->post_id ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 1, count( $data ) ); + + } + + public function test_post_no_revisions() { + + $no_revisions_id = $this->factory->post->create( array( + 'post_author' => $this->author, + 'post_title' => md5( wp_generate_password() ), + 'post_content' => md5( wp_generate_password() ) + ) ); + + wp_set_current_user( $this->author ); + $response = $this->endpoint->get_revisions( $no_revisions_id ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 0, count( $data ) ); + + } + + public function test_access_single_revision() { + + wp_set_current_user( 0 ); + $response = $this->endpoint->get_post( $this->revision_id ); + $this->assertErrorResponse( 'json_user_cannot_read', $response, 401 ); + + wp_set_current_user( $this->author ); + $response = $this->endpoint->get_post( $this->revision_id ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + $this->assertEquals( 200, $response->get_status() ); + + } + +} diff --git a/tests/test-json-posts-meta.php b/tests/test-json-posts-meta.php new file mode 100644 index 0000000000..b23de49180 --- /dev/null +++ b/tests/test-json-posts-meta.php @@ -0,0 +1,735 @@ +user = $this->factory->user->create(); + wp_set_current_user( $this->user ); + $this->user_obj = wp_get_current_user(); + $this->user_obj->add_role( 'author' ); + + $this->fake_server = $this->getMock( 'WP_JSON_Server' ); + $this->endpoint = new WP_JSON_Meta_Posts( $this->fake_server ); + } + + public function test_get_meta() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $response = $this->endpoint->get_meta( $post_id, $meta_id ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( $meta_id, $data['ID'] ); + $this->assertEquals( 'testkey', $data['key'] ); + $this->assertEquals( 'testvalue', $data['value'] ); + } + + public function test_get_meta_no_post_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $response = $this->endpoint->get_meta( 0, $meta_id ); + $this->assertErrorResponse( 'json_post_invalid_id', $response, 404 ); + } + + public function test_get_meta_invalid_post_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $response = $this->endpoint->get_meta( -1, $meta_id ); + $this->assertErrorResponse( 'json_post_invalid_id', $response, 404 ); + } + + public function test_get_meta_no_meta_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $response = $this->endpoint->get_meta( $post_id, 0 ); + $this->assertErrorResponse( 'json_meta_invalid_id', $response, 404 ); + } + + public function test_get_meta_invalid_meta_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $response = $this->endpoint->get_meta( $post_id, -1 ); + $this->assertErrorResponse( 'json_meta_invalid_id', $response, 404 ); + } + + public function test_get_meta_protected() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, '_testkey', 'testvalue' ); + + $response = $this->endpoint->get_meta( $post_id, $meta_id ); + $this->assertErrorResponse( 'json_meta_protected', $response, 403 ); + } + + public function test_get_meta_serialized_array() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', array( 'testvalue' => 'test' ) ); + + $response = $this->endpoint->get_meta( $post_id, $meta_id ); + $this->assertErrorResponse( 'json_meta_protected', $response, 403 ); + } + + public function test_get_meta_serialized_object() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', (object) array( 'testvalue' => 'test' ) ); + + $response = $this->endpoint->get_meta( $post_id, $meta_id ); + $this->assertErrorResponse( 'json_meta_protected', $response, 403 ); + } + + public function test_get_meta_unauthenticated() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + wp_set_current_user( 0 ); + + $response = $this->endpoint->get_meta( $post_id, $meta_id ); + $this->assertErrorResponse( 'json_cannot_edit', $response, 403 ); + } + + public function test_get_meta_wrong_post() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $post_id_two = $this->factory->post->create(); + $meta_id_two = add_post_meta( $post_id_two, 'testkey', 'testvalue' ); + + $response = $this->endpoint->get_meta( $post_id_two, $meta_id ); + $this->assertErrorResponse( 'json_meta_post_mismatch', $response, 400 ); + + $response = $this->endpoint->get_meta( $post_id, $meta_id_two ); + $this->assertErrorResponse( 'json_meta_post_mismatch', $response, 400 ); + } + + public function test_get_all_meta() { + $post_id = $this->factory->post->create(); + $meta_id_basic = add_post_meta( $post_id, 'testkey', 'testvalue' ); + $meta_id_other1 = add_post_meta( $post_id, 'testotherkey', 'testvalue1' ); + $meta_id_other2 = add_post_meta( $post_id, 'testotherkey', 'testvalue2' ); + $value = array( 'testvalue1', 'testvalue2' ); + $meta_id_serialized = add_post_meta( $post_id, 'testkey', $value ); + $value = (object) array( 'testvalue' => 'test' ); + $meta_id_serialized_object = add_post_meta( $post_id, 'testkey', $value ); + $value = serialize( array( 'testkey1' => 'testvalue1', 'testkey2' => 'testvalue2' ) ); + $meta_id_serialized_string = add_post_meta( $post_id, 'testkey', $value ); + $meta_id_protected = add_post_meta( $post_id, '_testkey', 'testvalue' ); + + $response = $this->endpoint->get_all_meta( $post_id ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 3, $data ); + + foreach ( $data as $row ) { + $this->assertArrayHasKey( 'ID', $row ); + $this->assertArrayHasKey( 'key', $row ); + $this->assertArrayHasKey( 'value', $row ); + + $this->assertTrue( in_array( $row['ID'], array( $meta_id_basic, $meta_id_other1, $meta_id_other2 ) ) ); + + if ( $row['ID'] === $meta_id_basic ) { + $this->assertEquals( 'testkey', $row['key'] ); + $this->assertEquals( 'testvalue', $row['value'] ); + } + elseif ( $row['ID'] === $meta_id_other1 ) { + $this->assertEquals( 'testotherkey', $row['key'] ); + $this->assertEquals( 'testvalue1', $row['value'] ); + } + elseif ( $row['ID'] === $meta_id_other2 ) { + $this->assertEquals( 'testotherkey', $row['key'] ); + $this->assertEquals( 'testvalue2', $row['value'] ); + } + else { + $this->fail(); + } + } + } + + public function test_get_all_meta_no_post_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $response = $this->endpoint->get_all_meta( 0 ); + $this->assertErrorResponse( 'json_post_invalid_id', $response ); + } + + public function test_get_all_meta_invalid_post_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $response = $this->endpoint->get_all_meta( -1 ); + $this->assertErrorResponse( 'json_post_invalid_id', $response ); + } + + public function test_get_all_meta_unauthenticated() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + wp_set_current_user( 0 ); + + $response = $this->endpoint->get_all_meta( $post_id ); + $this->assertErrorResponse( 'json_cannot_edit', $response ); + } + + public function test_add_meta() { + $post_id = $this->factory->post->create(); + $data = array( + 'key' => 'testkey', + 'value' => 'testvalue', + ); + $response = $this->endpoint->add_meta( $post_id, $data ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + + $meta = get_post_meta( $post_id, 'testkey', false ); + $this->assertNotEmpty( $meta ); + $this->assertCount( 1, $meta ); + $this->assertEquals( 'testvalue', $meta[0] ); + + $data = $response->get_data(); + $this->assertObjectHasAttribute( 'ID', $data ); + $this->assertEquals( 'testkey', $data->key ); + $this->assertEquals( 'testvalue', $data->value ); + } + + public function test_add_meta_no_post_id() { + $data = array( + 'key' => 'testkey', + 'value' => 'testvalue', + ); + $response = $this->endpoint->add_meta( 0, $data ); + $this->assertErrorResponse( 'json_post_invalid_id', $response, 404 ); + } + + public function test_add_meta_invalid_post_id() { + $data = array( + 'key' => 'testkey', + 'value' => 'testvalue', + ); + $response = $this->endpoint->add_meta( -1, $data ); + $this->assertErrorResponse( 'json_post_invalid_id', $response, 404 ); + } + + public function test_add_meta_no_value() { + $post_id = $this->factory->post->create(); + $data = array( + 'key' => 'testkey', + ); + $response = $this->endpoint->add_meta( $post_id, $data ); + $this->assertErrorResponse( 'json_post_missing_value', $response, 400 ); + } + + public function test_add_meta_no_key() { + $post_id = $this->factory->post->create(); + $data = array( + 'value' => 'testvalue', + ); + $response = $this->endpoint->add_meta( $post_id, $data ); + $this->assertErrorResponse( 'json_post_missing_key', $response, 400 ); + } + + public function test_add_meta_invalid_key() { + $post_id = $this->factory->post->create(); + $data = array( + 'key' => false, + 'value' => 'testvalue', + ); + $response = $this->endpoint->add_meta( $post_id, $data ); + $this->assertErrorResponse( 'json_meta_invalid_key', $response, 400 ); + } + + public function test_add_meta_unauthenticated() { + $post_id = $this->factory->post->create(); + $data = array( + 'key' => 'testkey', + 'value' => 'testvalue', + ); + + wp_set_current_user( 0 ); + + $response = $this->endpoint->add_meta( $post_id, $data ); + $this->assertErrorResponse( 'json_cannot_edit', $response, 403 ); + $this->assertEmpty( get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_add_meta_serialized_array() { + $post_id = $this->factory->post->create(); + $data = array( + 'key' => 'testkey', + 'value' => array( 'testvalue1', 'testvalue2' ), + ); + + $response = $this->endpoint->add_meta( $post_id, $data ); + $this->assertErrorResponse( 'json_post_invalid_action', $response, 400 ); + $this->assertEmpty( get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_add_meta_serialized_object() { + $post_id = $this->factory->post->create(); + $data = array( + 'key' => 'testkey', + 'value' => (object) array( 'testkey1' => 'testvalue1', 'testkey2' => 'testvalue2' ), + ); + + $response = $this->endpoint->add_meta( $post_id, $data ); + $this->assertErrorResponse( 'json_post_invalid_action', $response, 400 ); + $this->assertEmpty( get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_add_meta_serialized_string() { + $post_id = $this->factory->post->create(); + $data = array( + 'key' => 'testkey', + 'value' => serialize( array( 'testkey1' => 'testvalue1', 'testkey2' => 'testvalue2' ) ), + ); + + $response = $this->endpoint->add_meta( $post_id, $data ); + $this->assertErrorResponse( 'json_post_invalid_action', $response, 400 ); + $this->assertEmpty( get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_add_meta_failed_get() { + $this->endpoint = $this->getMock( 'WP_JSON_Meta_Posts', array('get_meta'), array( $this->fake_server ) ); + + $test_error = new WP_Error( 'json_test_error', 'Test error' ); + $this->endpoint->expects( $this->any() )->method( 'get_meta' )->will( $this->returnValue( $test_error ) ); + + $post_id = $this->factory->post->create(); + $data = array( + 'key' => 'testkey', + 'value' => 'testvalue', + ); + + $response = $this->endpoint->add_meta( $post_id, $data ); + $this->assertErrorResponse( 'json_test_error', $response ); + } + + public function test_add_meta_protected() { + $post_id = $this->factory->post->create(); + $data = array( + 'key' => '_testkey', + 'value' => 'testvalue', + ); + + $response = $this->endpoint->add_meta( $post_id, $data ); + $this->assertErrorResponse( 'json_meta_protected', $response, 403 ); + $this->assertEmpty( get_post_meta( $post_id, '_testkey' ) ); + } + + public function test_update_meta_value() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $data = array( + 'value' => 'testnewvalue', + ); + $response = $this->endpoint->update_meta( $post_id, $meta_id, $data ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( $meta_id, $data['ID'] ); + $this->assertEquals( 'testkey', $data['key'] ); + $this->assertEquals( 'testnewvalue', $data['value'] ); + + $meta = get_post_meta( $post_id, 'testkey', false ); + $this->assertNotEmpty( $meta ); + $this->assertCount( 1, $meta ); + $this->assertEquals( 'testnewvalue', $meta[0] ); + } + + public function test_update_meta_key() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $data = array( + 'key' => 'testnewkey', + ); + $response = $this->endpoint->update_meta( $post_id, $meta_id, $data ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( $meta_id, $data['ID'] ); + $this->assertEquals( 'testnewkey', $data['key'] ); + $this->assertEquals( 'testvalue', $data['value'] ); + + $meta = get_post_meta( $post_id, 'testnewkey', false ); + $this->assertNotEmpty( $meta ); + $this->assertCount( 1, $meta ); + $this->assertEquals( 'testvalue', $meta[0] ); + + // Ensure it was actually renamed, not created + $meta = get_post_meta( $post_id, 'testkey', false ); + $this->assertEmpty( $meta ); + } + + public function test_update_meta_key_and_value() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $data = array( + 'key' => 'testnewkey', + 'value' => 'testnewvalue', + ); + $response = $this->endpoint->update_meta( $post_id, $meta_id, $data ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( $meta_id, $data['ID'] ); + $this->assertEquals( 'testnewkey', $data['key'] ); + $this->assertEquals( 'testnewvalue', $data['value'] ); + + $meta = get_post_meta( $post_id, 'testnewkey', false ); + $this->assertNotEmpty( $meta ); + $this->assertCount( 1, $meta ); + $this->assertEquals( 'testnewvalue', $meta[0] ); + + // Ensure it was actually renamed, not created + $meta = get_post_meta( $post_id, 'testkey', false ); + $this->assertEmpty( $meta ); + } + + public function test_update_meta_empty() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $data = array(); + $response = $this->endpoint->update_meta( $post_id, $meta_id, $data ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( $meta_id, $data['ID'] ); + $this->assertEquals( 'testkey', $data['key'] ); + $this->assertEquals( 'testvalue', $data['value'] ); + + $meta = get_post_meta( $post_id, 'testkey', false ); + $this->assertNotEmpty( $meta ); + $this->assertCount( 1, $meta ); + $this->assertEquals( 'testvalue', $meta[0] ); + } + + public function test_update_meta_no_post_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $data = array( + 'key' => 'testnewkey', + 'value' => 'testnewvalue', + ); + $response = $this->endpoint->update_meta( 0, $meta_id, $data ); + $this->assertErrorResponse( 'json_post_invalid_id', $response, 404 ); + } + + public function test_update_meta_invalid_post_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $data = array( + 'key' => 'testnewkey', + 'value' => 'testnewvalue', + ); + $response = $this->endpoint->update_meta( -1, $meta_id, $data ); + $this->assertErrorResponse( 'json_post_invalid_id', $response, 404 ); + } + + public function test_update_meta_no_meta_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $data = array( + 'key' => 'testnewkey', + 'value' => 'testnewvalue', + ); + $response = $this->endpoint->update_meta( $post_id, 0, $data ); + $this->assertErrorResponse( 'json_meta_invalid_id', $response, 404 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_update_meta_invalid_meta_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $data = array( + 'key' => 'testnewkey', + 'value' => 'testnewvalue', + ); + $response = $this->endpoint->update_meta( $post_id, -1, $data ); + $this->assertErrorResponse( 'json_meta_invalid_id', $response, 404 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_update_meta_unauthenticated() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + wp_set_current_user( 0 ); + + $data = array( + 'key' => 'testnewkey', + 'value' => 'testnewvalue', + ); + $response = $this->endpoint->update_meta( $post_id, $meta_id, $data ); + $this->assertErrorResponse( 'json_cannot_edit', $response, 403 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_update_meta_wrong_post() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $post_id_two = $this->factory->post->create(); + $meta_id_two = add_post_meta( $post_id_two, 'testkey', 'testvalue' ); + + $data = array( + 'key' => 'testnewkey', + 'value' => 'testnewvalue', + ); + $response = $this->endpoint->update_meta( $post_id_two, $meta_id, $data ); + $this->assertErrorResponse( 'json_meta_post_mismatch', $response, 400 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id_two, 'testkey' ) ); + + $response = $this->endpoint->get_meta( $post_id, $meta_id_two, $data ); + $this->assertErrorResponse( 'json_meta_post_mismatch', $response, 400 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_update_meta_serialized_array() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $data = array( + 'value' => array( 'testvalue1', 'testvalue2' ), + ); + $response = $this->endpoint->update_meta( $post_id, $meta_id, $data ); + $this->assertErrorResponse( 'json_post_invalid_action', $response, 400 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_update_meta_serialized_object() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $data = array( + 'value' => (object) array( 'testkey1' => 'testvalue1', 'testkey2' => 'testvalue2' ), + ); + $response = $this->endpoint->update_meta( $post_id, $meta_id, $data ); + $this->assertErrorResponse( 'json_post_invalid_action', $response, 400 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_update_meta_serialized_string() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $data = array( + 'value' => serialize( array( 'testkey1' => 'testvalue1', 'testkey2' => 'testvalue2' ) ), + ); + $response = $this->endpoint->update_meta( $post_id, $meta_id, $data ); + $this->assertErrorResponse( 'json_post_invalid_action', $response, 400 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_update_meta_existing_serialized() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', array( 'testvalue1', 'testvalue2' ) ); + + $data = array( + 'value' => 'testnewvalue', + ); + $response = $this->endpoint->update_meta( $post_id, $meta_id, $data ); + $this->assertErrorResponse( 'json_post_invalid_action', $response, 400 ); + $this->assertEquals( array( array( 'testvalue1', 'testvalue2' ) ), get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_update_meta_protected() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, '_testkey', 'testvalue' ); + + $data = array( + 'value' => 'testnewvalue', + ); + $response = $this->endpoint->update_meta( $post_id, $meta_id, $data ); + $this->assertErrorResponse( 'json_meta_protected', $response, 403 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, '_testkey' ) ); + } + + public function test_update_meta_protected_new() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $data = array( + 'key' => '_testnewkey', + 'value' => 'testnewvalue', + ); + $response = $this->endpoint->update_meta( $post_id, $meta_id, $data ); + $this->assertErrorResponse( 'json_meta_protected', $response, 403 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey' ) ); + $this->assertEmpty( get_post_meta( $post_id, '_testnewkey' ) ); + } + + public function test_update_meta_invalid_key() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $data = array( + 'key' => false, + 'value' => 'testnewvalue', + ); + $response = $this->endpoint->update_meta( $post_id, $meta_id, $data ); + $this->assertErrorResponse( 'json_meta_invalid_key', $response, 400 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_delete_meta() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $response = $this->endpoint->delete_meta( $post_id, $meta_id ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'message', $data ); + $this->assertNotEmpty( $data['message'] ); + + $meta = get_post_meta( $post_id, 'testkey', false ); + $this->assertEmpty( $meta ); + } + + public function test_delete_meta_no_post_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $response = $this->endpoint->delete_meta( 0, $meta_id ); + $this->assertErrorResponse( 'json_post_invalid_id', $response, 404 ); + + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey', false ) ); + } + + public function test_delete_meta_invalid_post_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $response = $this->endpoint->delete_meta( -1, $meta_id ); + $this->assertErrorResponse( 'json_post_invalid_id', $response, 404 ); + + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey', false ) ); + } + + public function test_delete_meta_no_meta_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $response = $this->endpoint->delete_meta( $post_id, 0 ); + $this->assertErrorResponse( 'json_meta_invalid_id', $response, 404 ); + + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey', false ) ); + } + + public function test_delete_meta_invalid_meta_id() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $response = $this->endpoint->delete_meta( $post_id, -1 ); + $this->assertErrorResponse( 'json_meta_invalid_id', $response, 404 ); + + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey', false ) ); + } + + public function test_delete_meta_unauthenticated() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + wp_set_current_user( 0 ); + + $response = $this->endpoint->delete_meta( $post_id, $meta_id ); + $this->assertErrorResponse( 'json_cannot_edit', $response, 403 ); + + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey', false ) ); + } + + public function test_delete_meta_wrong_post() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, 'testkey', 'testvalue' ); + + $post_id_two = $this->factory->post->create(); + $meta_id_two = add_post_meta( $post_id_two, 'testkey', 'testvalue' ); + + $response = $this->endpoint->delete_meta( $post_id_two, $meta_id ); + $this->assertErrorResponse( 'json_meta_post_mismatch', $response, 400 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id_two, 'testkey' ) ); + + $response = $this->endpoint->delete_meta( $post_id, $meta_id_two ); + $this->assertErrorResponse( 'json_meta_post_mismatch', $response, 400 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_delete_meta_serialized_array() { + $post_id = $this->factory->post->create(); + $value = array( 'testvalue1', 'testvalue2' ); + $meta_id = add_post_meta( $post_id, 'testkey', $value ); + + $response = $this->endpoint->delete_meta( $post_id, $meta_id ); + $this->assertErrorResponse( 'json_post_invalid_action', $response, 400 ); + $this->assertEquals( array( $value ), get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_delete_meta_serialized_object() { + $post_id = $this->factory->post->create(); + $value = (object) array( 'testkey1' => 'testvalue1', 'testkey2' => 'testvalue2' ); + $meta_id = add_post_meta( $post_id, 'testkey', $value ); + + $response = $this->endpoint->delete_meta( $post_id, $meta_id ); + $this->assertErrorResponse( 'json_post_invalid_action', $response, 400 ); + $this->assertEquals( array( $value ), get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_delete_meta_serialized_string() { + $post_id = $this->factory->post->create(); + $value = serialize( array( 'testkey1' => 'testvalue1', 'testkey2' => 'testvalue2' ) ); + $meta_id = add_post_meta( $post_id, 'testkey', $value ); + + $response = $this->endpoint->delete_meta( $post_id, $meta_id ); + $this->assertErrorResponse( 'json_post_invalid_action', $response, 400 ); + $this->assertEquals( array( $value ), get_post_meta( $post_id, 'testkey' ) ); + } + + public function test_delete_meta_protected() { + $post_id = $this->factory->post->create(); + $meta_id = add_post_meta( $post_id, '_testkey', 'testvalue' ); + + $response = $this->endpoint->delete_meta( $post_id, $meta_id ); + $this->assertErrorResponse( 'json_meta_protected', $response, 403 ); + $this->assertEquals( array( 'testvalue' ), get_post_meta( $post_id, '_testkey' ) ); + } +} diff --git a/tests/test-json-posts.php b/tests/test-json-posts.php new file mode 100644 index 0000000000..0c6e9a823f --- /dev/null +++ b/tests/test-json-posts.php @@ -0,0 +1,741 @@ +author_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $this->author_id ); + + $this->post_id = $this->factory->post->create(); + $this->post_obj = get_post( $this->post_id ); + + $this->fake_server = $this->getMock( 'WP_JSON_Server', null ); + $this->endpoint = new WP_JSON_Posts( $this->fake_server ); + } + + protected function set_data( $args = array() ) { + $defaults = array( + 'title' => rand_str(), + 'content_raw' => rand_str(), + 'excerpt_raw' => rand_str(), + 'name' => 'test', + 'status' => 'publish', + 'author' => $this->author_id, + ); + + return wp_parse_args( $args, $defaults ); + } + + protected function check_get_post_response( $response, $post_obj, $context = 'view' ) { + $response = json_ensure_response( $response ); + $response_data = $response->get_data(); + + $this->assertEquals( $post_obj->ID, $response_data['ID'] ); + $this->assertEquals( $post_obj->post_name, $response_data['slug'] ); + $this->assertEquals( $post_obj->post_status, $response_data['status'] ); + $this->assertEquals( $post_obj->post_author, $response_data['author'] ); + $this->assertArrayHasKey( 'parent', $response_data ); + $this->assertEquals( get_permalink( $post_obj->ID ), $response_data['link'] ); + $this->assertEquals( $post_obj->menu_order, $response_data['menu_order'] ); + $this->assertEquals( $post_obj->comment_status, $response_data['comment_status'] ); + $this->assertEquals( $post_obj->ping_status, $response_data['ping_status'] ); + $this->assertEquals( $post_obj->post_password, $response_data['password'] ); + $this->assertEquals( is_sticky( $post_obj->ID ), $response_data['sticky'] ); + + // Check post parent. + if ( $post_obj->post_parent ) { + if ( is_int( $response_data['parent'] ) ) { + $this->assertEquals( $post_obj->post_parent, $response_data['parent'] ); + } + else { + $this->assertEquals( $post_obj->post_parent, $response_data['parent']['ID'] ); + $this->check_get_post_response( $response_data['parent'], get_post( $response_data['parent']['ID'] ), 'view-parent' ); + } + } + else { + $this->assertEmpty( $response_data['parent'] ); + } + + // Check post format. + $post_format = get_post_format( $post_obj->ID ); + if ( empty( $post_format ) ) { + $this->assertEquals( 'standard', $response_data['format'] ); + } else { + $this->assertEquals( get_post_format( $post_obj->ID ), $response_data['format'] ); + } + + // Check post dates. + if ( $post_obj->post_date_gmt === '0000-00-00 00:00:00' ) { + $this->assertNull( $response_data['date'] ); + $this->assertNull( $response_data['date_gmt'] ); + } + else { + $this->assertEquals( json_mysql_to_rfc3339( $post_obj->post_date ), $response_data['date'] ); + $this->assertEquals( json_mysql_to_rfc3339( $post_obj->post_date_gmt ), $response_data['date_gmt'] ); + } + + if ( $post_obj->post_modified_gmt === '0000-00-00 00:00:00' ) { + $this->assertNull( $response_data['modified'] ); + $this->assertNull( $response_data['modified_gmt'] ); + } + else { + $this->assertEquals( json_mysql_to_rfc3339( $post_obj->post_modified ), $response_data['modified'] ); + $this->assertEquals( json_mysql_to_rfc3339( $post_obj->post_modified_gmt ), $response_data['modified_gmt'] ); + } + + + // Check filtered values. + $this->assertEquals( get_the_title( $post_obj->ID ), $response_data['title'] ); + // TODO: apply content filter for more accurate testing. + $this->assertEquals( wpautop( $post_obj->post_content ), $response_data['content'] ); + // TODO: apply excerpt filter for more accurate testing. + $this->assertEquals( wpautop( $post_obj->post_excerpt ), $response_data['excerpt'] ); + $this->assertEquals( $post_obj->guid, $response_data['guid'] ); + + if ( $context === 'edit' ) { + $this->assertEquals( $post_obj->post_content, $response_data['content_raw'] ); + $this->assertEquals( $post_obj->post_excerpt, $response_data['excerpt_raw'] ); + } + + } + + protected function check_create_response( $response ) { + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + $headers = $response->get_headers(); + + // Check that we succeeded + $this->assertEquals( 201, $response->get_status() ); + $this->assertArrayHasKey( 'Location', $headers ); + $this->assertArrayHasKey( 'Last-Modified', $headers ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + + $this->check_get_post_response( $response, $new_post ); + } + + function test_create_post() { + $data = $this->set_data(); + $response = $this->endpoint->create_post( $data ); + $this->check_create_response( $response ); + } + + function test_create_post_other_type() { + $data = $this->set_data(array( + 'type' => 'page', + )); + $response = $this->endpoint->create_post( $data ); + $this->check_create_response( $response ); + } + + function test_create_post_invalid_type() { + $data = $this->set_data(array( + 'type' => 'testposttype', + )); + $response = $this->endpoint->create_post( $data ); + $this->assertErrorResponse( 'json_invalid_post_type', $response, 400 ); + } + + function test_create_post_other_author() { + $other_user = $this->factory->user->create( array( 'role' => 'author' ) ); + $data = $this->set_data(array( + 'author' => $other_user, + )); + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( $data['author'], $new_post->post_author ); + } + + function test_create_post_other_author_object() { + $other_user = $this->factory->user->create( array( 'role' => 'author' ) ); + $data = $this->set_data(array( + 'author' => (object) array( + 'ID' => $other_user, + ), + )); + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( $data['author']->ID, $new_post->post_author ); + } + + function test_create_post_invalid_author() { + $data = $this->set_data(array( + 'author' => -1, + )); + $response = $this->endpoint->create_post( $data ); + $this->assertErrorResponse( 'json_invalid_author', $response, 400 ); + } + + function test_create_post_invalid_author_object() { + $data = $this->set_data(array( + 'author' => (object) array( + 'ID' => -1, + ), + )); + $response = $this->endpoint->create_post( $data ); + $this->assertErrorResponse( 'json_invalid_author', $response, 400 ); + } + + function test_create_post_invalid_author_object_id() { + $data = $this->set_data(array( + 'author' => (object) array( + 'testfield' => 'testvalue', + ), + )); + $response = $this->endpoint->create_post( $data ); + $this->assertErrorResponse( 'json_invalid_author', $response, 400 ); + } + + function test_create_post_invalid_author_without_permission() { + $data = $this->set_data(); + $other_user = $this->factory->user->create( array( 'role' => 'author' ) ); + + wp_set_current_user( $other_user ); + + $response = $this->endpoint->create_post( $data ); + $this->assertErrorResponse( 'json_cannot_edit_others', $response, 401 ); + } + + function test_create_post_without_permission() { + $data = $this->set_data(array()); + $user = wp_get_current_user(); + $user->add_cap( 'edit_posts', false ); + + // Flush capabilities, https://core.trac.wordpress.org/ticket/28374 + $user->get_role_caps(); + $user->update_user_level_from_caps(); + + $response = $this->endpoint->create_post( $data ); + $this->assertErrorResponse( 'json_cannot_create', $response, 403 ); + } + + function test_create_post_draft() { + $data = $this->set_data(array( + 'status' => 'draft', + )); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( $data['status'], $new_post->post_status ); + } + + function test_create_post_private() { + $data = $this->set_data(array( + 'status' => 'private', + )); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( $data['status'], $new_post->post_status ); + } + + function test_create_post_private_without_permission() { + $data = $this->set_data(array( + 'status' => 'private', + )); + $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(); + + $response = $this->endpoint->create_post( $data ); + $this->assertErrorResponse( 'json_cannot_create_private', $response, 403 ); + } + + function test_create_post_publish_without_permission() { + $data = $this->set_data(array( + 'status' => 'publish', + )); + $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(); + + $response = $this->endpoint->create_post( $data ); + $this->assertErrorResponse( 'json_cannot_publish', $response, 403 ); + } + + /** + * Invalid statuses should be defaulted to "draft" status + */ + function test_create_post_invalid_status() { + $data = $this->set_data(array( + 'status' => 'teststatus', + )); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( 'draft', $new_post->post_status ); + } + + function test_create_post_with_password() { + $this->markTestSkipped('https://github.com/WP-API/WP-API/issues/286'); + $data = $this->set_data(array( + 'password' => 'testing', + )); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( $data['password'], $new_post->post_password ); + } + + function test_create_post_with_password_without_permission() { + $data = $this->set_data(array( + 'password' => 'testing', + )); + + $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(); + + $response = $this->endpoint->create_post( $data ); + $this->assertErrorResponse( 'json_cannot_publish', $response, 403 ); + } + + function test_create_page_with_parent() { + $parent = $this->factory->post->create(array( + 'type' => 'page', + )); + $data = $this->set_data(array( + 'type' => 'page', + 'parent' => $parent, + )); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + + $this->assertEquals( $parent, $new_post->post_parent ); + } + + function test_create_page_with_invalid_parent() { + $parent = $this->factory->post->create(array( + 'type' => 'page', + )); + $data = $this->set_data(array( + 'type' => 'page', + 'parent' => -1, + )); + + $response = $this->endpoint->create_post( $data ); + $this->assertErrorResponse( 'json_post_invalid_id', $response, 400 ); + } + + function test_create_post_custom_date() { + $data = $this->set_data(array( + 'date' => '2010-01-01T02:00:00Z', + )); + $time = gmmktime( 2, 0, 0, 1, 1, 2010 ); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( $time, strtotime( $new_post->post_date ) ); + } + + function test_create_post_custom_date_with_timezone() { + $data = $this->set_data(array( + 'date' => '2010-01-01T02:00:00-10:00', + )); + $time = gmmktime( 12, 0, 0, 1, 1, 2010 ); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( $time, strtotime( $new_post->post_date ) ); + } + + function test_create_post_custom_date_with_irregular_offset() { + $data = $this->set_data(array( + 'date' => '2010-01-01T02:00:00-10:14', + )); + $time = gmmktime( 12, 14, 0, 1, 1, 2010 ); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( $time, strtotime( $new_post->post_date ) ); + } + + function test_create_post_custom_date_gmt() { + $data = $this->set_data(array( + 'date_gmt' => '2010-01-01T02:00:00Z', + )); + $time = gmmktime( 2, 0, 0, 1, 1, 2010 ); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( $time, strtotime( $new_post->post_date ) ); + } + + /** + * `date_gmt` fields should always be forced to UTC + */ + function test_create_post_custom_date_gmt_with_timezone() { + $data = $this->set_data(array( + 'date_gmt' => '2010-01-01T02:00:00+10:00', + )); + $time = gmmktime( 2, 0, 0, 1, 1, 2010 ); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( $time, strtotime( $new_post->post_date ) ); + } + + /** + * Custom `modified` field values are ignored + */ + function test_create_post_custom_modified() { + $data = $this->set_data(array( + 'date' => '2010-01-01T02:00:00Z', + 'modified' => '2009-01-01T02:00:00Z', + )); + $time = gmmktime( 2, 0, 0, 1, 1, 2010 ); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( $time, strtotime( $new_post->post_modified ) ); + } + + function test_create_post_menu_order() { + $data = $this->set_data(array( + 'menu_order' => 5, + )); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( $data['menu_order'], $new_post->menu_order ); + } + + function test_create_post_menu_order_negative() { + $data = $this->set_data(array( + 'menu_order' => -5, + )); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( $data['menu_order'], $new_post->menu_order ); + } + + function test_create_post_menu_order_noninteger() { + $data = $this->set_data(array( + 'menu_order' => 'test', + )); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $new_post = get_post( $response_data['ID'] ); + $this->assertEquals( 0, $new_post->menu_order ); + } + + function test_create_post_sticky() { + $data = $this->set_data(array( + 'sticky' => true, + )); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $this->assertTrue( is_sticky( $response_data['ID'] ) ); + } + + function test_create_post_sticky_false() { + $data = $this->set_data(array( + 'sticky' => false, + )); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $this->assertFalse( is_sticky( $response_data['ID'] ) ); + } + + function test_create_post_with_meta() { + $data = $this->set_data( array( + 'post_meta' => array( + array( + 'key' => 'testkey', + 'value' => 'testvalue', + ), + ) ) ); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $this->assertEquals( 1, count( $response_data['post_meta'] ) ); + + $post_meta_value = get_post_meta( $response_data['ID'], 'testkey', true ); + $this->assertEquals( 'testvalue', $post_meta_value ); + } + + function test_create_post_with_multiple_meta() { + $data = $this->set_data( array( + 'post_meta' => array( + array( + 'key' => 'some_meta', + 'value' => 'some_value', + ), + array( + 'key' => 'some_other_meta', + 'value' => 'some_other_value', + ), + ) ) ); + + $response = $this->endpoint->create_post( $data ); + $response = json_ensure_response( $response ); + $this->check_create_response( $response ); + + $response_data = $response->get_data(); + $this->assertEquals( 2, count( $response_data['post_meta'] ) ); + + $post_meta_first_value = get_post_meta( $response_data['ID'], 'some_meta', true ); + $post_meta_second_value = get_post_meta( $response_data['ID'], 'some_other_meta', true ); + $this->assertEquals( 'some_value', $post_meta_first_value ); + $this->assertEquals( 'some_other_value', $post_meta_second_value ); + } + + function test_get_post() { + $response = $this->endpoint->get_post( $this->post_id ); + + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + $headers = $response->get_headers(); + + // Check that we succeeded + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'Last-Modified', $headers ); + + $this->check_get_post_response( $response, $this->post_obj ); + } + + function test_get_post_edit_context() { + add_post_meta( $this->post_id, 'some_meta', 'some_value', true ); + add_post_meta( $this->post_id, 'some_other_meta', 'some_other_value', true ); + + $response = $this->endpoint->get_post( $this->post_id, 'edit' ); + + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + $this->check_get_post_response( $response, $this->post_obj ); + + $headers = $response->get_headers(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'Last-Modified', $headers ); + + $response_data = $response->get_data(); + $this->assertEquals( 2, count( $response_data['post_meta'] ) ); + } + + function test_get_revisions() { + wp_update_post( array( 'post_content' => 'This content is better.', 'ID' => $this->post_id ) ); + wp_update_post( array( 'post_content' => 'This content is marvelous.', 'ID' => $this->post_id ) ); + + $revisions = wp_get_post_revisions( $this->post_id ); + + $struct = array(); + foreach ( $revisions as $revision ) { + $post = get_object_vars( $revision ); + $struct[] = $post; + + $this->assertTrue( user_can( $this->author_id, 'edit_post', $revision->post_parent ) ); + } + + $response = $this->endpoint->get_revisions( $this->post_id ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + $response_data = $response->get_data(); + + // Check that we succeeded + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 2, $response_data ); + } + + function test_edit_post() { + $data = $this->set_data( array( 'ID' => $this->post_id ) ) ; + $response = $this->endpoint->edit_post( $this->post_id, $data ); + + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + $headers = $response->get_headers(); + + // Check that we succeeded + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'Last-Modified', $headers ); + + $edited_post = get_post( $this->post_id ); + + $this->check_get_post_response( $response, $edited_post ); + } + + function test_edit_post_set_empty_title() { + $data = $this->set_data( array( 'ID' => $this->post_id, 'title' => '' ) ) ; + $this->endpoint->edit_post( $this->post_id, $data ); + + // Check that we have an empty title + $this->assertEquals( '', get_the_title( $this->post_id ) ); + } + + function test_edit_post_without_permission() { + $data = $this->set_data( array( 'ID' => $this->post_id ) ) ; + + $user = wp_get_current_user(); + $user->add_cap( 'edit_published_posts', false ); + + // Flush capabilities, https://core.trac.wordpress.org/ticket/28374 + $user->get_role_caps(); + $user->update_user_level_from_caps(); + + $response = $this->endpoint->edit_post( $this->post_id, $data ); + $this->assertErrorResponse( 'json_cannot_edit', $response, 401 ); + } + + function test_edit_post_draft_without_permission() { + // Set post to draft status + wp_update_post(array( + 'ID' => $this->post_id, + 'post_status' => 'draft', + )); + + $data = $this->set_data( array( 'ID' => $this->post_id ) ) ; + + $user = wp_get_current_user(); + $user->add_cap( 'edit_posts', false ); + + // Flush capabilities, https://core.trac.wordpress.org/ticket/28374 + $user->get_role_caps(); + $user->update_user_level_from_caps(); + + $response = $this->endpoint->edit_post( $this->post_id, $data ); + $this->assertErrorResponse( 'json_cannot_edit', $response, 401 ); + } + + function test_edit_post_change_type() { + $data = $this->set_data( array( + 'ID' => $this->post_id, + 'type' => 'page', + ) ) ; + + $response = $this->endpoint->edit_post( $this->post_id, $data ); + $this->assertErrorResponse( 'json_cannot_change_post_type', $response, 400 ); + } + + function test_edit_post_change_type_invalid() { + $data = $this->set_data( array( + 'ID' => $this->post_id, + 'type' => 'testposttype', + ) ) ; + + $response = $this->endpoint->edit_post( $this->post_id, $data ); + $this->assertErrorResponse( 'json_invalid_post_type', $response, 400 ); + } + + function test_edit_post_sticky() { + $this->markTestSkipped('https://github.com/WP-API/WP-API/issues/218'); + $data = $this->set_data(array( + 'sticky' => true, + )); + + $response = $this->endpoint->edit_post( $this->post_id, $data ); + $response = json_ensure_response( $response ); + + $edited_post = get_post( $this->post_id ); + $this->check_get_post_response( $response, $edited_post ); + + $this->assertTrue( is_sticky( $this->post_id ) ); + } + + function test_edit_post_sticky_false() { + $data = $this->set_data(array( + 'sticky' => false, + )); + + $response = $this->endpoint->edit_post( $this->post_id, $data ); + $response = json_ensure_response( $response ); + + $edited_post = get_post( $this->post_id ); + $this->check_get_post_response( $response, $edited_post ); + + $this->assertFalse( is_sticky( $this->post_id ) ); + } + +} diff --git a/tests/test_json_server.php b/tests/test-json-server.php similarity index 77% rename from tests/test_json_server.php rename to tests/test-json-server.php index d67256ceea..653fc7a512 100644 --- a/tests/test_json_server.php +++ b/tests/test-json-server.php @@ -24,34 +24,11 @@ function setUp() { parent::setUp(); - include_once( plugin_dir_path( dirname( __FILE__ ) ) . 'lib/class-wp-json-server.php' ); - // Allow for a plugin to insert a different class to handle requests. $wp_json_server_class = apply_filters('wp_json_server_class', 'WP_JSON_Server'); $wp_json_server = new $wp_json_server_class; } - /** - * The server should be able to authenticate users using basic auth. - */ - function test_valid_basic_auth() { - global $wp_json_server; - - $user_id = $this->factory->user->create( array( - 'user_login' => 'basic_auth', - 'user_pass' => 'basic_auth' - ) ); - - $_SERVER['PHP_AUTH_USER'] = 'basic_auth'; - $_SERVER['PHP_AUTH_PW'] = 'basic_auth'; - - $result = $wp_json_server->check_authentication(); - $this->assertTrue( $result instanceof WP_User ); - - unset( $_SERVER['PHP_AUTH_USER'] ); - unset( $_SERVER['PHP_AUTH_PW'] ); - } - /** * Errors should convert to arrays cleanly. */ @@ -60,7 +37,8 @@ function test_error_to_array() { } /** - * Test the format of errors encoded to json. + * Test the format of errors encoded to json. Include + * a test with periods to be sure it's allowed. */ function test_json_error() { $this->markTestIncomplete('Missing test implementation.'); @@ -128,12 +106,4 @@ function test_json_serializable() { $this->markTestIncomplete('Missing test implementation.'); } - /** - * Test if local RFC3339 dates are converted to MySQL datetimes with the - * appropriate GMT timezone. - */ - function test_get_date_with_gmt() { - $this->markTestIncomplete('Missing test implementation.'); - } - } diff --git a/tests/test-json-taxonomies.php b/tests/test-json-taxonomies.php new file mode 100644 index 0000000000..e288a98f80 --- /dev/null +++ b/tests/test-json-taxonomies.php @@ -0,0 +1,346 @@ +user = $this->factory->user->create(); + wp_set_current_user( $this->user ); + + $this->fake_server = $this->getMock('WP_JSON_Server'); + $this->endpoint = new WP_JSON_Taxonomies( $this->fake_server ); + } + + public function test_register_routes() { + $routes = array(); + $routes = $this->endpoint->register_routes( $routes ); + + $this->assertArrayHasKey( '/taxonomies', $routes ); + $this->assertArrayHasKey( '/taxonomies/(?P[\w-]+)', $routes ); + $this->assertArrayHasKey( '/taxonomies/(?P[\w-]+)/terms', $routes ); + $this->assertArrayHasKey( '/taxonomies/(?P[\w-]+)/terms/(?P[\w-]+)', $routes ); + + $deprecated = array( + '/posts/types/(?P[\w-]+)/taxonomies', + '/posts/types/(?P[\w-]+)/taxonomies/(?P[\w-]+)', + '/posts/types/(?P[\w-]+)/taxonomies/(?P[\w-]+)/terms', + '/posts/types/(?P[\w-]+)/taxonomies/(?P[\w-]+)/terms/(?P[\w-]+)', + ); + + foreach ( $deprecated as $route ) { + $this->assertArrayHasKey( $route, $routes ); + foreach ( $routes[$route] as $parts ) { + $bitmask = $parts[1]; + $this->assertEquals( WP_JSON_Server::HIDDEN_ENDPOINT, $bitmask & WP_JSON_Server::HIDDEN_ENDPOINT, "Deprecated $route should be hidden" ); + } + } + } + + /** + * Utility function for use in get_public_taxonomies + */ + private function is_public( $taxonomy ) { + return $taxonomy->public !== false; + } + + /** + * Utility function to filter down to only public taxonomies + */ + private function get_public_taxonomies( $taxonomies ) { + // Pass through array_values to re-index after filtering + return array_values( array_filter( $taxonomies, array( $this, 'is_public' ) ) ); + } + + protected function check_taxonomies_for_type_response( $type, $response ) { + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $taxonomies = $this->get_public_taxonomies( get_object_taxonomies( $type, 'objects' ) ); + + $this->assertEquals( count( $taxonomies ), count( $data ) ); + } + + /** + * @expectedDeprecated WP_JSON_Taxonomies::get_taxonomies_for_type + */ + public function test_get_taxonomies_for_type() { + $response = $this->endpoint->get_taxonomies_for_type( 'post' ); + $this->check_taxonomies_for_type_response( 'post', $response ); + } + + public function test_get_taxonomies() { + $response = $this->endpoint->get_taxonomies(); + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $taxonomies = $this->get_public_taxonomies( get_taxonomies( '', 'objects' ) ); + $this->assertEquals( count( $taxonomies ), count( $data ) ); + + // Check each key in $data against those in $taxonomies + foreach ( array_keys( $data ) as $key ) { + $this->assertEquals( $taxonomies[$key]->label, $data[$key]['name'] ); + $this->assertEquals( $taxonomies[$key]->name, $data[$key]['slug'] ); + $this->assertEquals( $taxonomies[$key]->hierarchical, $data[$key]['hierarchical'] ); + $this->assertEquals( $taxonomies[$key]->show_tagcloud, $data[$key]['show_cloud'] ); + } + } + + public function test_get_taxonomies_with_types() { + foreach ( get_post_types() as $type ) { + $response = $this->endpoint->get_taxonomies( $type ); + $this->check_taxonomies_for_type_response( $type, $response ); + } + } + + protected function check_taxonomy_object( $tax_obj, $data ) { + $this->assertEquals( $tax_obj->label, $data['name'] ); + $this->assertEquals( $tax_obj->name, $data['slug'] ); + $this->assertEquals( $tax_obj->show_tagcloud, $data['show_cloud'] ); + $this->assertEquals( $tax_obj->hierarchical, $data['hierarchical'] ); + } + + protected function check_taxonomy_object_response( $response ) { + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $category = get_taxonomy( 'category' ); + + $this->check_taxonomy_object( $category, $data ); + } + + /** + * @expectedDeprecated WP_JSON_Taxonomies::get_taxonomy + */ + public function test_get_taxonomy() { + $response = $this->endpoint->get_taxonomy( 'post', 'category' ); + $this->check_taxonomy_object_response( $response ); + } + + public function test_get_taxonomy_object() { + $response = $this->endpoint->get_taxonomy_object( 'category' ); + $this->check_taxonomy_object_response( $response ); + } + + public function test_get_taxonomy_object_empty() { + $response = $this->endpoint->get_taxonomy_object( '' ); + $this->assertErrorResponse( 'json_taxonomy_invalid_id', $response, 404 ); + } + + protected function call_protected( $method, $args ) { + require_once dirname( __FILE__ ) . '/taxonomies_caller.php'; + + $class = new WP_Test_JSON_Taxonomies_Caller(); + return $class->testProtectedCall( $method, $args ); + } + + /** + * @expectedDeprecated WP_JSON_Taxonomies::prepare_taxonomy + */ + public function test_prepare_taxonomy() { + $tax = get_taxonomy( 'category' ); + $data = $this->call_protected( 'prepare_taxonomy', array( $tax ) ); + $this->check_taxonomy_object( $tax, $data ); + } + + public function test_prepare_taxonomy_object() { + $tax = get_taxonomy( 'category' ); + $data = $this->call_protected( 'prepare_taxonomy_object', array( $tax ) ); + $this->check_taxonomy_object( $tax, $data ); + } + + /** + * Check old _in_collection parameter + * @expectedDeprecated WP_JSON_Taxonomies::prepare_taxonomy_object + */ + public function test_prepare_taxonomy_object_in_collection() { + $tax = get_taxonomy( 'category' ); + $data = $this->call_protected( 'prepare_taxonomy_object', array( $tax, true ) ); + $this->check_taxonomy_object( $tax, $data ); + } + + /** + * Check old _in_collection parameter + * @expectedDeprecated WP_JSON_Taxonomies::prepare_taxonomy_object + */ + public function test_prepare_taxonomy_object_not_in_collection() { + $tax = get_taxonomy( 'category' ); + $data = $this->call_protected( 'prepare_taxonomy_object', array( $tax, false ) ); + $this->check_taxonomy_object( $tax, $data ); + } + + protected function check_get_taxonomy_terms_response( $response ) { + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $args = array( + 'hide_empty' => false, + ); + $categories = get_terms( 'category', $args ); + + $this->assertEquals( count( $categories ), count( $data ) ); + + $this->assertEquals( $categories[0]->term_id, $data[0]['ID'] ); + $this->assertEquals( $categories[0]->name, $data[0]['name'] ); + $this->assertEquals( $categories[0]->slug, $data[0]['slug']); + $this->assertEquals( $categories[0]->description, $data[0]['description']); + $this->assertEquals( $categories[0]->count, $data[0]['count']); + } + + /** + * @expectedDeprecated WP_JSON_Taxonomies::get_terms + */ + public function test_get_terms() { + $response = $this->endpoint->get_terms( 'post', 'category' ); + $this->check_get_taxonomy_terms_response( $response ); + } + + public function test_get_taxonomy_terms() { + $response = $this->endpoint->get_taxonomy_terms( 'category' ); + $this->check_get_taxonomy_terms_response( $response ); + } + + public function test_get_taxonomy_terms_empty() { + $response = $this->endpoint->get_taxonomy_terms( '' ); + $this->assertErrorResponse( 'json_taxonomy_invalid_id', $response, 404 ); + } + + public function test_get_taxonomy_terms_invalid() { + $response = $this->endpoint->get_taxonomy_terms( 'testtaxonomy' ); + $this->assertErrorResponse( 'json_taxonomy_invalid_id', $response, 404 ); + } + + public function get_terms_return_error() { + return new WP_Error( 'test_internal_error' ); + } + + public function test_get_taxonomy_terms_internal_error() { + add_filter( 'get_terms', array( $this, 'get_terms_return_error' ) ); + + $response = $this->endpoint->get_taxonomy_terms( 'category' ); + remove_filter( 'get_terms', array( $this, 'get_terms_return_error' ) ); + + $this->assertErrorResponse( 'test_internal_error', $response ); + } + + protected function check_taxonomy_term( $term, $data ) { + $this->assertEquals( $term->term_id, $data['ID'] ); + $this->assertEquals( $term->name, $data['name'] ); + $this->assertEquals( $term->slug, $data['slug'] ); + $this->assertEquals( $term->description, $data['description'] ); + $this->assertEquals( $term->count, $data['count'] ); + } + + public function check_get_taxonomy_term_response( $response ) { + $this->assertNotInstanceOf( 'WP_Error', $response ); + $response = json_ensure_response( $response ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $category = get_term( 1, 'category' ); + $this->check_taxonomy_term( $category, $data ); + } + + /** + * @expectedDeprecated WP_JSON_Taxonomies::get_term + */ + public function test_get_term() { + $response = $this->endpoint->get_term( 'post', 'category', 1 ); + $this->check_get_taxonomy_term_response( $response ); + } + + public function test_get_taxonomy_term() { + $response = $this->endpoint->get_taxonomy_term( 'category', 1 ); + $this->check_get_taxonomy_term_response( $response ); + } + + public function test_get_taxonomy_term_invalid_taxonomy() { + $response = $this->endpoint->get_taxonomy_term( 'testtaxonomy', 1 ); + $this->assertErrorResponse( 'json_taxonomy_invalid_id', $response, 404 ); + } + + public function test_get_taxonomy_term_invalid_term() { + $response = $this->endpoint->get_taxonomy_term( 'category', 'testmissingcat' ); + $this->assertErrorResponse( 'json_taxonomy_invalid_term', $response, 404 ); + } + + public function test_add_taxonomy_data() { + // Mock type + $type = new stdClass; + $type->name = 'post'; + + // This record is not a taxonomy record: taxonomies should be embedded + $data = $this->endpoint->add_taxonomy_data( array(), $type, 'view' ); + $this->assertArrayHasKey( 'taxonomies', $data ); + + // This record is a taxonomy record: taxonomies should NOT be embedded + $data_within_taxonomy = $this->endpoint->add_taxonomy_data( array(), $type, 'embed' ); + $this->assertArrayNotHasKey( 'taxonomies', $data_within_taxonomy ); + } + + public function test_add_term_data() { + $post = $this->factory->post->create(); + $post_obj = get_post( $post, ARRAY_A ); + $category = $this->factory->category->create(); + wp_set_post_categories( $post, $category ); + + // This record is not a taxonomy record: taxonomies should be embedded + $data = $this->endpoint->add_term_data( array(), $post_obj, false ); + + $this->assertCount( 1, $data['terms']['category'] ); + $this->assertEquals( $category, $data['terms']['category'][0]['ID'] ); + } + + public function test_prepare_taxonomy_term() { + $term = get_term( 1, 'category' ); + $data = $this->call_protected( 'prepare_taxonomy_term', array( $term ) ); + $this->check_taxonomy_term( $term, $data ); + } + + public function test_prepare_taxonomy_term_child() { + $child = $this->factory->category->create( array( + 'parent' => 1, + ) ); + + $term = get_term( $child, 'category' ); + $data = $this->call_protected( 'prepare_taxonomy_term', array( $term ) ); + $this->check_taxonomy_term( $term, $data ); + + $this->assertNotEmpty( $data['parent'] ); + + $parent = get_term( 1, 'category' ); + $this->check_taxonomy_term( $parent, $data['parent'] ); + } + + /** + * @expectedDeprecated WP_JSON_Taxonomies::prepare_term + */ + public function test_prepare_term() { + $term = get_term( 1, 'category' ); + $data = $this->call_protected( 'prepare_term', array( $term, 'post' ) ); + $this->check_taxonomy_term( $term, $data ); + } +} diff --git a/tests/test-json-testcase.php b/tests/test-json-testcase.php new file mode 100644 index 0000000000..3cf235158c --- /dev/null +++ b/tests/test-json-testcase.php @@ -0,0 +1,14 @@ +assertInstanceOf( 'WP_Error', $response ); + $this->assertEquals( $code, $response->get_error_code() ); + + if ( $status !== null ) { + $data = $response->get_error_data(); + $this->assertArrayHasKey( 'status', $data ); + $this->assertEquals( $status, $data['status'] ); + } + } +} \ No newline at end of file diff --git a/tests/test-json-users.php b/tests/test-json-users.php new file mode 100644 index 0000000000..5a868dc9da --- /dev/null +++ b/tests/test-json-users.php @@ -0,0 +1,249 @@ +user = $this->factory->user->create(); + wp_set_current_user( $this->user ); + $this->user_obj = wp_get_current_user(); + + $this->fake_server = $this->getMock('WP_JSON_Server'); + $this->endpoint = new WP_JSON_Users( $this->fake_server ); + } + + protected function allow_user_to_create_users( $user ) { + if ( is_multisite() ) { + update_site_option( 'site_admins', array( $user->user_login ) ); + } else { + $user->set_role( 'administrator' ); + } + } + + public function test_get_current_user() { + $response = $this->endpoint->get_current_user(); + $this->assertNotInstanceOf( 'WP_Error', $response ); + + if ( ! $response instanceof WP_JSON_ResponseInterface ) { + $response = new WP_JSON_Response( $response ); + } + + // Check that we succeeded + $this->assertEquals( 302, $response->get_status() ); + + $headers = $response->get_headers(); + $response_data = $response->get_data(); + $this->assertArrayHasKey( 'Location', $headers ); + $this->assertEquals( $response_data['meta']['links']['self'], $headers['Location'] ); + + $this->check_get_user_response( $response, $this->user_obj ); + + } + + public function test_get_user() { + $response = $this->endpoint->get_user( $this->user ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + + if ( ! $response instanceof WP_JSON_ResponseInterface ) { + $response = new WP_JSON_Response( $response ); + } + + // Check that we succeeded + $this->assertEquals( 200, $response->get_status() ); + + $this->check_get_user_response( $response, $this->user_obj ); + } + + public function test_get_user_with_edit_context() { + $response = $this->endpoint->get_user( $this->user, 'edit' ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + + if ( ! $response instanceof WP_JSON_ResponseInterface ) { + $response = new WP_JSON_Response( $response ); + } + + // Check that we succeeded + $this->assertEquals( 200, $response->get_status() ); + + $this->check_get_user_response( $response, $this->user_obj, 'edit' ); + } + + protected function check_get_user_response( $response, $user_obj, $context = 'view' ) { + $response_data = $response->get_data(); + + // Check basic data + $this->assertEquals( $user_obj->ID, $response_data['ID'] ); + $this->assertEquals( $user_obj->user_login, $response_data['username'] ); + if ( $context === 'view' ) { + $this->assertEquals( false, $response_data['email'] ); + + // Check that we didn't get extra data + $this->assertArrayNotHasKey( 'extra_capabilities', $response_data ); + } + else { + $this->assertEquals( $user_obj->user_email, $response_data['email'] ); + $this->assertEquals( $user_obj->caps, $response_data['extra_capabilities'] ); + } + } + + public function test_create_user() { + $this->allow_user_to_create_users( $this->user_obj ); + $data = array( + 'username' => 'test_user', + 'password' => 'test_password', + 'email' => 'test@example.com', + ); + $response = $this->endpoint->create_user( $data ); + + // Check that we succeeded + $this->assertNotInstanceOf( 'WP_Error', $response ); + $this->assertEquals( 201, $response->get_status() ); + + $response_data = $response->get_data(); + + // Check that the data is intact + $new_user = get_userdata( $response_data['ID'] ); + + $this->assertEquals( $data['username'], $response_data['username'] ); + $this->assertEquals( $data['username'], $new_user->user_login ); + + $this->assertEquals( false, $response_data['email'] ); + $this->assertEquals( $data['email'], $new_user->user_email ); + + $this->assertTrue( wp_check_password( $data['password'], $new_user->user_pass ), 'Password check failed' ); + } + + public function test_create_user_missing_params() { + $this->allow_user_to_create_users( $this->user_obj ); + $data = array( + 'username' => 'test_user', + ); + $response = $this->endpoint->create_user( $data ); + $this->assertInstanceOf( 'WP_Error', $response ); + } + + public function test_delete_user() { + $this->allow_user_to_create_users( $this->user_obj ); + + // Test with a new user, rather than ourselves, to avoid any + // complications with doing so. We should check this separately though. + $test_user = $this->factory->user->create(); + $response = $this->endpoint->delete_user( $test_user ); + + $this->assertNotInstanceOf( 'WP_Error', $response ); + + if ( ! $response instanceof WP_JSON_ResponseInterface ) { + $response = new WP_JSON_Response( $response ); + } + + // Check that we succeeded + $this->assertEquals( 200, $response->get_status() ); + } + + public function test_delete_user_reassign() { + $this->allow_user_to_create_users( $this->user_obj ); + + // Test with a new user, rather than ourselves, to avoid any + // complications with doing so. We should check this separately though. + $test_user = $this->factory->user->create(); + $test_new_author = $this->factory->user->create(); + $test_post = $this->factory->post->create(array( + 'post_author' => $test_user, + )); + + // Sanity check to ensure the factory created the post correctly + $post = get_post( $test_post ); + $this->assertEquals( $test_user, $post->post_author ); + + // Delete our test user, and reassign to the new author + $response = $this->endpoint->delete_user( $test_user, false, $test_new_author ); + + $this->assertNotInstanceOf( 'WP_Error', $response ); + + if ( ! $response instanceof WP_JSON_ResponseInterface ) { + $response = new WP_JSON_Response( $response ); + } + + // Check that we succeeded + $this->assertEquals( 200, $response->get_status() ); + + // Check that the post has been updated correctly + $post = get_post( $test_post ); + $this->assertEquals( $test_new_author, $post->post_author ); + } + + public function test_update_user() { + $pw_before = $this->user_obj->user_pass; + + $data = array( + 'first_name' => 'New Name', + ); + $response = $this->endpoint->edit_user( $this->user, $data ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + + if ( ! $response instanceof WP_JSON_ResponseInterface ) { + $response = new WP_JSON_Response( $response ); + } + + // Check that we succeeded + $this->assertEquals( 200, $response->get_status() ); + + // Check that the name has been updated correctly + $new_data = $response->get_data(); + $this->assertEquals( $data['first_name'], $new_data['first_name'] ); + + $user = get_userdata( $this->user ); + $this->assertEquals( $user->first_name, $data['first_name'] ); + + // Check that we haven't inadvertently changed the user's password, + // as per https://core.trac.wordpress.org/ticket/21429 + $this->assertEquals( $pw_before, $user->user_pass ); + } + + + public function test_update_user_role() { + $admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + $user_id = $this->factory->user->create( array( 'role' => 'author' ) ); + + wp_set_current_user( $admin_id ); + $admin = wp_get_current_user( $admin_id ); + $this->allow_user_to_create_users( $admin ); + + $response = $this->endpoint->edit_user( $user_id, array( + 'role' => 'editor', + ) ); + $this->assertNotInstanceOf( 'WP_Error', $response ); + + if ( ! $response instanceof WP_JSON_ResponseInterface ) { + $response = new WP_JSON_Response( $response ); + } + + // Check that we succeeded + $this->assertEquals( 200, $response->get_status() ); + + $user = get_userdata( $user_id ); + + $this->assertArrayHasKey( 'editor', $user->caps ); + } + + public function test_update_user_role_privilage_escalation() { + + $response = $this->endpoint->edit_user( $this->user, array( + 'role' => 'administrator' + ) ); + + $response = json_ensure_response( $response ); + + $this->assertErrorResponse( 'json_cannot_edit_roles', $response, 403 ); + + $user = get_userdata( $this->user ); + + $this->assertArrayHasKey( 'subscriber', $user->caps ); + } +} diff --git a/wp-api.js b/wp-api.js index e5b98ba8e9..56e70c2bf2 100644 --- a/wp-api.js +++ b/wp-api.js @@ -1,50 +1,27 @@ -(function ($) { - window.wp = window.wp || {}; - var api = wp.api = wp.api || {}; +(function( window, undefined ) { - // Date parsing code - // From: https://github.com/csnover/js-iso8601 - // Copyright 2011 Colin Snover, MIT Licensed - var origParse = Date.parse, numericKeys = [ 1, 4, 5, 6, 7, 10, 11 ]; - api.parseISO8601 = function (date) { - var timestamp, struct, minutesOffset = 0; + 'use strict'; - // ES5 §15.9.4.2 states that the string should attempt to be parsed as a Date Time String Format string - // before falling back to any implementation-specific date parsing, so that’s what we do, even if native - // implementations could be faster - // 1 YYYY 2 MM 3 DD 4 HH 5 mm 6 ss 7 msec 8 Z 9 ± 10 tzHH 11 tzmm - if ((struct = /^(\d{4}|[+\-]\d{6})(?:-(\d{2})(?:-(\d{2}))?)?(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{3}))?)?(?:(Z)|([+\-])(\d{2})(?::(\d{2}))?)?)?$/.exec(date))) { - // avoid NaN timestamps caused by “undefined” values being passed to Date.UTC - for (var i = 0, k; (k = numericKeys[i]); ++i) { - struct[k] = +struct[k] || 0; - } + function WP_API() { + this.models = {}; + this.collections = {}; + this.views = {}; + } - // allow undefined days and months - struct[2] = (+struct[2] || 1) - 1; - struct[3] = +struct[3] || 1; + window.wp = window.wp || {}; + wp.api = wp.api || new WP_API(); - if (struct[8] !== 'Z' && struct[9] !== undefined) { - minutesOffset = struct[10] * 60 + struct[11]; +})( window ); - if (struct[9] === '+') { - minutesOffset = 0 - minutesOffset; - } - } +(function( Backbone, _, window, undefined ) { - timestamp = Date.UTC(struct[1], struct[2], struct[3], struct[4], struct[5] + minutesOffset, struct[6], struct[7]); - } - else { - timestamp = origParse ? origParse(date) : NaN; - } - - return timestamp; - }; + //'use strict'; // ECMAScript 5 shim, from MDN // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString - if ( !Date.prototype.toISOString ) { - var pad = function (number) { - var r = String(number); + if ( ! Date.prototype.toISOString ) { + var pad = function( number ) { + var r = String( number ); if ( r.length === 1 ) { r = '0' + r; } @@ -52,191 +29,959 @@ }; Date.prototype.toISOString = function() { - return this.getUTCFullYear() - + '-' + pad( this.getUTCMonth() + 1 ) - + '-' + pad( this.getUTCDate() ) - + 'T' + pad( this.getUTCHours() ) - + ':' + pad( this.getUTCMinutes() ) - + ':' + pad( this.getUTCSeconds() ) - + '.' + String( (this.getUTCMilliseconds()/1000).toFixed(3) ).slice( 2, 5 ) - + 'Z'; + return this.getUTCFullYear() + + '-' + pad( this.getUTCMonth() + 1 ) + + '-' + pad( this.getUTCDate() ) + + 'T' + pad( this.getUTCHours() ) + + ':' + pad( this.getUTCMinutes() ) + + ':' + pad( this.getUTCSeconds() ) + + '.' + String( ( this.getUTCMilliseconds()/1000 ).toFixed( 3 ) ).slice( 2, 5 ) + + 'Z'; }; } - // Set the root URL from the global variable, if available - api.root = ""; - if (wpApiOptions) { - api.root = wpApiOptions.base; - } + function WP_API_Utils() { + var origParse = Date.parse, + numericKeys = [ 1, 4, 5, 6, 7, 10, 11 ]; - /** - * wp.api.models - * - * Model container - */ - api.models = {}; - /** - * wp.api.models.User - * - * User Entity model - */ - api.models.User = Backbone.Model.extend({ - urlRoot: api.root + "/users", + this.parseISO8601 = function( date ) { + var timestamp, struct, i, k, + minutesOffset = 0; - defaults: { - ID: 0, - name: "", - slug: "", - URL: "", - avatar: "", - meta: { - links: {} - } - }, + // ES5 §15.9.4.2 states that the string should attempt to be parsed as a Date Time String Format string + // before falling back to any implementation-specific date parsing, so that’s what we do, even if native + // implementations could be faster + // 1 YYYY 2 MM 3 DD 4 HH 5 mm 6 ss 7 msec 8 Z 9 ± 10 tzHH 11 tzmm + if ((struct = /^(\d{4}|[+\-]\d{6})(?:-(\d{2})(?:-(\d{2}))?)?(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{3}))?)?(?:(Z)|([+\-])(\d{2})(?::(\d{2}))?)?)?$/.exec(date))) { + // avoid NaN timestamps caused by “undefined” values being passed to Date.UTC + for ( i = 0; ( k = numericKeys[i] ); ++i) { + struct[k] = +struct[k] || 0; + } - avatar: function (size) { - return this.get('avatar') + '&s=' + size; - } - }); + // allow undefined days and months + struct[2] = ( +struct[2] || 1 ) - 1; + struct[3] = +struct[3] || 1; - var parseable_dates = [ 'date', 'modified' ]; + if ( struct[8] !== 'Z' && struct[9] !== undefined ) { + minutesOffset = struct[10] * 60 + struct[11]; - /** - * wp.api.models.Post - * - * Post Entity model - */ - api.models.Post = Backbone.Model.extend({ - // Technically, these are probably not needed - defaults: function () { - return { - ID: 0, - title: "", - status: "draft", - type: "post", - author: new api.models.User(), - content: "", - parent: 0, - link: "", - date: new Date(), - // date_gmt: new Date(), - modified: new Date(), - // modified_gmt: new Date(), - format: "standard", - slug: "", - guid: "", - excerpt: "", - menu_order: 0, - comment_status: "open", - ping_status: "open", - sticky: false, - date_tz: "Etc/UTC", - modified_tz: "Etc/UTC", - terms: {}, - post_meta: {}, - meta: { - links: {} + if ( struct[9] === '+' ) { + minutesOffset = 0 - minutesOffset; + } } - }; - }, - idAttribute: "ID", + timestamp = Date.UTC( struct[1], struct[2], struct[3], struct[4], struct[5] + minutesOffset, struct[6], struct[7] ); + } + else { + timestamp = origParse ? origParse( date ) : NaN; + } - urlRoot: api.root + "/posts", + return timestamp; + }; + } + window.wp = window.wp || {}; + wp.api = wp.api || {}; + wp.api.utils = wp.api.utils || new WP_API_Utils(); + +})( Backbone, _, window ); + +/* global WP_API_Settings:false */ +// Suppress warning about parse function's unused "options" argument: +/* jshint unused:false */ +(function( wp, WP_API_Settings, Backbone, _, window, undefined ) { + + 'use strict'; + + /** + * Array of parseable dates + * + * @type {string[]} + */ + var parseable_dates = [ 'date', 'modified', 'date_gmt', 'modified_gmt' ]; + + /** + * Mixin for all content that is time stamped + * + * @type {{toJSON: toJSON, parse: parse}} + */ + var TimeStampedMixin = { /** - * Serialize the entity + * Serialize the entity pre-sync * - * Overriden for correct date handling - * @return {!Object} Serializable attributes + * @returns {*} */ - toJSON: function () { + toJSON: function() { var attributes = _.clone( this.attributes ); - // Remove GMT dates in favour of our native Date objects - // The API only requires one of `date` and `date_gmt`, so this is - // safe for use. - delete attributes.date_gmt; - delete attributes.modified_gmt; - // Serialize Date objects back into 8601 strings _.each( parseable_dates, function ( key ) { - attributes[ key ] = attributes[ key ].toISOString(); + if ( key in attributes ) { + attributes[key] = attributes[key].toISOString(); + } }); return attributes; }, /** - * Unserialize the entity + * Unserialize the fetched response * - * Overriden for correct date handling - * @param {!Object} response Attributes parsed from JSON - * @param {!Object} options Request options - * @return {!Object} Fully parsed attributes + * @param {*} response + * @returns {*} */ - parse: function ( response, options ) { + parse: function( response ) { // Parse dates into native Date objects _.each( parseable_dates, function ( key ) { - if ( ! ( key in response ) ) + if ( ! ( key in response ) ) { return; + } - var timestamp = api.parseISO8601( response[ key ] ); - response[ key ] = new Date( timestamp ); + var timestamp = wp.api.utils.parseISO8601( response[key] ); + response[key] = new Date( timestamp ); }); - // Remove GMT dates in favour of our native Date objects - delete response.date_gmt; - delete response.modified_gmt; - // Parse the author into a User object - response.author = new api.models.User(response.author); + if ( response.author !== 'undefined' ) { + response.author = new wp.api.models.User( response.author ); + } return response; - }, + } + }; + /** + * Mixin for all hierarchical content types such as posts + * + * @type {{parent: parent}} + */ + var HierarchicalMixin = { /** - * Get parent post + * Get parent object * - * @return {wp.api.models.Post} Parent post, null if not found + * @returns {Backbone.Model} */ - parent: function () { - var parent = this.get('parent'); + parent: function() { + + var object, parent = this.get( 'parent' ); // Return null if we don't have a parent - if (parent === 0) { + if ( parent === 0 ) { return null; } - // Can we get this from its collection? - if (this.collection) { - return this.collection.get(parent); + var parentModel = this; + + if ( typeof this.parentModel !== 'undefined' ) { + /** + * Probably a better way to do this. Perhaps grab a cached version of the + * instantiated model? + */ + parentModel = new this.parentModel(); } - else { - // Otherwise, get the post directly - var post = new api.models.Post({ - id: parent + + // Can we get this from its collection? + if ( parentModel.collection ) { + return parentModel.collection.get( parent ); + } else { + // Otherwise, get the object directly + object = new parentModel.constructor( { + ID: parent }); // Note that this acts asynchronously - api.models.post.fetch(); - return post; + object.fetch(); + return object; + } + } + }; + + /** + * Private Backbone base model for all models + */ + var BaseModel = Backbone.Model.extend( + /** @lends BaseModel.prototype */ + { + /** + * Set nonce header before every Backbone sync + * + * @param {string} method + * @param {Backbone.Model} model + * @param {{beforeSend}, *} options + * @returns {*} + */ + sync: function( method, model, options ) { + options = options || {}; + + if ( typeof WP_API_Settings.nonce !== 'undefined' ) { + var beforeSend = options.beforeSend; + + options.beforeSend = function( xhr ) { + xhr.setRequestHeader( 'X-WP-Nonce', WP_API_Settings.nonce ); + + if ( beforeSend ) { + return beforeSend.apply( this, arguments ); + } + }; + } + + return Backbone.sync( method, model, options ); + } + } + ); + + /** + * Backbone model for single users + */ + wp.api.models.User = BaseModel.extend( + /** @lends User.prototype */ + { + idAttribute: 'ID', + + urlRoot: WP_API_Settings.root + '/users', + + defaults: { + ID: null, + username: '', + email: '', + password: '', + name: '', + first_name: '', + last_name: '', + nickname: '', + slug: '', + URL: '', + avatar: '', + meta: { + links: {} + } + }, + + /** + * Return avatar URL + * + * @param {number} size + * @returns {string} + */ + avatar: function( size ) { + return this.get( 'avatar' ) + '&s=' + size; + } + } + ); + + /** + * Model for Taxonomy + */ + wp.api.models.Taxonomy = BaseModel.extend( + /** @lends Taxonomy.prototype */ + { + idAttribute: 'slug', + + urlRoot: WP_API_Settings.root + '/taxonomies', + + defaults: { + name: '', + slug: null, + labels: {}, + types: {}, + show_cloud: false, + hierarchical: false, + meta: { + links: {} + } + } + } + ); + + /** + * Backbone model for term + */ + wp.api.models.Term = BaseModel.extend( _.extend( + /** @lends Term.prototype */ + { + idAttribute: 'ID', + + taxonomy: 'category', + + /** + * @class Represent a term + * @augments Backbone.Model + * @constructs + */ + initialize: function( attributes, options ) { + if ( typeof options !== 'undefined' ) { + if ( options.taxonomy ) { + this.taxonomy = options.taxonomy; + } + } + }, + + /** + * Return URL for the model + * + * @returns {string} + */ + url: function() { + var id = this.get( 'ID' ); + id = id || ''; + + return WP_API_Settings.root + '/taxonomies/' + this.taxonomy + '/terms/' + id; + }, + + defaults: { + ID: null, + name: '', + slug: '', + description: '', + parent: null, + count: 0, + link: '', + meta: { + links: {} + } + } + + }, TimeStampedMixin, HierarchicalMixin ) + ); + + /** + * Backbone model for single posts + */ + wp.api.models.Post = BaseModel.extend( _.extend( + /** @lends Post.prototype */ + { + idAttribute: 'ID', + + urlRoot: WP_API_Settings.root + '/posts', + + defaults: { + ID: null, + title: '', + status: 'draft', + type: 'post', + author: new wp.api.models.User(), + content: '', + link: '', + 'parent': 0, + date: new Date(), + date_gmt: new Date(), + modified: new Date(), + modified_gmt: new Date(), + format: 'standard', + slug: '', + guid: '', + excerpt: '', + menu_order: 0, + comment_status: 'open', + ping_status: 'open', + sticky: false, + date_tz: 'Etc/UTC', + modified_tz: 'Etc/UTC', + featured_image: null, + terms: {}, + post_meta: {}, + meta: { + links: {} + } + } + }, TimeStampedMixin, HierarchicalMixin ) + ); + + /** + * Backbone model for pages + */ + wp.api.models.Page = BaseModel.extend( _.extend( + /** @lends Page.prototype */ + { + idAttribute: 'ID', + + urlRoot: WP_API_Settings.root + '/pages', + + defaults: { + ID: null, + title: '', + status: 'draft', + type: 'page', + author: new wp.api.models.User(), + content: '', + parent: 0, + link: '', + date: new Date(), + modified: new Date(), + date_gmt: new Date(), + modified_gmt: new Date(), + date_tz: 'Etc/UTC', + modified_tz: 'Etc/UTC', + format: 'standard', + slug: '', + guid: '', + excerpt: '', + menu_order: 0, + comment_status: 'closed', + ping_status: 'open', + sticky: false, + password: '', + meta: { + links: {} + }, + featured_image: null, + terms: [] + } + }, TimeStampedMixin, HierarchicalMixin ) + ); + + /** + * Backbone model for revisions + */ + wp.api.models.Revision = wp.api.models.Post.extend( + /** @lends Revision.prototype */ + { + /** + * Return URL for model + * + * @returns {string} + */ + url: function() { + var parent_id = this.get( 'parent' ); + parent_id = parent_id || ''; + + var id = this.get( 'ID' ); + id = id || ''; + + return WP_API_Settings.root + '/posts/' + parent_id + '/revisions/' + id; + }, + + /** + * @class Represent a revision + * @augments Backbone.Model + * @constructs + */ + initialize: function() { + // Todo: what of the parent model is a page? + this.parentModel = wp.api.models.Post; + } + } + ); + + /** + * Backbone model for media items + */ + wp.api.models.Media = BaseModel.extend( _.extend( + /** @lends Media.prototype */ + { + idAttribute: 'ID', + + urlRoot: WP_API_Settings.root + '/media', + + defaults: { + ID: null, + title: '', + status: 'inherit', + type: 'attachment', + author: new wp.api.models.User(), + content: '', + parent: 0, + link: '', + date: new Date(), + modified: new Date(), + format: 'standard', + slug: '', + guid: '', + excerpt: '', + menu_order: 0, + comment_status: 'open', + ping_status: 'open', + sticky: false, + date_tz: 'Etc/UTC', + modified_tz: 'Etc/UTC', + date_gmt: new Date(), + modified_gmt: new Date(), + meta: { + links: {} + }, + terms: [], + source: '', + is_image: true, + attachment_meta: {}, + image_meta: {} + }, + + /** + * @class Represent a media item + * @augments Backbone.Model + * @constructs + */ + initialize: function() { + // Todo: what of the parent model is a page? + this.parentModel = wp.api.models.Post; + } + }, TimeStampedMixin, HierarchicalMixin ) + ); + + /** + * Backbone model for comments + */ + wp.api.models.Comment = BaseModel.extend( _.extend( + /** @lends Comment.prototype */ + { + idAttribute: 'ID', + + defaults: { + ID: null, + post: null, + content: '', + status: 'hold', + type: '', + parent: 0, + author: new wp.api.models.User(), + date: new Date(), + date_gmt: new Date(), + date_tz: 'Etc/UTC', + meta: { + links: {} + } + }, + + /** + * Return URL for model + * + * @returns {string} + */ + url: function() { + var post_id = this.get( 'post' ); + post_id = post_id || ''; + + var id = this.get( 'ID' ); + id = id || ''; + + return WP_API_Settings.root + '/posts/' + post_id + '/comments/' + id; + } + }, TimeStampedMixin, HierarchicalMixin ) + ); + + /** + * Backbone model for single post types + */ + wp.api.models.PostType = BaseModel.extend( + /** @lends PostType.prototype */ + { + idAttribute: 'slug', + + urlRoot: WP_API_Settings.root + '/posts/types', + + defaults: { + slug: null, + name: '', + description: '', + labels: {}, + queryable: false, + searchable: false, + hierarchical: false, + meta: { + links: {} + }, + taxonomies: [] + }, + + /** + * Prevent model from being saved + * + * @returns {boolean} + */ + save: function () { + return false; + }, + + /** + * Prevent model from being deleted + * + * @returns {boolean} + */ + 'delete': function () { + return false; + } + } + ); + + /** + * Backbone model for a post status + */ + wp.api.models.PostStatus = BaseModel.extend( + /** @lends PostStatus.prototype */ + { + idAttribute: 'slug', + + urlRoot: WP_API_Settings.root + '/posts/statuses', + + defaults: { + slug: null, + name: '', + 'public': true, + 'protected': false, + 'private': false, + queryable: true, + show_in_list: true, + meta: { + links: {} + } + }, + + /** + * Prevent model from being saved + * + * @returns {boolean} + */ + save: function() { + return false; + }, + + /** + * Prevent model from being deleted + * + * @returns {boolean} + */ + 'delete': function() { + return false; + } + } + ); + +})( wp, WP_API_Settings, Backbone, _, window ); + +/* global WP_API_Settings:false */ +(function( wp, WP_API_Settings, Backbone, _, window, undefined ) { + + 'use strict'; + + var BaseCollection = Backbone.Collection.extend( + /** @lends BaseCollection.prototype */ + { + + /** + * Setup default state + */ + initialize: function() { + this.state = { + data: {}, + currentPage: null, + totalPages: null, + totalObjects: null + }; + }, + + /** + * Overwrite Backbone.Collection.sync to pagination state based on response headers. + * + * Set nonce header before every Backbone sync. + * + * @param {string} method + * @param {Backbone.Model} model + * @param {{success}, *} options + * @returns {*} + */ + sync: function( method, model, options ) { + options = options || {}; + var beforeSend = options.beforeSend; + + if ( typeof WP_API_Settings.nonce !== 'undefined' ) { + options.beforeSend = function( xhr ) { + xhr.setRequestHeader( 'X-WP-Nonce', WP_API_Settings.nonce ); + + if ( beforeSend ) { + return beforeSend.apply( this, arguments ); + } + }; + } + + if ( 'read' === method ) { + var SELF = this; + + if ( options.data ) { + SELF.state.data = _.clone( options.data ); + + delete SELF.state.data.page; + } else { + SELF.state.data = options.data = {}; + } + + if ( typeof options.data.page === 'undefined' ) { + SELF.state.currentPage = null; + SELF.state.totalPages = null; + SELF.state.totalObjects = null; + } else { + SELF.state.currentPage = options.data.page - 1; + } + + var 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 ( SELF.state.currentPage === null ) { + SELF.state.currentPage = 1; + } else { + SELF.state.currentPage++; + } + + if ( success ) { + return success.apply( this, arguments ); + } + }; + } + + return Backbone.sync( method, model, options ); + }, + + /** + * Fetches the next page of objects if a new page exists + * + * @param {data: {page}} options + * @returns {*} + */ + more: function( options ) { + options = options || {}; + options.data = options.data || {}; + + _.extend( options.data, this.state.data ); + + if ( typeof options.data.page === 'undefined' ) { + if ( ! this.hasMore() ) { + return false; + } + + if ( this.state.currentPage === null || this.state.currentPage <= 1 ) { + options.data.page = 2; + } else { + options.data.page = this.state.currentPage + 1; + } + } + + return this.fetch( options ); + }, + + /** + * Returns true if there are more pages of objects available + * + * @returns null|boolean + */ + hasMore: function() { + if ( this.state.totalPages === null || + this.state.totalObjects === null || + this.state.currentPage === null ) { + return null; + } else { + return ( this.state.currentPage < this.state.totalPages ); + } + } + } + ); + + /** + * Backbone collection for posts + */ + wp.api.collections.Posts = BaseCollection.extend( + /** @lends Posts.prototype */ + { + url: WP_API_Settings.root + '/posts', + + model: wp.api.models.Post + } + ); + + /** + * Backbone collection for pages + */ + wp.api.collections.Pages = BaseCollection.extend( + /** @lends Pages.prototype */ + { + url: WP_API_Settings.root + '/pages', + + model: wp.api.models.Page + } + ); + + /** + * Backbone users collection + */ + wp.api.collections.Users = BaseCollection.extend( + /** @lends Users.prototype */ + { + url: WP_API_Settings.root + '/users', + + model: wp.api.models.User + } + ); + + /** + * Backbone post statuses collection + */ + wp.api.collections.PostStatuses = BaseCollection.extend( + /** @lends PostStatuses.prototype */ + { + url: WP_API_Settings.root + '/posts/statuses', + + model: wp.api.models.PostStatus + + } + ); + + /** + * Backbone media library collection + */ + wp.api.collections.MediaLibrary = BaseCollection.extend( + /** @lends MediaLibrary.prototype */ + { + url: WP_API_Settings.root + '/media', + + model: wp.api.models.Media + } + ); + + /** + * Backbone taxonomy collection + */ + wp.api.collections.Taxonomies = BaseCollection.extend( + /** @lends Taxonomies.prototype */ + { + model: wp.api.models.Taxonomy, + + url: WP_API_Settings.root + '/taxonomies' + } + ); + + /** + * Backbone comment collection + */ + wp.api.collections.Comments = BaseCollection.extend( + /** @lends Comments.prototype */ + { + model: wp.api.models.Comment, + + post: null, + + /** + * @class Represent an array of comments + * @augments Backbone.Collection + * @constructs + */ + initialize: function( models, options ) { + this.constructor.__super__.initialize.apply( this, arguments ); + + if ( options && options.post ) { + this.post = options.post; + } + }, + + /** + * Return URL for collection + * + * @returns {string} + */ + url: function() { + return WP_API_Settings.root + '/posts/' + this.post + '/comments'; } } - }); + ); /** - * wp.api.collections + * Backbone post type collection */ - api.collections = {}; + wp.api.collections.PostTypes = BaseCollection.extend( + /** @lends PostTypes.prototype */ + { + model: wp.api.models.PostType, + + url: WP_API_Settings.root + '/posts/types' + } + ); + + /** + * Backbone terms collection + */ + wp.api.collections.Terms = BaseCollection.extend( + /** @lends Terms.prototype */ + { + model: wp.api.models.Term, + + type: 'post', + + taxonomy: 'category', + + /** + * @class Represent an array of terms + * @augments Backbone.Collection + * @constructs + */ + initialize: function( models, options ) { + this.constructor.__super__.initialize.apply( this, arguments ); + + if ( typeof options !== 'undefined' ) { + if ( options.type ) { + this.type = options.type; + } + + if ( options.taxonomy ) { + this.taxonomy = options.taxonomy; + } + } + + this.on( 'add', _.bind( this.addModel, this ) ); + }, + + /** + * We need to set the type and taxonomy for each model + * + * @param {Backbone.model} model + */ + addModel: function( model ) { + model.type = this.type; + model.taxonomy = this.taxonomy; + }, + + /** + * Return URL for collection + * + * @returns {string} + */ + url: function() { + return WP_API_Settings.root + '/posts/types/' + this.type + '/taxonomies/' + this.taxonomy + '/terms/'; + } + } + ); /** - * wp.api.collections.Posts + * Backbone revisions collection */ - api.collections.Posts = Backbone.Collection.extend({ - url: api.root + "/posts", + wp.api.collections.Revisions = BaseCollection.extend( + /** @lends Revisions.prototype */ + { + model: wp.api.models.Revision, + + parent: null, + + /** + * @class Represent an array of revisions + * @augments Backbone.Collection + * @constructs + */ + initialize: function( models, options ) { + this.constructor.__super__.initialize.apply( this, arguments ); + + if ( options && options.parent ) { + this.parent = options.parent; + } + }, + + /** + * return URL for collection + * + * @returns {string} + */ + url: function() { + return WP_API_Settings.root + '/posts/' + this.parent + '/revisions'; + } + } + ); - model: api.models.Post - }); -})(jQuery); \ No newline at end of file +})( wp, WP_API_Settings, Backbone, _, window );