<?php
namespace Automattic\WCShipping\LabelPurchase;
use Automattic\WCShipping\Connect\WC_Connect_API_Client;
use Automattic\WCShipping\Connect\WC_Connect_Logger;
use Automattic\WCShipping\Connect\WC_Connect_Service_Settings_Store;
use Automattic\WCShipping\FeatureFlags\FeatureFlags;
use Automattic\WCShipping\Fulfillments\FulfillmentsService;
use Automattic\WCShipping\Fulfillments\ShippingFulfillment;
use Automattic\WCShipping\Fulfillments\ShippingFulfillmentsDataStore;
use Automattic\WCShipping\Utils;
use Automattic\WCShipping\WCShippingRESTController;
use WP_Error;
use WP_REST_Server;
class LabelPrintController extends WCShippingRESTController {
protected $rest_base = 'label/print';
/**
* @var WC_Connect_Service_Settings_Store
*/
protected $settings_store;
/**
* @var WC_Connect_API_Client
*/
protected $api_client;
/**
* @var WC_Connect_Logger
*/
protected $logger;
/**
* @var WC_Label_Print_Service
*/
protected $label_print_service;
/**
* Fulfillment data store used to verify bulk print requests before forwarding label IDs.
*
* @var ShippingFulfillmentsDataStore
*/
protected $shipping_fulfillments_data_store;
/**
* Fulfillment service used to resolve fulfillment entities.
*
* @var FulfillmentsService
*/
protected $fulfillments_service;
public function __construct(
WC_Connect_Service_Settings_Store $settings_store,
WC_Connect_API_Client $api_client,
WC_Connect_Logger $logger,
LabelPrintService $label_print_service,
ShippingFulfillmentsDataStore $shipping_fulfillments_data_store,
?FulfillmentsService $fulfillments_service = null
) {
$this->settings_store = $settings_store;
$this->api_client = $api_client;
$this->logger = $logger;
$this->label_print_service = $label_print_service;
$this->shipping_fulfillments_data_store = $shipping_fulfillments_data_store;
$this->fulfillments_service = $fulfillments_service ?? new FulfillmentsService( $shipping_fulfillments_data_store );
}
/**
* Register API routes.
*
* @return void
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'print_label' ),
'permission_callback' => array( $this, 'ensure_rest_permission' ),
),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/packing-list/(?P<label_id>\d+)/(?P<order_id>\d+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_packing_list' ),
'permission_callback' => array( $this, 'ensure_rest_permission' ),
'args' => array(
'label_id' => array(
'type' => 'integer',
'required' => true,
),
'order_id' => array(
'type' => 'integer',
'required' => true,
),
),
),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/packing-slip',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_packing_list_without_label' ),
'permission_callback' => array( $this, 'ensure_rest_permission' ),
'args' => array(
'order_id' => array(
'type' => 'integer',
'required' => true,
'validate_callback' => function ( $param ) {
return is_numeric( $param );
},
),
'format' => array(
'type' => 'string',
'required' => false,
'default' => 'html',
'enum' => array( 'html', 'pdf' ),
'validate_callback' => function ( $param ) {
return in_array( $param, array( 'html', 'pdf' ), true );
},
),
),
),
)
);
}
public function print_label( $request ) {
list( $label_id_csv, $paper_size ) = $this->get_and_check_request_params( $request, array( 'label_id_csv', 'paper_size' ) );
$fulfillment_id_csv = $request->get_param( 'fulfillment_id_csv' );
if ( ! $label_id_csv ) {
return $this->invalid_pdf_request_error();
}
if ( null !== $fulfillment_id_csv ) {
if (
! is_scalar( $fulfillment_id_csv ) ||
'' === (string) $fulfillment_id_csv
) {
return $this->invalid_pdf_request_error();
}
$labels = $this->parse_fulfillment_label_ids( $label_id_csv, $fulfillment_id_csv );
} else {
$labels = $this->parse_label_ids( $label_id_csv );
}
if ( is_wp_error( $labels ) ) {
return $labels;
}
if ( empty( $labels ) ) {
return $this->invalid_pdf_request_error();
}
$request_params = array(
'paper_size' => $paper_size,
'labels' => $labels,
);
$raw_response = $this->api_client->get_labels_print_pdf( $request_params );
if ( is_wp_error( $raw_response ) ) {
$this->logger->log( $raw_response, __CLASS__ );
return $raw_response;
}
return array(
'mimeType' => $raw_response['headers']['content-type'],
'b64Content' => base64_encode( $raw_response['body'] ),
'success' => true,
);
}
/**
* Build the `labels` payload from the raw `label_id_csv` parameter.
*
* Current behavior (flag off) forwards a single label to preserve existing mobile and web
* single-label callers. When `bulk_labels` is enabled, the CSV is parsed into multiple
* labels so a merged PDF can be produced server-side.
*
* The route does not declare a schema for `label_id_csv`, so `get_param` may return a
* non-string (for example an array from `label_id_csv[]=1&label_id_csv[]=2`). Non-scalar
* inputs are treated as invalid and return an empty payload, which the caller turns into
* a standard `invalid_pdf_request` error rather than a 500.
*
* @param mixed $label_id_csv Raw value from the `label_id_csv` query parameter.
* @return array[] Array of `['label_id' => int]` entries for the Connect Server `labels` payload.
*/
private function parse_label_ids( $label_id_csv ): array {
if ( ! is_scalar( $label_id_csv ) ) {
return array();
}
$label_id_csv = (string) $label_id_csv;
if ( ! FeatureFlags::is_bulk_labels_enabled() ) {
return array( array( 'label_id' => (int) $label_id_csv ) );
}
$ids = array_map( 'intval', array_map( 'trim', explode( ',', $label_id_csv ) ) );
$ids = array_values( array_filter( $ids, static fn ( int $id ) => $id > 0 ) );
return array_map( static fn ( int $id ) => array( 'label_id' => $id ), $ids );
}
/**
* Resolve a bulk print request through the fulfillment entity before forwarding label IDs.
*
* The Connect Server PDF endpoint still accepts label IDs, but bulk purchases are stored on
* fulfillment records. The paired `fulfillment_id_csv` makes the plugin prove every requested
* label belongs to the expected fulfillment entity before the remote print request is made.
*
* @param mixed $label_id_csv Raw CSV value from the `label_id_csv` query parameter.
* @param mixed $fulfillment_id_csv Raw CSV value from the `fulfillment_id_csv` query parameter.
* @return array[]|WP_Error Array of `['label_id' => int]` entries or an error.
*/
private function parse_fulfillment_label_ids( $label_id_csv, $fulfillment_id_csv ) {
if ( ! FeatureFlags::is_bulk_labels_enabled() ) {
return $this->invalid_pdf_request_error();
}
if ( ! Utils::should_use_fulfillment_api() ) {
return $this->fulfillment_api_required_error();
}
$label_ids = $this->parse_positive_int_csv_strict( $label_id_csv );
$fulfillment_ids = $this->parse_positive_int_csv_strict( $fulfillment_id_csv );
if (
! is_array( $label_ids ) ||
! is_array( $fulfillment_ids ) ||
empty( $label_ids ) ||
count( $label_ids ) !== count( $fulfillment_ids )
) {
return $this->invalid_pdf_request_error();
}
$labels = array();
foreach ( $label_ids as $index => $label_id ) {
$fulfillment = $this->shipping_fulfillments_data_store->get_by_label_id( (string) $label_id );
if ( ! $fulfillment instanceof ShippingFulfillment || (int) $fulfillment->get_id() !== (int) $fulfillment_ids[ $index ] ) {
$fulfillment = $this->fulfillments_service->get_fulfillment_by_id( (int) $fulfillment_ids[ $index ] );
}
if (
! $fulfillment instanceof ShippingFulfillment ||
(int) $fulfillment->get_id() !== (int) $fulfillment_ids[ $index ] ||
! $this->fulfillment_contains_label( $fulfillment, $label_id )
) {
return $this->invalid_pdf_request_error();
}
$labels[] = array( 'label_id' => $label_id );
}
return $labels;
}
/**
* Parse a comma-separated list of positive integers.
*
* Unlike `parse_label_ids`, this is strict because label IDs and fulfillment IDs are paired
* positionally. Dropping a bad token could silently pair a label with the wrong fulfillment.
*
* @param mixed $csv Raw CSV parameter.
* @return int[]|null Parsed IDs, or null when the CSV is malformed.
*/
private function parse_positive_int_csv_strict( $csv ): ?array {
if ( ! is_scalar( $csv ) ) {
return null;
}
$tokens = array_map( 'trim', explode( ',', (string) $csv ) );
if ( empty( $tokens ) ) {
return null;
}
$ids = array();
foreach ( $tokens as $token ) {
if ( '' === $token || ! ctype_digit( $token ) ) {
return null;
}
$id = (int) $token;
if ( $id <= 0 ) {
return null;
}
$ids[] = $id;
}
return $ids;
}
/**
* Check that the resolved fulfillment still contains the requested label.
*
* @param ShippingFulfillment $fulfillment Fulfillment entity resolved by label ID.
* @param int $label_id Requested label ID.
* @return bool True when the label is stored on the fulfillment entity.
*/
private function fulfillment_contains_label( ShippingFulfillment $fulfillment, int $label_id ): bool {
foreach ( $fulfillment->get_labels() as $label ) {
if ( isset( $label['label_id'] ) && (int) $label['label_id'] === $label_id ) {
return true;
}
}
return false;
}
/**
* Build and log the standard invalid-PDF-request error.
*
* @return WP_Error
*/
private function invalid_pdf_request_error(): WP_Error {
$message = __( 'Invalid PDF request.', 'woocommerce-shipping' );
$error = new WP_Error(
'invalid_pdf_request',
$message,
array(
'message' => $message,
'status' => 400,
)
);
$this->logger->log( $error, __CLASS__ );
return $error;
}
/**
* Build and log the fulfillment-API-required error.
*
* @return WP_Error
*/
private function fulfillment_api_required_error(): WP_Error {
$message = __( 'Bulk label printing requires the fulfillment API.', 'woocommerce-shipping' );
$error = new WP_Error(
'fulfillment_api_required',
$message,
array(
'message' => $message,
'status' => 400,
)
);
$this->logger->log( $error, __CLASS__ );
return $error;
}
/**
* Generate packing list for a specific label.
*
* @param WP_REST_Request $request REST request object.
* @return WP_REST_Response|WP_Error REST response or error.
*/
public function get_packing_list( \WP_REST_Request $request ) {
try {
list( $order_id, $label_id ) = $this->get_and_check_request_params( $request, array( 'order_id', 'label_id' ) );
} catch ( \RESTRequestException $error ) {
return rest_ensure_response( $error->get_error_response() );
}
return rest_ensure_response( $this->label_print_service->get_packing_list( $order_id, $label_id ) );
}
/**
* Generate packing list without requiring a shipping label.
*
* @param WP_REST_Request $request REST request object.
* @return WP_REST_Response|WP_Error REST response or error.
*/
public function get_packing_list_without_label( \WP_REST_Request $request ) {
try {
list( $order_id ) = $this->get_and_check_request_params( $request, array( 'order_id' ) );
} catch ( \RESTRequestException $error ) {
return rest_ensure_response( $error->get_error_response() );
}
$format = $request->get_param( 'format' ) ?? 'html';
$this->logger->log( "Packing slip request - Order ID: $order_id, Format: $format", __CLASS__ );
return rest_ensure_response( $this->label_print_service->get_packing_list_without_label( $order_id, $format ) );
}
}