namespace, '/' . $this->rest_base, array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, ), ) ); // GET /attributes/{id} - Get single attribute. // PUT /attributes/{id} - Update attribute. // DELETE /attributes/{id} - Delete attribute. register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_item' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_item' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, ), ) ); // GET /attributes/{id}/terms - List attribute terms. // POST /attributes/{id}/terms - Create attribute term. register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)/terms', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_terms' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_term' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, ), ) ); // PUT /attributes/{id}/terms/{term_id} - Update attribute term. // DELETE /attributes/{id}/terms/{term_id} - Delete attribute term. register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)/terms/(?P[\d]+)', array( array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_term' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_term' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, ), ) ); // POST /attributes/{id}/terms/batch - Batch operations on terms. register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)/terms/batch', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'batch_terms' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, ), ) ); } /** * Get all product attributes. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function get_items( $request ) { $this->log_request( 'GET /attributes', $request->get_params() ); $raw_attributes = wc_get_attribute_taxonomies(); $data = array(); foreach ( $raw_attributes as $raw_attribute ) { // wc_get_attribute_taxonomies returns objects with attribute_* properties // We need to get the full attribute object using wc_get_attribute() $attribute = wc_get_attribute( $raw_attribute->attribute_id ); if ( $attribute ) { $data[] = $this->prepare_attribute_for_response( $attribute ); } } return new WP_REST_Response( array( 'data' => $data ), 200 ); } /** * Get a single attribute. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function get_item( $request ) { $id = (int) $request->get_param( 'id' ); $this->log_request( 'GET /attributes/' . $id, array() ); $attribute = wc_get_attribute( $id ); if ( ! $attribute ) { return new WP_Error( 'gc_attribute_not_found', __( 'Attribuut niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } return new WP_REST_Response( array( 'data' => $this->prepare_attribute_for_response( $attribute ) ), 200 ); } /** * Create a new attribute. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function create_item( $request ) { $this->log_request( 'POST /attributes', array() ); $data = $request->get_json_params(); if ( empty( $data['name'] ) ) { return new WP_Error( 'gc_missing_name', __( 'Attribuut naam is verplicht.', 'genius-commerce' ), array( 'status' => 400 ) ); } // Check if attribute already exists by slug. $slug = isset( $data['slug'] ) ? sanitize_title( $data['slug'] ) : sanitize_title( $data['name'] ); $existing = $this->get_attribute_by_slug( $slug ); if ( $existing ) { // Return existing attribute instead of error. return new WP_REST_Response( array( 'data' => $this->prepare_attribute_for_response( $existing ), 'message' => __( 'Attribuut bestaat al.', 'genius-commerce' ), 'created' => false, ), 200 ); } // Create the attribute. $attribute_id = wc_create_attribute( array( 'name' => sanitize_text_field( $data['name'] ), 'slug' => $slug, 'type' => isset( $data['type'] ) ? sanitize_text_field( $data['type'] ) : 'select', 'order_by' => isset( $data['order_by'] ) ? sanitize_text_field( $data['order_by'] ) : 'menu_order', 'has_archives' => isset( $data['has_archives'] ) ? (bool) $data['has_archives'] : false, ) ); if ( is_wp_error( $attribute_id ) ) { return $attribute_id; } // Immediately register the taxonomy so it's available in the same request. // WooCommerce normally registers taxonomies on init, but we need it now. $this->register_attribute_taxonomy( $slug ); $attribute = wc_get_attribute( $attribute_id ); return new WP_REST_Response( array( 'data' => $this->prepare_attribute_for_response( $attribute ), 'message' => __( 'Attribuut aangemaakt.', 'genius-commerce' ), 'created' => true, ), 201 ); } /** * Register an attribute taxonomy immediately. * * WooCommerce typically registers attribute taxonomies on the 'init' hook, * but when creating attributes via API and immediately using them, * we need to register them in the same request. * * @param string $attribute_slug The attribute slug (without pa_ prefix). * @return void */ protected function register_attribute_taxonomy( string $attribute_slug ): void { $taxonomy_name = wc_attribute_taxonomy_name( $attribute_slug ); // Skip if already registered. if ( taxonomy_exists( $taxonomy_name ) ) { return; } // Get attribute data from database. global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared read with no core API equivalent; sync correctness requires uncached data. $attribute = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}woocommerce_attribute_taxonomies WHERE attribute_name = %s", $attribute_slug ) ); if ( ! $attribute ) { return; } // Register the taxonomy with WooCommerce standard settings. // These are WooCommerce's own attribute-taxonomy filters, re-applied here // so third-party code keeps working exactly as it does for core-registered // attributes. They are not ours to prefix. // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound register_taxonomy( $taxonomy_name, apply_filters( 'woocommerce_taxonomy_objects_' . $taxonomy_name, array( 'product' ) ), apply_filters( 'woocommerce_taxonomy_args_' . $taxonomy_name, array( 'hierarchical' => false, 'show_ui' => false, 'query_var' => true, 'rewrite' => false, 'sort' => false, ) ) ); // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound } /** * Update an attribute. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function update_item( $request ) { $id = (int) $request->get_param( 'id' ); $this->log_request( 'PUT /attributes/' . $id, array() ); $attribute = wc_get_attribute( $id ); if ( ! $attribute ) { return new WP_Error( 'gc_attribute_not_found', __( 'Attribuut niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } $data = $request->get_json_params(); $update_args = array( 'id' => $id ); if ( isset( $data['name'] ) ) { $update_args['name'] = sanitize_text_field( $data['name'] ); } if ( isset( $data['slug'] ) ) { $update_args['slug'] = sanitize_title( $data['slug'] ); } if ( isset( $data['type'] ) ) { $update_args['type'] = sanitize_text_field( $data['type'] ); } if ( isset( $data['order_by'] ) ) { $update_args['order_by'] = sanitize_text_field( $data['order_by'] ); } if ( isset( $data['has_archives'] ) ) { $update_args['has_archives'] = (bool) $data['has_archives']; } $result = wc_update_attribute( $id, $update_args ); if ( is_wp_error( $result ) ) { return $result; } $attribute = wc_get_attribute( $id ); return new WP_REST_Response( array( 'data' => $this->prepare_attribute_for_response( $attribute ), 'message' => __( 'Attribuut bijgewerkt.', 'genius-commerce' ), ), 200 ); } /** * Delete an attribute. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function delete_item( $request ) { $id = (int) $request->get_param( 'id' ); $this->log_request( 'DELETE /attributes/' . $id, array() ); $attribute = wc_get_attribute( $id ); if ( ! $attribute ) { return new WP_Error( 'gc_attribute_not_found', __( 'Attribuut niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } $response_data = $this->prepare_attribute_for_response( $attribute ); $result = wc_delete_attribute( $id ); if ( ! $result ) { return new WP_Error( 'gc_attribute_delete_failed', __( 'Attribuut verwijderen mislukt.', 'genius-commerce' ), array( 'status' => 500 ) ); } return new WP_REST_Response( array( 'data' => $response_data, 'message' => __( 'Attribuut verwijderd.', 'genius-commerce' ), 'deleted' => true, ), 200 ); } /** * Get terms for an attribute. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function get_terms( $request ) { $attribute_id = (int) $request->get_param( 'attribute_id' ); $this->log_request( 'GET /attributes/' . $attribute_id . '/terms', array() ); $attribute = wc_get_attribute( $attribute_id ); if ( ! $attribute ) { return new WP_Error( 'gc_attribute_not_found', __( 'Attribuut niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } $taxonomy = wc_attribute_taxonomy_name_by_id( $attribute_id ); $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, 'orderby' => 'menu_order', 'order' => 'ASC', ) ); if ( is_wp_error( $terms ) ) { return $terms; } $data = array(); foreach ( $terms as $term ) { $data[] = $this->prepare_term_for_response( $term ); } return new WP_REST_Response( array( 'data' => $data ), 200 ); } /** * Create a term for an attribute. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function create_term( $request ) { $attribute_id = (int) $request->get_param( 'attribute_id' ); $this->log_request( 'POST /attributes/' . $attribute_id . '/terms', array() ); $attribute = wc_get_attribute( $attribute_id ); if ( ! $attribute ) { return new WP_Error( 'gc_attribute_not_found', __( 'Attribuut niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } $data = $request->get_json_params(); if ( empty( $data['name'] ) ) { return new WP_Error( 'gc_missing_name', __( 'Term naam is verplicht.', 'genius-commerce' ), array( 'status' => 400 ) ); } $taxonomy = wc_attribute_taxonomy_name_by_id( $attribute_id ); // Check if term already exists. $existing = get_term_by( 'name', $data['name'], $taxonomy ); if ( $existing ) { return new WP_REST_Response( array( 'data' => $this->prepare_term_for_response( $existing ), 'message' => __( 'Term bestaat al.', 'genius-commerce' ), 'created' => false, ), 200 ); } $term_args = array( 'slug' => isset( $data['slug'] ) ? sanitize_title( $data['slug'] ) : '', ); if ( isset( $data['description'] ) ) { $term_args['description'] = sanitize_textarea_field( $data['description'] ); } $result = wp_insert_term( sanitize_text_field( $data['name'] ), $taxonomy, $term_args ); if ( is_wp_error( $result ) ) { return $result; } // Set menu_order if provided. if ( isset( $data['menu_order'] ) ) { update_term_meta( $result['term_id'], 'order', (int) $data['menu_order'] ); } $term = get_term( $result['term_id'], $taxonomy ); return new WP_REST_Response( array( 'data' => $this->prepare_term_for_response( $term ), 'message' => __( 'Term aangemaakt.', 'genius-commerce' ), 'created' => true, ), 201 ); } /** * Update a term. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function update_term( $request ) { $attribute_id = (int) $request->get_param( 'attribute_id' ); $term_id = (int) $request->get_param( 'term_id' ); $this->log_request( 'PUT /attributes/' . $attribute_id . '/terms/' . $term_id, array() ); $attribute = wc_get_attribute( $attribute_id ); if ( ! $attribute ) { return new WP_Error( 'gc_attribute_not_found', __( 'Attribuut niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } $taxonomy = wc_attribute_taxonomy_name_by_id( $attribute_id ); $term = get_term( $term_id, $taxonomy ); if ( ! $term || is_wp_error( $term ) ) { return new WP_Error( 'gc_term_not_found', __( 'Term niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } $data = $request->get_json_params(); $update_args = array(); if ( isset( $data['name'] ) ) { $update_args['name'] = sanitize_text_field( $data['name'] ); } if ( isset( $data['slug'] ) ) { $update_args['slug'] = sanitize_title( $data['slug'] ); } if ( isset( $data['description'] ) ) { $update_args['description'] = sanitize_textarea_field( $data['description'] ); } if ( ! empty( $update_args ) ) { $result = wp_update_term( $term_id, $taxonomy, $update_args ); if ( is_wp_error( $result ) ) { return $result; } } // Update menu_order if provided. if ( isset( $data['menu_order'] ) ) { update_term_meta( $term_id, 'order', (int) $data['menu_order'] ); } $term = get_term( $term_id, $taxonomy ); return new WP_REST_Response( array( 'data' => $this->prepare_term_for_response( $term ), 'message' => __( 'Term bijgewerkt.', 'genius-commerce' ), ), 200 ); } /** * Delete a term. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function delete_term( $request ) { $attribute_id = (int) $request->get_param( 'attribute_id' ); $term_id = (int) $request->get_param( 'term_id' ); $this->log_request( 'DELETE /attributes/' . $attribute_id . '/terms/' . $term_id, array() ); $attribute = wc_get_attribute( $attribute_id ); if ( ! $attribute ) { return new WP_Error( 'gc_attribute_not_found', __( 'Attribuut niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } $taxonomy = wc_attribute_taxonomy_name_by_id( $attribute_id ); $term = get_term( $term_id, $taxonomy ); if ( ! $term || is_wp_error( $term ) ) { return new WP_Error( 'gc_term_not_found', __( 'Term niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } $response_data = $this->prepare_term_for_response( $term ); $result = wp_delete_term( $term_id, $taxonomy ); if ( is_wp_error( $result ) ) { return $result; } if ( ! $result ) { return new WP_Error( 'gc_term_delete_failed', __( 'Term verwijderen mislukt.', 'genius-commerce' ), array( 'status' => 500 ) ); } return new WP_REST_Response( array( 'data' => $response_data, 'message' => __( 'Term verwijderd.', 'genius-commerce' ), 'deleted' => true, ), 200 ); } /** * Batch operations on terms. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function batch_terms( $request ) { $attribute_id = (int) $request->get_param( 'attribute_id' ); $this->log_request( 'POST /attributes/' . $attribute_id . '/terms/batch', array() ); $attribute = wc_get_attribute( $attribute_id ); if ( ! $attribute ) { return new WP_Error( 'gc_attribute_not_found', __( 'Attribuut niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } $data = $request->get_json_params(); $create = isset( $data['create'] ) ? (array) $data['create'] : array(); $update = isset( $data['update'] ) ? (array) $data['update'] : array(); $delete = isset( $data['delete'] ) ? (array) $data['delete'] : array(); // Enforce batch limit. $total = count( $create ) + count( $update ) + count( $delete ); if ( $total > 100 ) { return new WP_Error( 'gc_batch_limit_exceeded', __( 'Maximum 100 operaties per batch request.', 'genius-commerce' ), array( 'status' => 400 ) ); } $taxonomy = wc_attribute_taxonomy_name_by_id( $attribute_id ); $results = array( 'create' => array(), 'update' => array(), 'delete' => array(), ); // Process creates. foreach ( $create as $item ) { if ( empty( $item['name'] ) ) { $results['create'][] = array( 'error' => true, 'message' => __( 'Term naam is verplicht.', 'genius-commerce' ), ); continue; } // Check if term already exists. $existing = get_term_by( 'name', $item['name'], $taxonomy ); if ( $existing ) { $results['create'][] = $this->prepare_term_for_response( $existing ); continue; } $term_args = array(); if ( isset( $item['slug'] ) ) { $term_args['slug'] = sanitize_title( $item['slug'] ); } $result = wp_insert_term( sanitize_text_field( $item['name'] ), $taxonomy, $term_args ); if ( is_wp_error( $result ) ) { $results['create'][] = array( 'error' => true, 'message' => $result->get_error_message(), ); } else { if ( isset( $item['menu_order'] ) ) { update_term_meta( $result['term_id'], 'order', (int) $item['menu_order'] ); } $term = get_term( $result['term_id'], $taxonomy ); $results['create'][] = $this->prepare_term_for_response( $term ); } } // Process updates. foreach ( $update as $item ) { if ( empty( $item['id'] ) ) { $results['update'][] = array( 'error' => true, 'message' => __( 'Term ID is verplicht voor update.', 'genius-commerce' ), ); continue; } $term = get_term( $item['id'], $taxonomy ); if ( ! $term || is_wp_error( $term ) ) { $results['update'][] = array( 'id' => $item['id'], 'error' => true, 'message' => __( 'Term niet gevonden.', 'genius-commerce' ), ); continue; } $update_args = array(); if ( isset( $item['name'] ) ) { $update_args['name'] = sanitize_text_field( $item['name'] ); } if ( isset( $item['slug'] ) ) { $update_args['slug'] = sanitize_title( $item['slug'] ); } if ( ! empty( $update_args ) ) { $result = wp_update_term( $item['id'], $taxonomy, $update_args ); if ( is_wp_error( $result ) ) { $results['update'][] = array( 'id' => $item['id'], 'error' => true, 'message' => $result->get_error_message(), ); continue; } } if ( isset( $item['menu_order'] ) ) { update_term_meta( $item['id'], 'order', (int) $item['menu_order'] ); } $term = get_term( $item['id'], $taxonomy ); $results['update'][] = $this->prepare_term_for_response( $term ); } // Process deletes. foreach ( $delete as $term_id ) { $term = get_term( $term_id, $taxonomy ); if ( ! $term || is_wp_error( $term ) ) { $results['delete'][] = array( 'id' => $term_id, 'error' => true, 'message' => __( 'Term niet gevonden.', 'genius-commerce' ), ); continue; } $result = wp_delete_term( $term_id, $taxonomy ); if ( is_wp_error( $result ) || ! $result ) { $results['delete'][] = array( 'id' => $term_id, 'error' => true, 'message' => __( 'Term verwijderen mislukt.', 'genius-commerce' ), ); } else { $results['delete'][] = array( 'id' => $term_id, 'deleted' => true, ); } } return new WP_REST_Response( array( 'data' => $results ), 200 ); } /** * Prepare an attribute for the REST response. * * @param object $attribute The attribute object. * @return array Prepared attribute data. */ protected function prepare_attribute_for_response( $attribute ): array { $taxonomy = wc_attribute_taxonomy_name_by_id( $attribute->id ); $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, ) ); $term_data = array(); if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) { foreach ( $terms as $term ) { $term_data[] = array( 'term_id' => (int) $term->term_id, 'name' => $term->name, 'slug' => $term->slug, ); } } return array( 'id' => (int) $attribute->id, 'name' => $attribute->name, 'slug' => $attribute->slug, 'type' => $attribute->type, 'order_by' => $attribute->order_by, 'has_archives' => (bool) $attribute->has_archives, 'terms' => $term_data, ); } /** * Prepare a term for the REST response. * * @param \WP_Term $term The term object. * @return array Prepared term data. */ protected function prepare_term_for_response( $term ): array { return array( 'id' => (int) $term->term_id, 'name' => $term->name, 'slug' => $term->slug, 'description' => $term->description, 'menu_order' => (int) get_term_meta( $term->term_id, 'order', true ), 'count' => (int) $term->count, ); } /** * Get an attribute by its slug. * * @param string $slug The attribute slug. * @return object|null The attribute object or null. */ protected function get_attribute_by_slug( string $slug ) { $attributes = wc_get_attribute_taxonomies(); foreach ( $attributes as $attribute ) { if ( $attribute->attribute_name === $slug ) { return wc_get_attribute( $attribute->attribute_id ); } } return null; } }