Commit 5a3373de3a for woocommerce
commit 5a3373de3a66be9323165d673103fadc740b9971
Author: Fernando Espinosa <Ferdev@users.noreply.github.com>
Date: Wed Nov 26 16:48:50 2025 +0100
Add REST API endpoint for shipping providers (#61910)
* Add REST API endpoint for shipping providers
Implements /wc/v4/fulfillments/providers endpoint to expose shipping
provider information (labels, icons, tracking URLs) for the CIAB
Next Admin fulfillment workflow.
Changes:
- Add /providers route to v4 Fulfillments Controller
- Add get_providers() method returning FulfillmentUtils data
- Add permission checks requiring manage_woocommerce capability
- Register ProvidersRestController in FulfillmentsController
* Add changefile(s) from automation for the following project(s): woocommerce
* Remove duplicated code
* Address PR comments
* Fix linting problems
* Remove unused argument in fulfillments controller `get_providers`
* Refactor Fulfillments Controller to use separate Schema class
Following V4 REST API guidelines, moved schema definitions to a dedicated
FulfillmentSchema class extending AbstractSchema. This improves code
reusability and maintainability by:
- Creating FulfillmentSchema with get_item_schema_properties() and
get_item_response() methods
- Replacing constructor with init() method for dependency injection
- Updating route registration to use standard V4 patterns
- Removing all inline schema methods from Controller
* Update ProvidersTest to initialize controller with schema
Add FulfillmentSchema initialization in setUp() to match the refactored
Controller that now requires init() to be called before register_routes().
* Add WP_REST_Request parameter to get_providers callback
The REST API passes request objects to callbacks, so the method
signature must accept it to avoid ArgumentCountError.
* Add response filter for shipping providers endpoint
Add woocommerce_rest_prepare_fulfillments_providers filter to allow
extensions to customize the providers response, following WooCommerce
REST API conventions.
* Inject OrderFulfillmentsRestController via DI pattern
Follow V4 controller convention by injecting dependencies through
init() method rather than direct instantiation.
* Fix visibility of get_schema_for_providers REST callback
Change from private to public to allow REST server to invoke
the schema callback for OPTIONS requests and API discovery.
* Add validation for shipping providers filter response
- Document required provider structure in filter docblock
- Validate filtered result is an array
- Remove providers missing required keys (label, icon, value, url)
- Log _doing_it_wrong() when extensions return invalid structures
This helps maintain API stability by ensuring the response always
matches the documented schema regardless of filter modifications.
* Fix linting errors in providers validation
- Use esc_html__() for _doing_it_wrong() messages
- Fix variable alignment in validate_providers_structure()
---------
Co-authored-by: github-actions <github-actions@github.com>
diff --git a/plugins/woocommerce/changelog/61910-wooship-1574-shipping-fulfillments-create-endpoint-to-get-shipping-providers b/plugins/woocommerce/changelog/61910-wooship-1574-shipping-fulfillments-create-endpoint-to-get-shipping-providers
new file mode 100644
index 0000000000..3187e6fd3b
--- /dev/null
+++ b/plugins/woocommerce/changelog/61910-wooship-1574-shipping-fulfillments-create-endpoint-to-get-shipping-providers
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add REST API endpoint `/wc/v4/fulfillments/providers` to expose shipping provider information including labels, icons, and tracking URLs for CIAB Next Admin integration.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php
index d317e58850..19afcf3c64 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php
@@ -19,6 +19,7 @@ use Automattic\WooCommerce\Internal\Admin\Settings\Exceptions\ApiException;
use Automattic\WooCommerce\Internal\Fulfillments\Fulfillment;
use Automattic\WooCommerce\Internal\Fulfillments\OrderFulfillmentsRestController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Fulfillments\Schema\FulfillmentSchema;
use WP_Http;
use WP_Error;
use WC_Order;
@@ -41,6 +42,13 @@ class Controller extends AbstractController {
*/
protected $rest_base = 'fulfillments';
+ /**
+ * Schema class for this route.
+ *
+ * @var FulfillmentSchema
+ */
+ protected $item_schema;
+
/**
* Order fulfillments controller instance.
*
@@ -49,12 +57,16 @@ class Controller extends AbstractController {
protected $order_fulfillments_controller;
/**
- * Constructor.
+ * Initialize the controller.
*
- * @since 4.0.0
+ * @param FulfillmentSchema $item_schema Fulfillment schema class.
+ * @param OrderFulfillmentsRestController $order_fulfillments_controller Order fulfillments controller.
+ *
+ * @internal
*/
- public function __construct() {
- $this->order_fulfillments_controller = new OrderFulfillmentsRestController();
+ final public function init( FulfillmentSchema $item_schema, OrderFulfillmentsRestController $order_fulfillments_controller ) {
+ $this->item_schema = $item_schema;
+ $this->order_fulfillments_controller = $order_fulfillments_controller;
}
/**
@@ -68,19 +80,24 @@ class Controller extends AbstractController {
$this->namespace,
$this->rest_base,
array(
+ 'schema' => array( $this, 'get_public_item_schema' ),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_fulfillments' ),
'permission_callback' => array( $this, 'check_permission_for_fulfillments' ),
- 'args' => $this->get_args_for_get_fulfillments(),
- 'schema' => $this->get_schema_for_get_fulfillments(),
+ 'args' => array(
+ 'order_id' => array(
+ 'description' => __( 'Unique identifier for the order.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'required' => true,
+ ),
+ ),
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_fulfillment' ),
'permission_callback' => array( $this, 'check_permission_for_fulfillments' ),
- 'args' => $this->get_args_for_create_fulfillment(),
- 'schema' => $this->get_schema_for_create_fulfillment(),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
),
),
);
@@ -90,29 +107,54 @@ class Controller extends AbstractController {
$this->namespace,
$this->rest_base . '/(?P<fulfillment_id>[\d]+)',
array(
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ 'args' => array(
+ 'fulfillment_id' => array(
+ 'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'required' => true,
+ ),
+ ),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_fulfillment' ),
'permission_callback' => array( $this, 'check_permission_for_fulfillments' ),
- 'args' => $this->get_args_for_get_fulfillment(),
- 'schema' => $this->get_schema_for_get_fulfillment(),
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_fulfillment' ),
'permission_callback' => array( $this, 'check_permission_for_fulfillments' ),
- 'args' => $this->get_args_for_update_fulfillment(),
- 'schema' => $this->get_schema_for_update_fulfillment(),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
),
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_fulfillment' ),
'permission_callback' => array( $this, 'check_permission_for_fulfillments' ),
- 'args' => $this->get_args_for_delete_fulfillment(),
- 'schema' => $this->get_schema_for_delete_fulfillment(),
+ 'args' => array(
+ 'notify_customer' => array(
+ 'description' => __( 'Whether to notify the customer about the fulfillment update.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'default' => false,
+ 'required' => false,
+ ),
+ ),
),
),
);
+
+ // Register the route for getting shipping providers.
+ register_rest_route(
+ $this->namespace,
+ $this->rest_base . '/providers',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_providers' ),
+ 'permission_callback' => array( $this, 'check_permission_for_providers' ),
+ 'schema' => array( $this, 'get_schema_for_providers' ),
+ ),
+ )
+ );
}
/**
@@ -345,17 +387,13 @@ class Controller extends AbstractController {
}
/**
- * Get the schema for the fulfillment resource.
+ * Get the schema for the fulfillment resource. This is consumed by the AbstractController to generate the item schema
+ * after running various hooks on the response.
*
* @return array The schema for the fulfillment resource.
*/
protected function get_schema(): array {
- return array(
- '$schema' => 'http://json-schema.org/draft-04/schema#',
- 'title' => 'fulfillment',
- 'type' => 'object',
- 'properties' => $this->get_read_schema_for_fulfillment(),
- );
+ return $this->item_schema->get_item_schema();
}
/**
@@ -366,360 +404,166 @@ class Controller extends AbstractController {
* @return array The item response.
*/
protected function get_item_response( $item, WP_REST_Request $request ): array {
- // This method is required by AbstractController but not used in our implementation
- // since we delegate to OrderFulfillmentsRestController.
- return array();
- }
-
- /**
- * Get the arguments for the get order fulfillments endpoint.
- *
- * @return array
- */
- private function get_args_for_get_fulfillments(): array {
- return array(
- 'order_id' => array(
- 'description' => __( 'Unique identifier for the order.', 'woocommerce' ),
- 'type' => 'integer',
- 'required' => true,
- 'context' => array( 'view', 'edit' ),
- ),
- );
- }
-
- /**
- * Get the schema for the get order fulfillments endpoint.
- *
- * @return array
- */
- private function get_schema_for_get_fulfillments(): array {
- $schema = array(
- '$schema' => 'http://json-schema.org/draft-04/schema#',
- 'title' => 'base',
- 'type' => 'object',
- );
- $schema['title'] = __( 'Get fulfillments response.', 'woocommerce' );
- $schema['type'] = 'array';
- $schema['items'] = array(
- 'type' => 'object',
- 'properties' => $this->get_read_schema_for_fulfillment(),
- );
- return $schema;
+ return $this->item_schema->get_item_response( $item, $request, $this->get_fields_for_response( $request ) );
}
- /**
- * Get the arguments for the create fulfillment endpoint.
- *
- * @return array
- */
- private function get_args_for_create_fulfillment(): array {
- return $this->get_write_args_for_fulfillment( true );
- }
/**
- * Get the schema for the create fulfillment endpoint.
+ * Prepare an error response.
*
- * @return array
- */
- private function get_schema_for_create_fulfillment(): array {
- $schema = array(
- '$schema' => 'http://json-schema.org/draft-04/schema#',
- 'title' => 'base',
- 'type' => 'object',
- );
- $schema['title'] = __( 'Create fulfillment response.', 'woocommerce' );
- $schema['properties'] = $this->get_read_schema_for_fulfillment();
- return $schema;
- }
-
- /**
- * Get the arguments for the get fulfillment endpoint.
+ * @param string $code The error code.
+ * @param string $message The error message.
+ * @param array $data Additional error data, including 'status' key for HTTP status code.
*
- * @return array
+ * @return WP_REST_Response The error response.
*/
- private function get_args_for_get_fulfillment(): array {
- return array(
- 'fulfillment_id' => array(
- 'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
- 'type' => 'integer',
- 'context' => array( 'view', 'edit' ),
- 'required' => true,
+ private function prepare_error_response( $code, $message, $data ): WP_REST_Response {
+ return new WP_REST_Response(
+ array(
+ 'code' => $code,
+ 'message' => $message,
+ 'data' => $data,
),
+ $data['status'] ?? WP_Http::BAD_REQUEST
);
}
/**
- * Get the schema for the get fulfillment endpoint.
+ * Get all shipping providers.
*
- * @return array
+ * @since 10.5.0
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response
*/
- private function get_schema_for_get_fulfillment(): array {
- $schema = array(
- '$schema' => 'http://json-schema.org/draft-04/schema#',
- 'title' => 'base',
- 'type' => 'object',
- );
- $schema['title'] = __( 'Get fulfillment response.', 'woocommerce' );
- $schema['properties'] = $this->get_read_schema_for_fulfillment();
+ public function get_providers( WP_REST_Request $request ): WP_REST_Response {
+ $providers = \Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils::get_shipping_providers_object();
+
+ /**
+ * Filters the shipping providers response before it is returned.
+ *
+ * Each provider in the array must have the following structure:
+ * - 'label' (string): The display name of the provider.
+ * - 'icon' (string): URL to the provider's icon.
+ * - 'value' (string): The provider's unique identifier.
+ * - 'url' (string): The tracking URL template.
+ *
+ * @param array $providers The shipping providers data.
+ * @param WP_REST_Request $request The request object.
+ *
+ * @since 10.5.0
+ */
+ $providers = apply_filters( 'woocommerce_rest_prepare_fulfillments_providers', $providers, $request );
+
+ // Validate filtered result to prevent extensions from returning invalid structures.
+ if ( ! is_array( $providers ) ) {
+ _doing_it_wrong(
+ 'woocommerce_rest_prepare_fulfillments_providers',
+ esc_html__( 'The filter must return an array of providers.', 'woocommerce' ),
+ '10.5.0'
+ );
+ $providers = array();
+ } else {
+ $providers = $this->validate_providers_structure( $providers );
+ }
- return $schema;
+ return new WP_REST_Response( $providers, WP_Http::OK );
}
/**
- * Get the arguments for the update fulfillment endpoint.
+ * Validate the structure of providers returned by a filter.
*
- * @return array
- */
- private function get_args_for_update_fulfillment(): array {
- return $this->get_write_args_for_fulfillment( false );
- }
-
- /**
- * Get the schema for the update fulfillment endpoint.
+ * Removes any providers that don't have the required keys (label, icon, value, url).
*
- * @return array
+ * @since 10.5.0
+ * @param array $providers The providers array to validate.
+ * @return array The validated providers array with invalid entries removed.
*/
- private function get_schema_for_update_fulfillment(): array {
- $schema = array(
- '$schema' => 'http://json-schema.org/draft-04/schema#',
- 'title' => 'base',
- 'type' => 'object',
- );
- $schema['title'] = __( 'Update fulfillment response.', 'woocommerce' );
- $schema['type'] = 'object';
- $schema['properties'] = $this->get_read_schema_for_fulfillment();
+ private function validate_providers_structure( array $providers ): array {
+ $required_keys = array( 'label', 'icon', 'value', 'url' );
+ $valid_providers = array();
+ $has_invalid = false;
+
+ foreach ( $providers as $key => $provider ) {
+ if ( ! is_array( $provider ) ) {
+ $has_invalid = true;
+ continue;
+ }
- return $schema;
- }
+ $missing_keys = array_diff( $required_keys, array_keys( $provider ) );
+ if ( ! empty( $missing_keys ) ) {
+ $has_invalid = true;
+ continue;
+ }
- /**
- * Get the arguments for the delete fulfillment endpoint.
- *
- * @return array
- */
- private function get_args_for_delete_fulfillment(): array {
- return array(
- 'fulfillment_id' => array(
- 'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
- 'type' => 'integer',
- 'required' => true,
- 'context' => array( 'view', 'edit' ),
- ),
- 'notify_customer' => array(
- 'description' => __( 'Whether to notify the customer about the fulfillment update.', 'woocommerce' ),
- 'type' => 'boolean',
- 'default' => false,
- 'required' => false,
- 'context' => array( 'view', 'edit' ),
- ),
- );
- }
+ $valid_providers[ $key ] = $provider;
+ }
- /**
- * Get the schema for the delete fulfillment endpoint.
- *
- * @return array
- */
- private function get_schema_for_delete_fulfillment(): array {
- $schema = array(
- '$schema' => 'http://json-schema.org/draft-04/schema#',
- 'title' => 'base',
- 'type' => 'object',
- );
- $schema['title'] = __( 'Delete fulfillment response.', 'woocommerce' );
- $schema['properties'] = array(
- 'message' => array(
- 'description' => __( 'The response message.', 'woocommerce' ),
- 'type' => 'string',
- 'required' => true,
- ),
- );
+ if ( $has_invalid ) {
+ _doing_it_wrong(
+ 'woocommerce_rest_prepare_fulfillments_providers',
+ esc_html__( 'Some providers were removed because they are missing required keys (label, icon, value, url).', 'woocommerce' ),
+ '10.5.0'
+ );
+ }
- return $schema;
+ return $valid_providers;
}
/**
- * Get the base schema for the fulfillment with a read context.
+ * Check permissions for accessing shipping providers.
*
- * @return array
+ * @since 10.5.0
+ * @param WP_REST_Request $request Full details about the request.
+ * @return bool|WP_Error True if the current user has the capability, otherwise a WP_Error.
*/
- private function get_read_schema_for_fulfillment() {
- return array(
- 'id' => array(
- 'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
- 'type' => 'integer',
- 'context' => array( 'view', 'edit' ),
- 'readonly' => true,
- ),
- 'entity_type' => array(
- 'description' => __( 'The type of entity for which the fulfillment is created.', 'woocommerce' ),
- 'type' => 'string',
- 'required' => true,
- 'context' => array( 'view', 'edit' ),
- ),
- 'entity_id' => array(
- 'description' => __( 'Unique identifier for the entity.', 'woocommerce' ),
- 'type' => 'string',
- 'required' => true,
- 'context' => array( 'view', 'edit' ),
- ),
- 'status' => array(
- 'description' => __( 'The status of the fulfillment.', 'woocommerce' ),
- 'type' => 'string',
- 'default' => 'unfulfilled',
- 'required' => true,
- 'context' => array( 'view', 'edit' ),
- ),
- 'is_fulfilled' => array(
- 'description' => __( 'Whether the fulfillment is fulfilled.', 'woocommerce' ),
- 'type' => 'boolean',
- 'default' => false,
- 'required' => true,
- 'context' => array( 'view', 'edit' ),
- ),
- 'date_updated' => array(
- 'description' => __( 'The date the fulfillment was last updated.', 'woocommerce' ),
- 'type' => 'string',
- 'context' => array( 'view', 'edit' ),
- 'readonly' => true,
- 'required' => true,
- ),
- 'date_deleted' => array(
- 'description' => __( 'The date the fulfillment was deleted.', 'woocommerce' ),
- 'anyOf' => array(
- array(
- 'type' => 'string',
- ),
- array(
- 'type' => 'null',
- ),
- ),
- 'default' => null,
- 'context' => array( 'view', 'edit' ),
- 'readonly' => true,
- 'required' => true,
- ),
- 'meta_data' => array(
- 'description' => __( 'Meta data for the fulfillment.', 'woocommerce' ),
- 'type' => 'array',
- 'required' => true,
- 'items' => $this->get_schema_for_meta_data(),
- ),
- );
- }
+ public function check_permission_for_providers( WP_REST_Request $request ) {
+ if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ return $this->get_authentication_error_by_method( $request->get_method() );
+ }
- /**
- * Get the base args for the fulfillment with a write context.
- *
- * @param bool $is_create Whether the args list is for a create request.
- *
- * @return array
- */
- private function get_write_args_for_fulfillment( bool $is_create = false ) {
- return array_merge(
- ! $is_create ? array(
- 'fulfillment_id' => array(
- 'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
- 'type' => 'integer',
- 'context' => array( 'view', 'edit' ),
- 'readonly' => true,
- ),
- ) : array(),
- array(
- 'entity_type' => array(
- 'description' => __( 'The type of entity for which the fulfillment is created. Must be "order".', 'woocommerce' ),
- 'type' => 'string',
- 'required' => true,
- 'context' => array( 'view', 'edit' ),
- ),
- 'entity_id' => array(
- 'description' => __( 'Unique identifier for the entity.', 'woocommerce' ),
- 'type' => 'string',
- 'required' => true,
- 'context' => array( 'view', 'edit' ),
- ),
- 'status' => array(
- 'description' => __( 'The status of the fulfillment.', 'woocommerce' ),
- 'type' => 'string',
- 'default' => 'unfulfilled',
- 'required' => false,
- 'context' => array( 'view', 'edit' ),
- ),
- 'is_fulfilled' => array(
- 'description' => __( 'Whether the fulfillment is fulfilled.', 'woocommerce' ),
- 'type' => 'boolean',
- 'default' => false,
- 'required' => false,
- 'context' => array( 'view', 'edit' ),
- ),
- 'meta_data' => array(
- 'description' => __( 'Meta data for the fulfillment.', 'woocommerce' ),
- 'type' => 'array',
- 'required' => true,
- 'schema' => $this->get_schema_for_meta_data(),
- ),
- 'notify_customer' => array(
- 'description' => __( 'Whether to notify the customer about the fulfillment update.', 'woocommerce' ),
- 'type' => 'boolean',
- 'default' => false,
- 'required' => false,
- 'context' => array( 'view', 'edit' ),
- ),
- )
- );
+ return true;
}
/**
- * Get the schema for the meta data.
+ * Get the schema for the providers endpoint.
*
- * @return array
+ * @since 10.5.0
+ * @return array The schema for the providers endpoint.
*/
- private function get_schema_for_meta_data(): array {
+ public function get_schema_for_providers(): array {
return array(
- 'type' => 'object',
- 'properties' => array(
- 'id' => array(
- 'description' => __( 'The unique identifier for the meta data. Set `0` for new records.', 'woocommerce' ),
- 'type' => 'integer',
- 'context' => array( 'view', 'edit' ),
- 'readonly' => true,
- ),
- 'key' => array(
- 'description' => __( 'The key of the meta data.', 'woocommerce' ),
- 'type' => 'string',
- 'required' => true,
- 'context' => array( 'view', 'edit' ),
- ),
- 'value' => array(
- 'description' => __( 'The value of the meta data.', 'woocommerce' ),
- 'type' => 'string',
- 'required' => true,
- 'context' => array( 'view', 'edit' ),
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => __( 'Shipping providers', 'woocommerce' ),
+ 'type' => 'object',
+ 'additionalProperties' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'label' => array(
+ 'description' => __( 'The display name of the shipping provider.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'icon' => array(
+ 'description' => __( 'The icon URL for the shipping provider.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'value' => array(
+ 'description' => __( 'The unique key for the shipping provider.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'url' => array(
+ 'description' => __( 'The tracking URL template for the shipping provider.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
),
),
- 'required' => true,
- 'context' => array( 'view', 'edit' ),
- 'readonly' => true,
- );
- }
-
- /**
- * Prepare an error response.
- *
- * @param string $code The error code.
- * @param string $message The error message.
- * @param array $data Additional error data, including 'status' key for HTTP status code.
- *
- * @return WP_REST_Response The error response.
- */
- private function prepare_error_response( $code, $message, $data ): WP_REST_Response {
- return new WP_REST_Response(
- array(
- 'code' => $code,
- 'message' => $message,
- 'data' => $data,
- ),
- $data['status'] ?? WP_Http::BAD_REQUEST
);
}
}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Schema/FulfillmentSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Schema/FulfillmentSchema.php
new file mode 100644
index 0000000000..534feb13f1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Schema/FulfillmentSchema.php
@@ -0,0 +1,150 @@
+<?php
+/**
+ * FulfillmentSchema class.
+ *
+ * @package WooCommerce\RestApi
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Fulfillments\Schema;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\Fulfillments\Fulfillment;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
+use WP_REST_Request;
+
+/**
+ * FulfillmentSchema class.
+ */
+class FulfillmentSchema extends AbstractSchema {
+ /**
+ * The schema item identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'fulfillment';
+
+ /**
+ * Return all properties for the item schema.
+ *
+ * Note that context determines under which context data should be visible. For example, edit would be the context
+ * used when getting records with the intent of editing them. embed context allows the data to be visible when the
+ * item is being embedded in another response.
+ *
+ * @return array
+ */
+ public function get_item_schema_properties(): array {
+ return array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => self::VIEW_EDIT_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'entity_type' => array(
+ 'description' => __( 'The type of entity for which the fulfillment is created.', 'woocommerce' ),
+ 'type' => 'string',
+ 'required' => true,
+ 'context' => self::VIEW_EDIT_CONTEXT,
+ ),
+ 'entity_id' => array(
+ 'description' => __( 'Unique identifier for the entity.', 'woocommerce' ),
+ 'type' => 'string',
+ 'required' => true,
+ 'context' => self::VIEW_EDIT_CONTEXT,
+ ),
+ 'status' => array(
+ 'description' => __( 'The status of the fulfillment.', 'woocommerce' ),
+ 'type' => 'string',
+ 'default' => 'unfulfilled',
+ 'required' => true,
+ 'context' => self::VIEW_EDIT_CONTEXT,
+ ),
+ 'is_fulfilled' => array(
+ 'description' => __( 'Whether the fulfillment is fulfilled.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'default' => false,
+ 'required' => true,
+ 'context' => self::VIEW_EDIT_CONTEXT,
+ ),
+ 'date_updated' => array(
+ 'description' => __( 'The date the fulfillment was last updated.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_CONTEXT,
+ 'readonly' => true,
+ 'required' => true,
+ ),
+ 'date_deleted' => array(
+ 'description' => __( 'The date the fulfillment was deleted.', 'woocommerce' ),
+ 'anyOf' => array(
+ array(
+ 'type' => 'string',
+ ),
+ array(
+ 'type' => 'null',
+ ),
+ ),
+ 'default' => null,
+ 'context' => self::VIEW_EDIT_CONTEXT,
+ 'readonly' => true,
+ 'required' => true,
+ ),
+ 'meta_data' => array(
+ 'description' => __( 'Meta data for the fulfillment.', 'woocommerce' ),
+ 'type' => 'array',
+ 'required' => true,
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'The unique identifier for the meta data. Set `0` for new records.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => self::VIEW_EDIT_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'key' => array(
+ 'description' => __( 'The key of the meta data.', 'woocommerce' ),
+ 'type' => 'string',
+ 'required' => true,
+ 'context' => self::VIEW_EDIT_CONTEXT,
+ ),
+ 'value' => array(
+ 'description' => __( 'The value of the meta data.', 'woocommerce' ),
+ 'type' => array( 'string', 'number', 'boolean', 'object', 'array', 'null' ),
+ 'required' => true,
+ 'context' => self::VIEW_EDIT_CONTEXT,
+ ),
+ ),
+ 'required' => true,
+ 'context' => self::VIEW_EDIT_CONTEXT,
+ 'readonly' => true,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Get the item response.
+ *
+ * @param Fulfillment $fulfillment Fulfillment object.
+ * @param WP_REST_Request $request Request object.
+ * @param array $include_fields Fields to include in the response.
+ * @return array The item response.
+ */
+ public function get_item_response( $fulfillment, WP_REST_Request $request, array $include_fields = array() ): array {
+ $date_deleted = $fulfillment->get_date_deleted();
+
+ return array(
+ 'id' => $fulfillment->get_id(),
+ 'entity_type' => $fulfillment->get_entity_type(),
+ 'entity_id' => (string) $fulfillment->get_entity_id(),
+ 'status' => $fulfillment->get_status(),
+ 'is_fulfilled' => $fulfillment->get_is_fulfilled(),
+ 'date_updated' => wc_rest_prepare_date_response( $fulfillment->get_date_updated() ),
+ 'date_deleted' => $date_deleted ? wc_rest_prepare_date_response( $date_deleted ) : null,
+ 'meta_data' => $fulfillment->get_meta_data(),
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php
index 7dbf7ee9f4..c5e7d928a3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Tests\Internal\RestApi\Routes\V4\Fulfillments;
use Automattic\WooCommerce\Internal\Fulfillments\Fulfillment;
+use Automattic\WooCommerce\Internal\Fulfillments\OrderFulfillmentsRestController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Fulfillments\Controller as FulfillmentsController;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Fulfillments\Schema\FulfillmentSchema;
use Automattic\WooCommerce\Tests\Internal\Fulfillments\Helpers\FulfillmentsHelper;
use WC_REST_Unit_Test_Case;
use WC_Helper_Order;
@@ -77,6 +79,7 @@ class ControllerTest extends WC_REST_Unit_Test_Case {
parent::setUp();
$this->controller = new FulfillmentsController();
+ $this->controller->init( new FulfillmentSchema(), new OrderFulfillmentsRestController() );
$this->controller->register_routes();
$this->admin_user_id = $this->factory->user->create(
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ProvidersTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ProvidersTest.php
new file mode 100644
index 0000000000..265f3b1a5c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ProvidersTest.php
@@ -0,0 +1,244 @@
+<?php
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\RestApi\Routes\V4\Fulfillments;
+
+use Automattic\WooCommerce\Internal\Fulfillments\OrderFulfillmentsRestController;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Fulfillments\Controller as FulfillmentsController;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Fulfillments\Schema\FulfillmentSchema;
+use WC_REST_Unit_Test_Case;
+use WP_REST_Request;
+
+/**
+ * Fulfillments Providers Controller test class
+ */
+class ProvidersTest extends WC_REST_Unit_Test_Case {
+
+ /**
+ * Controller instance
+ *
+ * @var FulfillmentsController
+ */
+ private FulfillmentsController $controller;
+
+ /**
+ * Admin user for tests
+ *
+ * @var int
+ */
+ private int $admin_user_id;
+
+ /**
+ * Shop manager user for tests
+ *
+ * @var int
+ */
+ private int $shop_manager_user_id;
+
+ /**
+ * Customer user for tests
+ *
+ * @var int
+ */
+ private int $customer_user_id;
+
+ /**
+ * Set up the test environment.
+ */
+ public static function setUpBeforeClass(): void {
+ parent::setUpBeforeClass();
+ update_option( 'woocommerce_feature_fulfillments_enabled', 'yes' );
+ $controller = wc_get_container()->get( \Automattic\WooCommerce\Internal\Fulfillments\FulfillmentsController::class );
+ $controller->register();
+ $controller->initialize_fulfillments();
+ }
+
+ /**
+ * Tear down the test environment.
+ */
+ public static function tearDownAfterClass(): void {
+ update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+ parent::tearDownAfterClass();
+ }
+
+ /**
+ * Setup test environment
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->controller = new FulfillmentsController();
+ $this->controller->init( new FulfillmentSchema(), new OrderFulfillmentsRestController() );
+ $this->controller->register_routes();
+
+ $this->admin_user_id = $this->factory->user->create(
+ array(
+ 'role' => 'administrator',
+ )
+ );
+
+ $this->shop_manager_user_id = $this->factory->user->create(
+ array(
+ 'role' => 'shop_manager',
+ )
+ );
+
+ $this->customer_user_id = $this->factory->user->create(
+ array(
+ 'role' => 'customer',
+ )
+ );
+ }
+
+ /**
+ * Teardown test environment
+ */
+ public function tearDown(): void {
+ // Delete the created users.
+ wp_delete_user( $this->admin_user_id );
+ wp_delete_user( $this->shop_manager_user_id );
+ wp_delete_user( $this->customer_user_id );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test route registration
+ */
+ public function test_register_routes() {
+ $routes = rest_get_server()->get_routes();
+
+ $this->assertArrayHasKey( '/wc/v4/fulfillments/providers', $routes );
+ }
+
+ /**
+ * Test get_providers endpoint success
+ */
+ public function test_get_providers_success() {
+ wp_set_current_user( $this->admin_user_id );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/fulfillments/providers' );
+
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+ }
+
+ /**
+ * Test get_providers contains expected providers
+ */
+ public function test_get_providers_contains_expected_providers() {
+ wp_set_current_user( $this->admin_user_id );
+
+ // Add a test provider using the filter.
+ $test_provider = function () {
+ return array( 'TestProvider' );
+ };
+ add_filter( 'woocommerce_fulfillment_shipping_providers', $test_provider );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/fulfillments/providers' );
+
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+
+ // Remove the filter.
+ remove_filter( 'woocommerce_fulfillment_shipping_providers', $test_provider );
+
+ $this->assertIsArray( $data );
+ // Since we added a non-existent class, it should be empty.
+ $this->assertEmpty( $data );
+ }
+
+ /**
+ * Test permission check - admin user
+ */
+ public function test_permission_check_admin() {
+ wp_set_current_user( $this->admin_user_id );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/fulfillments/providers' );
+
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+ }
+
+ /**
+ * Test permission check - shop manager user
+ */
+ public function test_permission_check_shop_manager() {
+ wp_set_current_user( $this->shop_manager_user_id );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/fulfillments/providers' );
+
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+ }
+
+ /**
+ * Test permission check - customer user
+ */
+ public function test_permission_check_customer() {
+ wp_set_current_user( $this->customer_user_id );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/fulfillments/providers' );
+
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertEquals( 403, $response->get_status() );
+ }
+
+ /**
+ * Test permission check - unauthenticated user
+ */
+ public function test_permission_check_unauthenticated() {
+ wp_set_current_user( 0 );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/fulfillments/providers' );
+
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertEquals( 401, $response->get_status() );
+ }
+
+ /**
+ * Test get_providers with feature disabled
+ */
+ public function test_get_providers_with_feature_disabled() {
+ // Disable the fulfillments feature.
+ update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+
+ wp_set_current_user( $this->admin_user_id );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/fulfillments/providers' );
+
+ $response = rest_get_server()->dispatch( $request );
+
+ // Re-enable the fulfillments feature.
+ update_option( 'woocommerce_feature_fulfillments_enabled', 'yes' );
+
+ // The route should still exist and be accessible if the controller is already registered.
+ // This test verifies the route exists even when feature is disabled.
+ $this->assertEquals( 200, $response->get_status() );
+ }
+
+ /**
+ * Test response format validation
+ */
+ public function test_response_format_validation() {
+ wp_set_current_user( $this->admin_user_id );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/fulfillments/providers' );
+
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertIsArray( $data );
+
+ // Each provider should have the expected structure if any exist.
+ foreach ( $data as $key => $provider ) {
+ $this->assertIsString( $key );
+ $this->assertIsArray( $provider );
+ $this->assertArrayHasKey( 'label', $provider );
+ $this->assertArrayHasKey( 'icon', $provider );
+ $this->assertArrayHasKey( 'value', $provider );
+ $this->assertArrayHasKey( 'url', $provider );
+ }
+ }
+}