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 );
+		}
+	}
+}