class-acf-rest-api.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. <?php
  2. // Exit if accessed directly.
  3. if ( ! defined( 'ABSPATH' ) ) {
  4. exit;
  5. }
  6. // If class is already defined, return.
  7. if ( class_exists( 'ACF_Rest_Api' ) ) {
  8. return;
  9. }
  10. class ACF_Rest_Api {
  11. /** @var ACF_Rest_Request */
  12. private $request;
  13. /** @var ACF_Rest_Embed_Links */
  14. private $embed_links;
  15. public function __construct() {
  16. add_filter( 'rest_pre_dispatch', array( $this, 'initialize' ), 10, 3 );
  17. add_action( 'rest_api_init', array( $this, 'register_field' ) );
  18. }
  19. public function initialize( $response, $handler, $request ) {
  20. if ( ! acf_get_setting( 'rest_api_enabled' ) ) {
  21. return;
  22. }
  23. // Parse request and set the object for local access.
  24. $this->request = new ACF_Rest_Request();
  25. $this->request->parse_request( $request );
  26. // Register the 'acf' REST property.
  27. $this->register_field();
  28. // If embed links are enabled in ACF's global settings, init the handler and set for local access.
  29. if ( acf_get_setting( 'rest_api_embed_links' ) ) {
  30. $this->embed_links = new ACF_Rest_Embed_Links();
  31. $this->embed_links->initialize();
  32. }
  33. }
  34. /**
  35. * Register our custom property as a REST field.
  36. */
  37. public function register_field() {
  38. if ( ! acf_get_setting( 'rest_api_enabled' ) ) {
  39. return;
  40. }
  41. if ( ! $this->request instanceof ACF_Rest_Request ) {
  42. $this->request = new ACF_Rest_Request();
  43. $this->request->parse_request( null );
  44. }
  45. $base = $this->request->object_sub_type;
  46. // If the object sub type ($post_type, $taxonomy, 'user') cannot be determined from the current request,
  47. // we don't know what endpoint to register the field against. Bail if that is the case.
  48. if ( ! $base ) {
  49. return;
  50. }
  51. if ( $this->request->child_object_type ) {
  52. $base = $this->request->child_object_type;
  53. }
  54. // If we've already registered this route, no need to do it again.
  55. if ( acf_did( 'acf/register_rest_field' ) ) {
  56. global $wp_rest_additional_fields;
  57. if ( isset( $wp_rest_additional_fields[ $base ], $wp_rest_additional_fields[ $base ]['acf'] ) ) {
  58. return;
  59. }
  60. }
  61. register_rest_field(
  62. $base,
  63. 'acf',
  64. array(
  65. 'schema' => $this->get_schema(),
  66. 'get_callback' => array( $this, 'load_fields' ),
  67. 'update_callback' => array( $this, 'update_fields' ),
  68. )
  69. );
  70. }
  71. /**
  72. * Dynamically generate the schema for the current request.
  73. *
  74. * @return array
  75. */
  76. private function get_schema() {
  77. $schema = array(
  78. 'description' => 'ACF field data',
  79. 'type' => 'object',
  80. 'properties' => array(),
  81. 'arg_options' => array(
  82. 'validate_callback' => array( $this, 'validate_rest_arg' ),
  83. ),
  84. );
  85. // If we don't have an object type, we can't determine the schema for the current request.
  86. $object_type = $this->request->object_type;
  87. if ( ! $object_type ) {
  88. return $schema;
  89. }
  90. $object_id = $this->request->get_url_param( 'id' );
  91. $child_id = $this->request->get_url_param( 'child_id' );
  92. $object_sub_type = $this->request->object_sub_type;
  93. if ( $child_id ) {
  94. $object_id = $child_id;
  95. }
  96. if ( ! $object_id ) {
  97. $field_groups = $this->get_field_groups_by_object_type( $object_type );
  98. } else {
  99. $field_groups = $this->get_field_groups_by_id( $object_id, $object_type, $object_sub_type );
  100. }
  101. if ( empty( $field_groups ) ) {
  102. return $schema;
  103. }
  104. foreach ( $field_groups as $field_group ) {
  105. foreach ( $this->get_fields( $field_group, $object_id ) as $field ) {
  106. $schema['properties'][ $field['name'] ] = acf_get_field_rest_schema( $field );
  107. }
  108. }
  109. return $schema;
  110. }
  111. /**
  112. * Validate the request args. Mostly a wrapper for `rest_validate_request_arg()`, but also
  113. * fires off a filter, so we can add some custom validation for specific fields.
  114. *
  115. * This will likely no longer be needed once WordPress implements something like `validate_callback`
  116. * and `sanitize_callback` for nested schema properties, see:
  117. * https://core.trac.wordpress.org/ticket/49960
  118. *
  119. * @param mixed $value
  120. * @param \WP_REST_Request $request
  121. * @param string $param
  122. *
  123. * @return bool|WP_Error
  124. */
  125. public function validate_rest_arg( $value, $request, $param ) {
  126. // Validate all fields with default WordPress validation first.
  127. $valid = rest_validate_request_arg( $value, $request, $param );
  128. if ( true !== $valid ) {
  129. return $valid;
  130. }
  131. foreach ( $value as $field_name => $field_value ) {
  132. $field = acf_get_field( $field_name );
  133. if ( ! $field ) {
  134. continue;
  135. }
  136. /**
  137. * Filters whether a value passed via REST is valid.
  138. *
  139. * @since 5.11
  140. *
  141. * @param bool $valid True if the value is valid, false or WP_Error if not.
  142. * @param mixed $value The value to check.
  143. * @param array $field An array of information about the field.
  144. */
  145. $valid = apply_filters( 'acf/validate_rest_value/type=' . $field['type'], true, $field_value, $field );
  146. if ( true !== $valid ) {
  147. return $valid;
  148. }
  149. }
  150. return true;
  151. }
  152. /**
  153. * Load field values into the requested object. This method is not a part of any public API and is only public as
  154. * it is required by WordPress.
  155. *
  156. * @param array $object An array representation of the post, term, or user object.
  157. * @param string $field_name
  158. * @param WP_REST_Request $request
  159. * @param string $object_sub_type Note that this isn't the same as $this->object_type. This variable is
  160. * more specific and can be a post type or taxonomy.
  161. * @return array
  162. */
  163. public function load_fields( $object, $field_name, $request, $object_sub_type ) {
  164. // The fields loaded for display on the REST API in the form of {$field_name}=>{$field_value} pairs.
  165. $fields = array();
  166. // Determine the object ID from the given object.
  167. $object_id = acf_get_object_id( $object );
  168. // Use this object type parsed from the request.
  169. $object_type = $this->request->object_type;
  170. // Object ID and type are essential to determining which fields to load. Return if we don't have both.
  171. if ( ! $object_id or ! $object_type ) {
  172. return $fields;
  173. }
  174. $object_sub_type = str_replace( '-revision', '', $object_sub_type );
  175. // Get all field groups for the current object.
  176. $field_groups = $this->get_field_groups_by_id( $object_id, $object_type, $object_sub_type );
  177. if ( empty( $field_groups ) ) {
  178. return $fields;
  179. }
  180. // Determine the ACF ID string for the current object.
  181. $post_id = $this->make_identifier( $object_id, $object_type );
  182. // Loop through the fields within all applicable field groups and add the fields to the response.
  183. foreach ( $field_groups as $field_group ) {
  184. foreach ( $this->get_fields( $field_group, $object_id ) as $field ) {
  185. $value = acf_get_value( $post_id, $field );
  186. if ( $this->embed_links ) {
  187. $this->embed_links->prepare_links( $post_id, $field );
  188. }
  189. // Format the field value according to the request params.
  190. $format = $request->get_param( 'acf_format' ) ?: acf_get_setting( 'rest_api_format' );
  191. $value = acf_format_value_for_rest( $value, $post_id, $field, $format );
  192. $fields[ $field['name'] ] = $value;
  193. }
  194. }
  195. /**
  196. * Reset the store so that REST API values (which may be preloaded
  197. * by WP core and have different values than standard values) aren't
  198. * saved to the store.
  199. */
  200. acf_get_store( 'values' )->reset();
  201. return $fields;
  202. }
  203. /**
  204. * Update any incoming field values for the given object. This method is not a part of any public API and is only
  205. * public as it is required by WordPress.
  206. *
  207. * @param array $data
  208. * @param WP_Post|WP_Term|WP_User $object
  209. * @param string $property 'acf'
  210. * @param WP_REST_Request $request
  211. * @param string $object_sub_type This will be the post type, the taxonomy, or 'user'.
  212. * @return bool|WP_Error
  213. */
  214. public function update_fields( $data, $object, $property, $request, $object_sub_type ) {
  215. // If 'acf' data object is empty, don't do anything.
  216. if ( empty( $data ) ) {
  217. return true;
  218. }
  219. // Determine the object context (type & ID). If the context can't be determined from the current request, throw an
  220. // error as the fields are not updateable. This handles in line with WordPress' \WP_REST_Request::sanitize_params().
  221. $object_id = acf_get_object_id( $object );
  222. $object_type = $this->request->object_type;
  223. if ( ! $object_id or ! $object_type ) {
  224. return new WP_Error(
  225. 'acf_rest_object_unknown',
  226. __( sprintf( 'Unable to determine the %s object ID or type. The %s property cannot be updated.', get_class( $object ), $property ), 'acf' ),
  227. array( 'status' => 400 )
  228. );
  229. }
  230. // Determine the ACF selector for the current object.
  231. $post_id = $this->make_identifier( $object_id, $object_type );
  232. // Allow unrestricted update of fields by field key when saving via the WordPress admin. Admin mode will
  233. // update fields using their field keys to lookup the field. The field lookup is not scoped to field groups
  234. // located on the given object so any field can be updated. Given the field keys are not defined in the
  235. // schema, core validation/sanitisation are also bypassed.
  236. // if ( $this->is_admin_mode( $data ) ) {
  237. // Loop through payload and save fields using field keys.
  238. // foreach ( $data as $field_key => $value ) {
  239. // if ( $field = acf_get_field( $field_key ) ) {
  240. // acf_update_value( $value, $post_id, $field );
  241. // }
  242. // }
  243. //
  244. // return true;
  245. // }
  246. // todo - consider/discuss handling this in the request object instead
  247. // If the incoming data defines field group keys, extract it from the data. This is used to scope the
  248. // field lookup in \ACF_Rest_Api::get_field_groups_by_id();
  249. $field_group_scope = acf_extract_var( $data, '_acf_field_group_scope', array() );
  250. // Get all field groups for the current object.
  251. $field_groups = $this->get_field_groups_by_id( $object_id, $object_type, $object_sub_type, $field_group_scope );
  252. if ( empty( $field_groups ) ) {
  253. return true;
  254. }
  255. // Collect all fields from matching field groups.
  256. $all_fields = array();
  257. foreach ( $field_groups as $field_group ) {
  258. if ( $fields = $this->get_fields( $field_group, $object_id ) ) {
  259. $all_fields = array_merge( $fields, $all_fields );
  260. }
  261. }
  262. if ( $all_fields ) {
  263. // todo - consider/discuss handling this in the request object instead.
  264. // If the incoming request has a map of field names to keys, extract it for use in the subsequent
  265. // field search.
  266. $field_key_map = acf_extract_var( $data, '_acf_field_key_map', array() );
  267. // Loop through the inbound data payload, find the field matching the incoming field name, and
  268. // update the field.
  269. foreach ( $data as $field_name => $value ) {
  270. // If the field name has a key explicitly mapped to it, use the field key to find the field.
  271. if ( isset( $field_key_map[ $field_name ] ) ) {
  272. $field_name = $field_key_map[ $field_name ];
  273. }
  274. if ( $field = acf_search_fields( $field_name, $all_fields ) ) {
  275. acf_update_value( $value, $post_id, $field );
  276. }
  277. }
  278. }
  279. return true;
  280. }
  281. // todo - this should check for a flag and validate a nonce to ensure we are in admin mode.
  282. // todo - consider/discuss handling this in the request object instead.
  283. private function is_admin_mode( $data ) {
  284. return isset( $data['_acf_admin_mode'] ) && $data['_acf_admin_mode'];
  285. }
  286. /**
  287. * Make the ACF identifier string for the given object.
  288. *
  289. * @param int $object_id
  290. * @param string $object_type 'user', 'term', or 'post'
  291. * @return string
  292. */
  293. private function make_identifier( $object_id, $object_type ) {
  294. $formats = array(
  295. 'user' => 'user_%s',
  296. 'term' => 'term_%s',
  297. 'comment' => 'comment_%s',
  298. );
  299. return isset( $formats[ $object_type ] )
  300. ? sprintf( $formats[ $object_type ], $object_id )
  301. : $object_id;
  302. }
  303. /**
  304. * Gets an array of the location types that a field group is configured to use.
  305. *
  306. * @param string $object_type 'user', 'term', or 'post'
  307. * @param array $field_group The field group to check.
  308. * @param array $location_types An array of location types.
  309. *
  310. * @return bool
  311. */
  312. private function object_type_has_field_group( $object_type, $field_group, $location_types = array() ) {
  313. if ( ! isset( $field_group['location'] ) || ! is_array( $field_group['location'] ) ) {
  314. return false;
  315. }
  316. $location_types = empty( $location_types ) ? acf_get_location_types() : $location_types;
  317. foreach ( $field_group['location'] as $rule_group ) {
  318. $match = false;
  319. foreach ( $rule_group as $rule ) {
  320. $rule = acf_validate_location_rule( $rule );
  321. if ( ! isset( $location_types[ $rule['param'] ] ) ) {
  322. continue;
  323. }
  324. // Make sure the main object type matches.
  325. $location_type = $location_types[ $rule['param'] ];
  326. if ( ! isset( $location_type->object_type ) || $location_type->object_type !== (string) $object_type ) {
  327. continue;
  328. }
  329. /**
  330. * For posts/pages, we can only be sure that fields will show up if
  331. * the field group is configured to show up for all items of the current
  332. * post type.
  333. */
  334. if ( 'post' === $object_type && 'post_type' === $rule['param'] ) {
  335. if ( $rule['operator'] === '==' && $this->request->object_sub_type !== $rule['value'] ) {
  336. continue;
  337. }
  338. if ( $rule['operator'] === '!=' && $this->request->object_sub_type === $rule['value'] ) {
  339. continue;
  340. }
  341. $match = true;
  342. }
  343. if ( 'term' === $object_type && 'taxonomy' === $rule['param'] ) {
  344. if ( $rule['operator'] === '==' && $this->request->object_sub_type !== $rule['value'] ) {
  345. continue;
  346. }
  347. if ( $rule['operator'] === '!=' && $this->request->object_sub_type === $rule['value'] ) {
  348. continue;
  349. }
  350. $match = true;
  351. }
  352. if ( in_array( $object_type, array( 'user', 'comment' ) ) ) {
  353. $match = true;
  354. }
  355. }
  356. if ( $match ) {
  357. return true;
  358. }
  359. }
  360. return false;
  361. }
  362. /**
  363. * Get all field groups for the provided object type.
  364. *
  365. * @param string $object_type 'user', 'term', or 'post'
  366. *
  367. * @return array An array of field groups that display for that location type.
  368. */
  369. private function get_field_groups_by_object_type( $object_type ) {
  370. $field_groups = acf_get_field_groups();
  371. $location_types = acf_get_location_types();
  372. $object_type_groups = array();
  373. foreach ( $field_groups as $field_group ) {
  374. if ( empty( $field_group['show_in_rest'] ) ) {
  375. continue;
  376. }
  377. if ( $this->object_type_has_field_group( $object_type, $field_group, $location_types ) ) {
  378. $object_type_groups[] = $field_group;
  379. }
  380. }
  381. return $object_type_groups;
  382. }
  383. /**
  384. * Get all field groups for a given object.
  385. *
  386. * @param int $object_id
  387. * @param string $object_type 'user', 'term', or 'post'
  388. * @param string|null $object_sub_type The post type or taxonomy. When an $object_type of 'user' is in play, this can be ignored.
  389. * @param array $scope Field group keys to limit the returned set of field groups to. This is used to scope field lookups to specific groups.
  390. * @return array An array of matching field groups.
  391. */
  392. private function get_field_groups_by_id( $object_id, $object_type, $object_sub_type = null, $scope = array() ) {
  393. // When dealing with a term, we need the taxonomy in order to look up the relevant field groups. The taxonomy is expected
  394. // in the $object_sub_type variable but when building our schema, this isn't readily available. This block ensures the
  395. // taxonomy is set when not passed in.
  396. if ( $object_type === 'term' && $object_sub_type === null ) {
  397. $term = get_term( $object_id );
  398. if ( ! $term instanceof WP_Term ) {
  399. return array();
  400. }
  401. $object_sub_type = $term->taxonomy;
  402. }
  403. switch ( $object_type ) {
  404. case 'user':
  405. $args = array(
  406. 'user_id' => $object_id,
  407. 'rest' => true,
  408. );
  409. break;
  410. case 'term':
  411. $args = array( 'taxonomy' => $object_sub_type );
  412. break;
  413. case 'comment':
  414. $comment = get_comment( $object_id );
  415. $post_type = get_post_type( $comment->comment_post_ID );
  416. $args = array( 'comment' => $post_type );
  417. break;
  418. case 'post':
  419. default:
  420. $args = array( 'post_id' => $object_id );
  421. $child_rest_base = $this->request->get_url_param( 'child_rest_base' );
  422. if ( $child_rest_base && 'post' === $object_type ) {
  423. $args['post_type'] = $object_sub_type;
  424. }
  425. }
  426. // Only return field groups that are configured to show in REST.
  427. return array_filter(
  428. acf_get_field_groups( $args ),
  429. function ( $group ) use ( $scope ) {
  430. if ( $scope and ! in_array( $group['key'], $scope ) ) {
  431. return false;
  432. }
  433. return $group['show_in_rest'];
  434. }
  435. );
  436. }
  437. /**
  438. * Get all ACF fields for a given field group and allow third party filtering.
  439. *
  440. * @param array $field_group This could technically be other possible values supported by acf_get_fields() but in this
  441. * context, we're only using the field group arrays.
  442. * @param null|int $object_id The ID of the object being prepared.
  443. * @return array
  444. */
  445. private function get_fields( $field_group, $object_id = null ) {
  446. // Get all fields for this field group that are rest enabled.
  447. $fields = array_filter(
  448. acf_get_fields( $field_group ),
  449. function ( $field ) {
  450. $field_type = acf_get_field_type( $field['type'] );
  451. return isset( $field_type->show_in_rest ) && $field_type->show_in_rest;
  452. }
  453. );
  454. // Set up context array for use in the filter below.
  455. $resource = array(
  456. 'type' => $this->request->object_type,
  457. 'sub_type' => $this->request->object_sub_type,
  458. 'id' => $object_id,
  459. );
  460. $http_method = $this->request->http_method;
  461. /**
  462. * Filter the fields available to the REST API.
  463. *
  464. * @param array $fields The ACF fields for this field group.
  465. * @param array $resource Contextual information about the current resource request.
  466. * @param string $http_method The HTTP method of the current request (GET, POST, PUT, PATCH, DELETE, OPTION, HEAD).
  467. */
  468. return (array) apply_filters( 'acf/rest/get_fields', $fields, $resource, $http_method );
  469. }
  470. }