Cartonization 1.0.0

Calling the API

Your backend calls the install's JSON API over HTTP, server-to-server. This page shows the trigger flow from the backend's side, how to handle errors, and complete clients for Java, TypeScript and C#. Every field is described in the Reference.

Base URL and network#

Method Path Purpose
POST /api/pack Pack items into containers with one, several or all algorithms
POST /api/capacity How many of one item fit in one container, and how many containers N units need (details)
POST /api/pack/multi-level Items into cartons into pallets (into trailers) in one request (details)
GET /api/algorithms The available algorithm ids, names and descriptions
GET /health {"status":"ok"}

Quick start#

One request, with curl:

bash
curl -s -X POST http://127.0.0.1:10081/api/pack -H 'Content-Type: application/json' -d '{"containers":[{"id":"BOX-M","length":30,"width":25,"height":20,"maxWeight":20}],"items":[{"id":"MUG","length":12,"width":9,"height":10,"weight":0.4,"quantity":4,"rotation":"upright"}],"includeAllSolutions":true}'

The response has the winning solution in best (its containers, with each unit's position, and the units left out), the ranking of every algorithm that ran, and (because of includeAllSolutions) every algorithm's full solution in solutions.

The trigger flow, from your backend#

When the user clicks your button, your frontend calls an endpoint of your backend, for example POST /api/cartonize with { orderId, lines: [{ sku, quantity }] }. Your backend then:

  1. Validates the call. Unknown SKUs, missing lines and bad quantities are the user's input: answer 400 with a message the frontend can show. Merge duplicate SKUs, because item ids must be unique in a pack request.
  2. Builds the pack request. Items come from the order lines and your item master (dimensions, weight, rotation, stackable, hazmatClass, ...). Containers come from your box master. Use one consistent unit for every dimension (for example cm) and one for every weight (for example kg).
  3. Calls POST /api/pack with includeAllSolutions: true when the page shows the ranking: without it, ranking rows can't open the other algorithms' solutions (the ranking shows a hint instead).
  4. Reads what it needs, for example to log the result or save the chosen boxes: best.containers (the boxes, each with its placements), best.unpacked (units left out, each with a reason) and best.metrics.
  5. Returns the response body unchanged to the frontend, which sets it on the components.

This is examples/backend-node/server.mjs (Node, no dependencies). First the validation and the mapping:

js
/**
 * Checks the body `{ orderId, lines: [{ sku, quantity }] }` and returns the lines with duplicate SKUs merged.
 * Throws BadRequest with a message the frontend can show.
 */
function parseOrder(body) {
    if (typeof body !== 'object' || body === null || Array.isArray(body)) {
        throw new BadRequest('The body must be a JSON object: { "orderId": "...", "lines": [{ "sku": "...", "quantity": 1 }] }');
    }
    if (typeof body.orderId !== 'string' || body.orderId.trim() === '') {
        throw new BadRequest('orderId must be a non-empty string');
    }
    if (!Array.isArray(body.lines) || body.lines.length === 0) {
        throw new BadRequest('lines must be a non-empty array of { sku, quantity }');
    }
    const quantities = new Map();
    body.lines.forEach((line, i) => {
        if (typeof line?.sku !== 'string' || line.sku.trim() === '') {
            throw new BadRequest(`lines[${i}].sku must be a non-empty string`);
        }
        if (!Number.isInteger(line.quantity) || line.quantity < 1 || line.quantity > MAX_LINE_QUANTITY) {
            throw new BadRequest(`lines[${i}].quantity must be an integer from 1 to ${MAX_LINE_QUANTITY}`);
        }
        const sku = line.sku.trim();
        quantities.set(sku, (quantities.get(sku) ?? 0) + line.quantity);
    });
    const unknown = [...quantities.keys()].filter(sku => !Object.hasOwn(PRODUCTS, sku));
    if (unknown.length > 0) {
        throw new BadRequest(`Unknown SKU: ${unknown.join(', ')}`);
    }
    const units = [...quantities.values()].reduce((sum, q) => sum + q, 0);
    if (units > MAX_UNITS) {
        throw new BadRequest(`Too many units: ${units} (max ${MAX_UNITS} per order)`);
    }
    return [...quantities].map(([sku, quantity]) => ({ sku, quantity }));
}
js
/** Maps order lines to a POST /api/pack request: the box master becomes containers, the lines become items. */
function packRequestFor(lines) {
    return {
        containers: BOX_TYPES,
        items: lines.map(({ sku, quantity }) => ({ id: sku, quantity, ...PRODUCTS[sku] })),
        algorithm: 'best',
        // Every algorithm's solution, so the ranking rows in the frontend can open them.
        includeAllSolutions: true,
        options: { timeLimitMs: 300 },
    };
}

Then the call. The response body goes back as it was received:

js
try {
    const upstream = await fetch(`${CARTONIZATION_URL}/api/pack`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(packRequestFor(lines)),
        signal: AbortSignal.timeout(60_000),
    });
    const body = await upstream.text();
    if (!upstream.ok) {
        // 400: the request we built is invalid (a mapping bug); 503: the solver is busy or timed out, retry later.
        console.error(`POST /api/pack returned ${upstream.status}: ${body}`);
        send(res, upstream.status === 503 ? 503 : 502, { error: `Cartonization service returned ${upstream.status}` }, cors);
        return;
    }
    // Pass the pack response through unchanged: the components take it as it is.
    send(res, 200, body, cors);
}
catch (err) {
    console.error(`Cartonization service unreachable at ${CARTONIZATION_URL}: ${err.message}`);
    send(res, 502, { error: 'Cartonization service unreachable' }, cors);
}

The same backend in Java 17 with only the JDK is examples/backend-java/Backend.java. Below are clients you can copy into your own backend.

Returning the response to your frontend#

The install answered Your backend answers Why
200 200 with the body unchanged
400 502 (and log the issues) The request your backend built is invalid: a bug in your mapping, not the user's input
503 503 (pass Retry-After on when present) Busy or over its time budget; the user can try again
No answer, timeout 502 or 504

Errors, retries and timeouts#

Status When Body What to do
400 Invalid input: schema violations, an unknown algorithm, too many units or container types { "error": "Invalid request", "issues": [...] }, or { "error": "<message>" } Fix the request; issues[].path points at the field
402 The install has no valid license: none set, not valid, or expired { "error": "<message>", "code": "license_required" | "license_expired" | "product_not_licensed" } Don't retry; tell the install's operator. The message says what to do (License)
503 The solver is busy (more requests than SOLVER_MAX_CONCURRENT) or a request ran past SOLVER_TIMEOUT_MS { "error": "<message>" } Retry later with backoff, or lower totalTimeLimitMs
500 Unexpected server error { "error": "Internal server error" } Report it; the details are in the server log

An example 400:

json
{"error":"Invalid request","issues":[{"origin":"number","code":"too_small","minimum":0.01,"inclusive":true,"path":["containers",0,"length"],"message":"dimensions must be at least 0.01 (use a smaller unit, e.g. mm)"}]}

Java#

Java 17: the JDK's java.net.http.HttpClient and Jackson for JSON. The records map the whole response, and unknown fields are ignored, so new response fields don't break the client. Maven dependency:

xml
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.2</version>
</dependency>

CartonizationClient.java (put it in your own package). pack() returns a PackResult with the parsed response and the raw json to pass to your frontend. It throws InvalidRequestException (400, with issues()), ServiceUnavailableException (503 after the retries, with retryAfter()), CartonizationTimeoutException, or CartonizationException for anything else.

java
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
 * Client for POST /api/pack on your cartonization install. Java 17: java.net.http and Jackson.
 * Thread-safe; create one per base URL and reuse it.
 */
public class CartonizationClient {
    // ---------- request (null fields are left out of the JSON) ----------

    public record Container(String id, double length, double width, double height, Double maxWeight, Double cost, Integer quantity, Boolean hazmat,
                            List<String> hazmatClasses) {
        /** A regular box type with unlimited stock. */
        public Container(String id, double length, double width, double height, double maxWeight, double cost) {
            this(id, length, width, height, maxWeight, cost, null, null, null);
        }
    }

    public record Item(String id, double length, double width, double height, double weight, int quantity, String rotation, Boolean stackable, Double maxLoad,
                       String hazmatClass) {
        /** An item that may be rotated any way. */
        public Item(String id, double length, double width, double height, double weight, int quantity) {
            this(id, length, width, height, weight, quantity, null, null, null, null);
        }
    }

    public record Options(Double minSupportRatio, Integer timeLimitMs, Integer totalTimeLimitMs, String objective, Boolean heavyOnBottom, Integer seed) {}

    /** `algorithm` is "best", one algorithm id, or a List of ids. */
    public record PackRequest(List<Container> containers, List<Item> items, Options options, Object algorithm, Boolean includeAllSolutions) {}

    // ---------- response (unknown fields are ignored, so new response fields don't break the client) ----------

    public record Dimensions(double length, double width, double height) {}

    public record Placement(String uid, String itemId, double x, double y, double z, double length, double width, double height, double weight) {}

    public record PackedContainer(int index, String containerId, Dimensions dimensions, List<Placement> placements, double itemVolume, double containerVolume,
                                  double fillRate, double netWeight, double grossWeight, Double weightUtilization, String hazmatClass) {}

    public record Metrics(int containerCount, int packedUnits, int unpackedUnits, double totalItemVolume, double totalContainerVolume, double fillRate,
                          double totalCost, Double weightUtilization) {}

    public record Unpacked(String uid, String itemId, String reason) {}

    public record Solution(String algorithm, List<PackedContainer> containers, List<Unpacked> unpacked, Metrics metrics, double elapsedMs) {}

    public record RankingEntry(int rank, String algorithm, Metrics metrics, double elapsedMs) {}

    /** `solutions` is null unless the request had includeAllSolutions: true. */
    public record PackResponse(Solution best, List<RankingEntry> ranking, String objective, List<String> skipped, List<Solution> solutions) {}

    /** The parsed response for your backend's logic, and the response body as received, to pass to your frontend unchanged. */
    public record PackResult(PackResponse response, String json) {}

    /** One entry of a 400's `issues`: `path` points at the field, e.g. ["containers", 0, "length"]. */
    public record Issue(List<Object> path, String message, String code) {}

    record ErrorBody(String error, List<Issue> issues) {}

    // ---------- errors ----------

    /** Any failure of a pack call. `status` is the HTTP status, or 0 when there was no response. */
    public static class CartonizationException extends RuntimeException {
        private static final long serialVersionUID = 1L;
        private final int status;

        public CartonizationException(String message, int status, Throwable cause) {
            super(message, cause);
            this.status = status;
        }

        public int status() {
            return status;
        }
    }

    /** 400: the request is invalid. Usually a bug in how your backend maps its data; `issues` names the fields. */
    public static class InvalidRequestException extends CartonizationException {
        private static final long serialVersionUID = 1L;
        private final transient List<Issue> issues;

        public InvalidRequestException(String message, List<Issue> issues) {
            super(message, 400, null);
            this.issues = issues;
        }

        public List<Issue> issues() {
            return issues;
        }
    }

    /** 503: the solver was busy or the request ran past the service's timeout, on every attempt. */
    public static class ServiceUnavailableException extends CartonizationException {
        private static final long serialVersionUID = 1L;
        private final Duration retryAfter;

        public ServiceUnavailableException(String message, Duration retryAfter) {
            super(message, 503, null);
            this.retryAfter = retryAfter;
        }

        /** From the last response's Retry-After header, or null when it had none. */
        public Duration retryAfter() {
            return retryAfter;
        }
    }

    /** No response within the client's timeout. */
    public static class CartonizationTimeoutException extends CartonizationException {
        private static final long serialVersionUID = 1L;

        public CartonizationTimeoutException(String message, Throwable cause) {
            super(message, 0, cause);
        }
    }

    // ---------- client ----------

    /** The longest wait between attempts, whatever Retry-After says. */
    private static final Duration MAX_RETRY_WAIT = Duration.ofSeconds(30);

    private final ObjectMapper mapper = new ObjectMapper()
            .setSerializationInclusion(JsonInclude.Include.NON_NULL)
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
    private final URI packUri;
    private final Duration timeout;
    private final int maxAttempts;

    /**
     * @param baseUrl     your install, e.g. https://cartonization.internal.example (from your configuration)
     * @param timeout     per attempt; keep it above the request's options.totalTimeLimitMs (default 15 s)
     * @param maxAttempts attempts on 503 (1 = no retry)
     */
    public CartonizationClient(String baseUrl, Duration timeout, int maxAttempts) {
        this.packUri = URI.create(baseUrl.replaceAll("/+$", "") + "/api/pack");
        this.timeout = timeout;
        this.maxAttempts = maxAttempts;
    }

    /** Base URL from the CARTONIZATION_URL environment variable (default http://127.0.0.1:10081), 60 s timeout, 3 attempts. */
    public static CartonizationClient fromEnvironment() {
        return new CartonizationClient(System.getenv().getOrDefault("CARTONIZATION_URL", "http://127.0.0.1:10081"), Duration.ofSeconds(60), 3);
    }

    /** Calls POST /api/pack. Retries 503 up to maxAttempts, waiting for Retry-After when the response has one. */
    public PackResult pack(PackRequest request) {
        String body = toJson(request);
        for (int attempt = 1; ; attempt++) {
            HttpResponse<String> response = send(body);
            int status = response.statusCode();
            if (status == 200) {
                return new PackResult(fromJson(response.body(), PackResponse.class), response.body());
            }
            else if (status == 400) {
                ErrorBody error = errorBody(response);
                throw new InvalidRequestException(error.error(), error.issues() == null ? List.of() : error.issues());
            }
            else if (status == 503) {
                Duration retryAfter = retryAfter(response);
                if (attempt >= maxAttempts) {
                    throw new ServiceUnavailableException(errorBody(response).error(), retryAfter);
                }
                sleep(retryAfter == null ? Duration.ofSeconds(attempt) : min(retryAfter, MAX_RETRY_WAIT));
            }
            else {
                throw new CartonizationException("POST /api/pack returned " + status + ": " + errorBody(response).error(), status, null);
            }
        }
    }

    private HttpResponse<String> send(String body) {
        HttpRequest request = HttpRequest.newBuilder(packUri)
                .header("Content-Type", "application/json")
                .timeout(timeout)
                .POST(HttpRequest.BodyPublishers.ofString(body))
                .build();
        try {
            return http.send(request, HttpResponse.BodyHandlers.ofString());
        }
        catch (HttpTimeoutException e) {
            throw new CartonizationTimeoutException("No response from " + packUri + " within " + timeout.toMillis() + " ms", e);
        }
        catch (IOException e) {
            throw new CartonizationException("Cartonization service unreachable at " + packUri + ": " + e, 0, e);
        }
        catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new CartonizationException("Interrupted", 0, e);
        }
    }

    /** Retry-After is either delta-seconds or an HTTP date. */
    private static Duration retryAfter(HttpResponse<String> response) {
        String value = response.headers().firstValue("Retry-After").orElse(null);
        if (value == null) {
            return null;
        }
        try {
            return Duration.ofSeconds(Math.max(0, Long.parseLong(value.trim())));
        }
        catch (NumberFormatException e) {
            try {
                Duration until = Duration.between(ZonedDateTime.now(), ZonedDateTime.parse(value.trim(), DateTimeFormatter.RFC_1123_DATE_TIME));
                return until.isNegative() ? Duration.ZERO : until;
            }
            catch (DateTimeParseException ignored) {
                return null;
            }
        }
    }

    private ErrorBody errorBody(HttpResponse<String> response) {
        try {
            return mapper.readValue(response.body(), ErrorBody.class);
        }
        catch (JsonProcessingException e) {
            return new ErrorBody(response.body(), null);
        }
    }

    private String toJson(Object value) {
        try {
            return mapper.writeValueAsString(value);
        }
        catch (JsonProcessingException e) {
            throw new IllegalArgumentException("Cannot serialize the request", e);
        }
    }

    private <T> T fromJson(String json, Class<T> type) {
        try {
            return mapper.readValue(json, type);
        }
        catch (JsonProcessingException e) {
            throw new CartonizationException("Unexpected response from " + packUri + ": " + e.getOriginalMessage(), 200, e);
        }
    }

    private static Duration min(Duration a, Duration b) {
        return a.compareTo(b) <= 0 ? a : b;
    }

    private static void sleep(Duration duration) {
        try {
            Thread.sleep(duration.toMillis());
        }
        catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new CartonizationException("Interrupted", 0, e);
        }
    }
}

Using it:

java
import java.util.List;
import java.util.stream.Collectors;

/** Packs a small order with CartonizationClient and prints the result. Run: CARTONIZATION_URL=... java -cp <jackson jars>:. PackExample */
public class PackExample {
    public static void main(String[] args) {
        CartonizationClient client = CartonizationClient.fromEnvironment();
        CartonizationClient.PackRequest request = new CartonizationClient.PackRequest(
                List.of(
                        new CartonizationClient.Container("BOX-S", 20, 15, 10, 10, 0.4),
                        new CartonizationClient.Container("BOX-M", 30, 25, 20, 20, 0.8)),
                List.of(
                        new CartonizationClient.Item("MUG", 12, 9, 10, 0.4, 4, "upright", null, null, null),
                        new CartonizationClient.Item("BOOK", 22, 15, 3, 0.6, 3)),
                new CartonizationClient.Options(null, 500, 15000, "fill-rate", null, null),
                "best",
                // Every algorithm's solution, so the ranking component can open them.
                true);
        try {
            CartonizationClient.PackResult result = client.pack(request);
            CartonizationClient.Solution best = result.response().best();
            System.out.printf("%s: %d box(es), fill %.1f%%%n", best.algorithm(), best.metrics().containerCount(), best.metrics().fillRate() * 100);
            for (CartonizationClient.PackedContainer c : best.containers()) {
                String items = c.placements().stream().map(CartonizationClient.Placement::itemId).collect(Collectors.joining(", "));
                System.out.printf("  #%d %s: %s%n", c.index(), c.containerId(), items);
            }
            // Units left out are not an error: the response is 200 and lists them with a reason.
            best.unpacked().forEach(u -> System.out.println("  unpacked " + u.uid() + ": " + u.reason()));
            // result.json() is the body as received: return it to your frontend unchanged.
            System.out.printf("%d solutions in the response%n", result.response().solutions().size());
        }
        catch (CartonizationClient.InvalidRequestException e) {
            System.err.println("Invalid request: " + e.getMessage());
            e.issues().forEach(issue -> System.err.println("  " + issue.path() + ": " + issue.message()));
            System.exit(2);
        }
        catch (CartonizationClient.ServiceUnavailableException e) {
            System.err.println("Cartonization busy, retry later (Retry-After: " + e.retryAfter() + "): " + e.getMessage());
            System.exit(3);
        }
        catch (CartonizationClient.CartonizationTimeoutException e) {
            System.err.println(e.getMessage());
            System.exit(4);
        }
    }
}

It prints:

text
extreme-points: 1 box(es), fill 48.6%
  #1 BOX-M: MUG, MUG, MUG, MUG, BOOK, BOOK, BOOK
9 solutions in the response

Spring Boot: the /api/cartonize endpoint#

A @RestController with the trigger-flow endpoint, using the client above. Configure the install with cartonization.base-url=https://cartonization.internal.example in application.properties.

Our test suite compiles this controller against Spring Framework 6.2 and calls its methods directly against a running install. It doesn't start it inside a Spring Boot application.

java
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

/**
 * The /api/cartonize pattern in Spring Boot 3: your frontend posts an order's lines, this backend maps them to a pack request
 * with your product and box master data, calls the cartonization install, and returns its response body unchanged.
 * Configure the install in application.properties: cartonization.base-url=https://cartonization.internal.example
 */
@RestController
public class CartonizeController {
    private static final Logger log = LoggerFactory.getLogger(CartonizeController.class);

    public record OrderLine(String sku, Integer quantity) {}

    public record CartonizeRequest(String orderId, List<OrderLine> lines) {}

    public record ErrorResponse(String error) {}

    // Your box master and item master. In a real service these come from your database.
    private static final List<CartonizationClient.Container> BOX_TYPES = List.of(
            new CartonizationClient.Container("BOX-S", 20, 15, 10, 10, 0.4),
            new CartonizationClient.Container("BOX-M", 30, 25, 20, 20, 0.8),
            new CartonizationClient.Container("BOX-L", 50, 40, 30, 30, 1.5),
            new CartonizationClient.Container("HZ-BOX", 30, 20, 20, 15.0, 3.0, null, true, List.of("3")));

    private static final Map<String, CartonizationClient.Item> PRODUCTS = Map.of(
            "BOOK", new CartonizationClient.Item("BOOK", 22, 15, 3, 0.6, 1),
            "MUG", new CartonizationClient.Item("MUG", 12, 9, 10, 0.4, 1, "upright", null, null, null),
            "LAMP", new CartonizationClient.Item("LAMP", 25, 18, 18, 1.5, 1, "upright", false, null, null),
            "CABLE", new CartonizationClient.Item("CABLE", 10, 8, 4, 0.2, 1),
            "LIGHTER-FLUID", new CartonizationClient.Item("LIGHTER-FLUID", 8, 5, 15, 0.3, 1, "upright", null, null, "3"));

    private final CartonizationClient cartonization;

    public CartonizeController(@Value("${cartonization.base-url}") String baseUrl) {
        this.cartonization = new CartonizationClient(baseUrl, Duration.ofSeconds(60), 3);
    }

    @PostMapping(path = "/api/cartonize", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<?> cartonize(@RequestBody CartonizeRequest order) {
        if (order.lines() == null || order.lines().isEmpty()) {
            return ResponseEntity.badRequest().body(new ErrorResponse("lines must be a non-empty array of { sku, quantity }"));
        }
        // Merge duplicate SKUs: item ids must be unique in a pack request.
        Map<String, Integer> quantities = new LinkedHashMap<>();
        for (OrderLine line : order.lines()) {
            if (line.sku() == null || line.quantity() == null || line.quantity() < 1) {
                return ResponseEntity.badRequest().body(new ErrorResponse("Each line needs a sku and a quantity of at least 1"));
            }
            if (!PRODUCTS.containsKey(line.sku())) {
                return ResponseEntity.badRequest().body(new ErrorResponse("Unknown SKU: " + line.sku()));
            }
            quantities.merge(line.sku(), line.quantity(), Integer::sum);
        }
        List<CartonizationClient.Item> items = quantities.entrySet().stream().map(e -> {
            CartonizationClient.Item p = PRODUCTS.get(e.getKey());
            return new CartonizationClient.Item(p.id(), p.length(), p.width(), p.height(), p.weight(), e.getValue(), p.rotation(), p.stackable(), p.maxLoad(), p.hazmatClass());
        }).toList();
        CartonizationClient.PackRequest request = new CartonizationClient.PackRequest(BOX_TYPES, items, new CartonizationClient.Options(null, 300, null, null, null, null), "best", true);

        // Return the body as received: the components take it unchanged, and re-serializing the records would drop fields they don't map.
        return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(cartonization.pack(request).json());
    }

    /** The request this backend built is invalid: a mapping bug on our side, not the user's. */
    @ExceptionHandler(CartonizationClient.InvalidRequestException.class)
    public ResponseEntity<ErrorResponse> invalid(CartonizationClient.InvalidRequestException e) {
        log.error("POST /api/pack rejected our request: {} {}", e.getMessage(), e.issues());
        return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse("Cartonization rejected the request"));
    }

    @ExceptionHandler(CartonizationClient.ServiceUnavailableException.class)
    public ResponseEntity<ErrorResponse> busy(CartonizationClient.ServiceUnavailableException e) {
        log.warn("Cartonization unavailable: {}", e.getMessage());
        ResponseEntity.BodyBuilder response = ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE);
        if (e.retryAfter() != null) {
            response.header("Retry-After", String.valueOf(e.retryAfter().toSeconds()));
        }
        return response.body(new ErrorResponse("Cartonization is busy, try again shortly"));
    }

    @ExceptionHandler(CartonizationClient.CartonizationException.class)
    public ResponseEntity<ErrorResponse> failed(CartonizationClient.CartonizationException e) {
        log.error("Cartonization failed", e);
        return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse("Cartonization failed"));
    }
}

TypeScript#

Node 18+ with the built-in fetch; no dependencies. The interfaces cover the whole response. Run it as an ES module (.mts, or "type": "module" in package.json).

cartonization-client.ts. pack() resolves to { response, json }: the parsed response and the raw JSON to pass to your frontend. It rejects with InvalidRequestError (400, with issues), ServiceUnavailableError (503 after the retries, with retryAfterMs), CartonizationTimeoutError, or CartonizationError for anything else.

ts
// Client for POST /api/pack on your cartonization install. Node 18+ (global fetch), no dependencies.

// ---------- request ----------

export type Rotation = 'any' | 'upright' | 'none';
export type Objective = 'fill-rate' | 'fewest-containers' | 'lowest-cost' | 'weight-utilization';

export interface Container {
    id: string;
    length: number;
    width: number;
    height: number;
    maxWeight?: number;
    tareWeight?: number;
    quantity?: number;
    cost?: number;
    hazmat?: boolean;
    hazmatClasses?: string[];
    maxOverhang?: number;
}

export interface Item {
    id: string;
    length: number;
    width: number;
    height: number;
    weight: number;
    quantity?: number;
    rotation?: Rotation;
    stackable?: boolean;
    maxLoad?: number;
    hazmatClass?: string;
}

export interface PackOptions {
    minSupportRatio?: number;
    timeLimitMs?: number;
    totalTimeLimitMs?: number;
    heavyOnBottom?: boolean;
    objective?: Objective;
    seed?: number;
}

export interface PackRequest {
    containers: Container[];
    items: Item[];
    options?: PackOptions;
    /** 'best' (the default), one algorithm id, or a list of ids. */
    algorithm?: string | string[];
    /** Every algorithm's full solution in `solutions`, so the ranking component can open them. */
    includeAllSolutions?: boolean;
}

// ---------- response ----------

export interface Dimensions {
    length: number;
    width: number;
    height: number;
}

export interface Placement extends Dimensions {
    uid: string;
    itemId: string;
    x: number;
    y: number;
    z: number;
    weight: number;
}

export interface PackedContainer {
    index: number;
    containerId: string;
    dimensions: Dimensions;
    placements: Placement[];
    itemVolume: number;
    containerVolume: number;
    fillRate: number;
    netWeight: number;
    grossWeight: number;
    weightUtilization: number | null;
    hazmatClass?: string;
}

export interface SolutionMetrics {
    containerCount: number;
    packedUnits: number;
    unpackedUnits: number;
    totalItemVolume: number;
    totalContainerVolume: number;
    fillRate: number;
    totalCost: number;
    weightUtilization: number | null;
}

export interface Solution {
    algorithm: string;
    containers: PackedContainer[];
    unpacked: { uid: string; itemId: string; reason: string }[];
    metrics: SolutionMetrics;
    elapsedMs: number;
}

export interface PackResponse {
    best: Solution;
    ranking: { rank: number; algorithm: string; metrics: SolutionMetrics; elapsedMs: number }[];
    objective: Objective;
    skipped: string[];
    /** Present when the request had includeAllSolutions: true. */
    solutions?: Solution[];
}

/** The parsed response for your backend's logic, and the body as received, to pass to your frontend unchanged. */
export interface PackResult {
    response: PackResponse;
    json: string;
}

/** One entry of a 400's `issues`: `path` points at the field, e.g. ['containers', 0, 'length']. */
export interface Issue {
    path: (string | number)[];
    message: string;
    code?: string;
}

// ---------- errors ----------

/** Any failure of a pack call. `status` is the HTTP status, or 0 when there was no response. */
export class CartonizationError extends Error {
    constructor(message: string, readonly status: number, options?: { cause?: unknown }) {
        super(message, options);
        this.name = new.target.name;
    }
}

/** 400: the request is invalid. Usually a bug in how your backend maps its data; `issues` names the fields. */
export class InvalidRequestError extends CartonizationError {
    constructor(message: string, readonly issues: Issue[]) {
        super(message, 400);
    }
}

/** 503: the solver was busy or the request ran past the service's timeout, on every attempt. */
export class ServiceUnavailableError extends CartonizationError {
    /** From the last response's Retry-After header, or null when it had none. */
    constructor(message: string, readonly retryAfterMs: number | null) {
        super(message, 503);
    }
}

/** No response within the client's timeout. */
export class CartonizationTimeoutError extends CartonizationError {
    constructor(message: string, options?: { cause?: unknown }) {
        super(message, 0, options);
    }
}

// ---------- client ----------

export interface ClientOptions {
    /** Your install, e.g. https://cartonization.internal.example (from your configuration). */
    baseUrl: string;
    /** Per attempt; keep it above the request's options.totalTimeLimitMs (default 15 s). Default 60 s. */
    timeoutMs?: number;
    /** Attempts on 503 (1 = no retry). Default 3. */
    maxAttempts?: number;
}

/** The longest wait between attempts, whatever Retry-After says. */
const MAX_RETRY_WAIT_MS = 30_000;
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

/** Retry-After is either delta-seconds or an HTTP date. */
function retryAfterMs(header: string | null): number | null {
    if (header === null) {
        return null;
    }
    if (/^\s*\d+\s*$/.test(header)) {
        return Number(header) * 1000;
    }
    const date = Date.parse(header);
    return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}

async function errorBody(res: Response): Promise<{ error: string; issues?: Issue[] }> {
    const text = await res.text();
    try {
        return JSON.parse(text) as { error: string; issues?: Issue[] };
    }
    catch {
        return { error: text };
    }
}

export class CartonizationClient {
    private readonly packUrl: string;
    private readonly timeoutMs: number;
    private readonly maxAttempts: number;

    constructor({ baseUrl, timeoutMs = 60_000, maxAttempts = 3 }: ClientOptions) {
        this.packUrl = `${baseUrl.replace(/\/+$/, '')}/api/pack`;
        this.timeoutMs = timeoutMs;
        this.maxAttempts = maxAttempts;
    }

    /** Calls POST /api/pack. Retries 503 up to maxAttempts, waiting for Retry-After when the response has one. */
    async pack(request: PackRequest): Promise<PackResult> {
        const body = JSON.stringify(request);
        for (let attempt = 1; ; attempt++) {
            const res = await this.send(body);
            if (res.ok) {
                const json = await res.text();
                return { response: JSON.parse(json) as PackResponse, json };
            }
            const error = await errorBody(res);
            if (res.status === 400) {
                throw new InvalidRequestError(error.error, error.issues ?? []);
            }
            if (res.status === 503) {
                const wait = retryAfterMs(res.headers.get('retry-after'));
                if (attempt >= this.maxAttempts) {
                    throw new ServiceUnavailableError(error.error, wait);
                }
                await sleep(wait === null ? attempt * 1000 : Math.min(wait, MAX_RETRY_WAIT_MS));
                continue;
            }
            throw new CartonizationError(`POST /api/pack returned ${res.status}: ${error.error}`, res.status);
        }
    }

    private async send(body: string): Promise<Response> {
        try {
            return await fetch(this.packUrl, {
                method: 'POST',
                headers: { 'content-type': 'application/json' },
                body,
                signal: AbortSignal.timeout(this.timeoutMs),
            });
        }
        catch (err) {
            if (err instanceof DOMException && err.name === 'TimeoutError') {
                throw new CartonizationTimeoutError(`No response from ${this.packUrl} within ${this.timeoutMs} ms`, { cause: err });
            }
            throw new CartonizationError(`Cartonization service unreachable at ${this.packUrl}: ${(err as Error).message}`, 0, { cause: err });
        }
    }
}

Using it:

ts
// Packs a small order and prints the result. Run as an ES module: CARTONIZATION_URL=... npx tsx pack-example.ts
import { CartonizationClient, CartonizationTimeoutError, InvalidRequestError, ServiceUnavailableError } from './cartonization-client.js';

const client = new CartonizationClient({ baseUrl: process.env.CARTONIZATION_URL ?? 'http://127.0.0.1:10081' });

try {
    const result = await client.pack({
        containers: [
            { id: 'BOX-S', length: 20, width: 15, height: 10, maxWeight: 10, cost: 0.4 },
            { id: 'BOX-M', length: 30, width: 25, height: 20, maxWeight: 20, cost: 0.8 },
        ],
        items: [
            { id: 'MUG', length: 12, width: 9, height: 10, weight: 0.4, quantity: 4, rotation: 'upright' },
            { id: 'BOOK', length: 22, width: 15, height: 3, weight: 0.6, quantity: 3 },
        ],
        algorithm: 'best',
        // Every algorithm's solution, so the ranking component can open them.
        includeAllSolutions: true,
        options: { timeLimitMs: 500, totalTimeLimitMs: 15_000, objective: 'fill-rate' },
    });
    const { best } = result.response;
    console.log(`${best.algorithm}: ${best.metrics.containerCount} box(es), fill ${(best.metrics.fillRate * 100).toFixed(1)}%`);
    for (const c of best.containers) {
        console.log(`  #${c.index} ${c.containerId}: ${c.placements.map(p => p.itemId).join(', ')}`);
    }
    // Units left out are not an error: the response is 200 and lists them with a reason.
    for (const u of best.unpacked) {
        console.log(`  unpacked ${u.uid}: ${u.reason}`);
    }
    // result.json is the body as received: return it to your frontend unchanged.
    console.log(`${result.response.solutions?.length ?? 0} solutions in the response`);
}
catch (err) {
    if (err instanceof InvalidRequestError) {
        console.error(`Invalid request: ${err.message}`);
        err.issues.forEach(issue => console.error(`  ${issue.path.join('.')}: ${issue.message}`));
    }
    else if (err instanceof ServiceUnavailableError) {
        console.error(`Cartonization busy, retry later (Retry-After: ${err.retryAfterMs} ms): ${err.message}`);
    }
    else if (err instanceof CartonizationTimeoutError) {
        console.error(err.message);
    }
    else {
        throw err;
    }
    process.exitCode = 1;
}

It prints the same as the Java example. For the /api/cartonize endpoint itself, see the Node backend above: in Express or any other framework, the handler does the same three steps (validate, build the request, call pack() and send json with Content-Type: application/json).

C# (.NET 8)#

Not compiled in our test suite. There is no .NET SDK in our test environment, so this code was written and reviewed carefully but has not been compiled or run. Please report anything that doesn't build.

HttpClient with System.Text.Json and records, registered as a typed client in an ASP.NET Core minimal API. Start from dotnet new web (which enables implicit usings) and add the two files; no extra packages are needed.

CartonizationClient.cs. PackAsync() returns a PackResult with the parsed Response and the raw Json to pass to your frontend. It throws InvalidRequestException (400, with Issues), ServiceUnavailableException (503 after the retries, with RetryAfter), CartonizationTimeoutException (the HttpClient.Timeout elapsed), or CartonizationException for anything else.

csharp
// Typed HttpClient for POST /api/pack on your cartonization install. .NET 8: HttpClient and System.Text.Json.
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Cartonization;

// ---------- request (null properties are left out of the JSON) ----------

public sealed record Container(string Id, double Length, double Width, double Height, double? MaxWeight = null, double? Cost = null,
    int? Quantity = null, bool? Hazmat = null, IReadOnlyList<string>? HazmatClasses = null);

public sealed record Item(string Id, double Length, double Width, double Height, double Weight, int Quantity = 1, string? Rotation = null,
    bool? Stackable = null, double? MaxLoad = null, string? HazmatClass = null);

public sealed record PackOptions(double? MinSupportRatio = null, int? TimeLimitMs = null, int? TotalTimeLimitMs = null, string? Objective = null,
    bool? HeavyOnBottom = null, int? Seed = null);

/// <summary><c>Algorithm</c> is "best" or one algorithm id.</summary>
public sealed record PackRequest(IReadOnlyList<Container> Containers, IReadOnlyList<Item> Items, PackOptions? Options = null,
    string Algorithm = "best", bool? IncludeAllSolutions = null);

// ---------- response (unknown properties are ignored) ----------

public sealed record BoxDimensions(double Length, double Width, double Height);

public sealed record Placement(string Uid, string ItemId, double X, double Y, double Z, double Length, double Width, double Height, double Weight);

public sealed record PackedContainer(int Index, string ContainerId, BoxDimensions Dimensions, IReadOnlyList<Placement> Placements, double ItemVolume,
    double ContainerVolume, double FillRate, double NetWeight, double GrossWeight, double? WeightUtilization, string? HazmatClass);

public sealed record SolutionMetrics(int ContainerCount, int PackedUnits, int UnpackedUnits, double TotalItemVolume, double TotalContainerVolume,
    double FillRate, double TotalCost, double? WeightUtilization);

public sealed record UnpackedUnit(string Uid, string ItemId, string Reason);

public sealed record Solution(string Algorithm, IReadOnlyList<PackedContainer> Containers, IReadOnlyList<UnpackedUnit> Unpacked,
    SolutionMetrics Metrics, double ElapsedMs);

public sealed record RankingEntry(int Rank, string Algorithm, SolutionMetrics Metrics, double ElapsedMs);

/// <summary><c>Solutions</c> is null unless the request had IncludeAllSolutions = true.</summary>
public sealed record PackResponse(Solution Best, IReadOnlyList<RankingEntry> Ranking, string Objective, IReadOnlyList<string> Skipped,
    IReadOnlyList<Solution>? Solutions);

/// <summary>The parsed response for your backend's logic, and the body as received, to pass to your frontend unchanged.</summary>
public sealed record PackResult(PackResponse Response, string Json);

/// <summary>One entry of a 400's <c>issues</c>: <c>Path</c> points at the field, e.g. ["containers", 0, "length"].</summary>
public sealed record Issue(IReadOnlyList<JsonElement> Path, string Message, string? Code);

internal sealed record ErrorBody(string? Error, IReadOnlyList<Issue>? Issues);

// ---------- errors ----------

/// <summary>Any failure of a pack call. <c>Status</c> is null when there was no response.</summary>
public class CartonizationException : Exception
{
    public CartonizationException(string message, HttpStatusCode? status = null, Exception? inner = null) : base(message, inner)
    {
        Status = status;
    }

    public HttpStatusCode? Status { get; }
}

/// <summary>400: the request is invalid. Usually a bug in how your backend maps its data; <c>Issues</c> names the fields.</summary>
public sealed class InvalidRequestException : CartonizationException
{
    public InvalidRequestException(string message, IReadOnlyList<Issue> issues) : base(message, HttpStatusCode.BadRequest)
    {
        Issues = issues;
    }

    public IReadOnlyList<Issue> Issues { get; }
}

/// <summary>503: the solver was busy or the request ran past the service's timeout, on every attempt.</summary>
public sealed class ServiceUnavailableException : CartonizationException
{
    public ServiceUnavailableException(string message, TimeSpan? retryAfter) : base(message, HttpStatusCode.ServiceUnavailable)
    {
        RetryAfter = retryAfter;
    }

    /// <summary>From the last response's Retry-After header, or null when it had none.</summary>
    public TimeSpan? RetryAfter { get; }
}

/// <summary>No response within the HttpClient's Timeout.</summary>
public sealed class CartonizationTimeoutException : CartonizationException
{
    public CartonizationTimeoutException(string message, Exception inner) : base(message, null, inner)
    {
    }
}

// ---------- client ----------

/// <summary>Register it as a typed client: <c>builder.Services.AddHttpClient&lt;CartonizationClient&gt;(...)</c> (see Program.cs).</summary>
public sealed class CartonizationClient
{
    /// <summary>Attempts on 503 (1 = no retry).</summary>
    private const int MaxAttempts = 3;

    /// <summary>The longest wait between attempts, whatever Retry-After says.</summary>
    private static readonly TimeSpan MaxRetryWait = TimeSpan.FromSeconds(30);

    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    };

    private readonly HttpClient _http;

    public CartonizationClient(HttpClient http)
    {
        _http = http;
    }

    /// <summary>Calls POST /api/pack. Retries 503 up to MaxAttempts, waiting for Retry-After when the response has one.</summary>
    public async Task<PackResult> PackAsync(PackRequest request, CancellationToken cancellationToken = default)
    {
        for (var attempt = 1; ; attempt++)
        {
            using var response = await SendAsync(request, cancellationToken);
            var body = await response.Content.ReadAsStringAsync(cancellationToken);
            if (response.IsSuccessStatusCode)
            {
                var parsed = JsonSerializer.Deserialize<PackResponse>(body, JsonOptions)
                    ?? throw new CartonizationException("Empty response from POST /api/pack", response.StatusCode);
                return new PackResult(parsed, body);
            }

            var error = ParseError(body);
            if (response.StatusCode == HttpStatusCode.BadRequest)
            {
                throw new InvalidRequestException(error.Error ?? "Invalid request", error.Issues ?? Array.Empty<Issue>());
            }

            if (response.StatusCode != HttpStatusCode.ServiceUnavailable)
            {
                throw new CartonizationException($"POST /api/pack returned {(int)response.StatusCode}: {error.Error}", response.StatusCode);
            }

            var retryAfter = RetryAfter(response);
            if (attempt >= MaxAttempts)
            {
                throw new ServiceUnavailableException(error.Error ?? "Service unavailable", retryAfter);
            }

            var wait = retryAfter ?? TimeSpan.FromSeconds(attempt);
            await Task.Delay(wait < MaxRetryWait ? wait : MaxRetryWait, cancellationToken);
        }
    }

    private async Task<HttpResponseMessage> SendAsync(PackRequest request, CancellationToken cancellationToken)
    {
        try
        {
            return await _http.PostAsJsonAsync("api/pack", request, JsonOptions, cancellationToken);
        }
        catch (TaskCanceledException e) when (!cancellationToken.IsCancellationRequested)
        {
            // HttpClient.Timeout elapsed (the caller didn't cancel).
            throw new CartonizationTimeoutException($"No response from {_http.BaseAddress}api/pack within {_http.Timeout.TotalSeconds:0} s", e);
        }
        catch (HttpRequestException e)
        {
            throw new CartonizationException($"Cartonization service unreachable at {_http.BaseAddress}: {e.Message}", null, e);
        }
    }

    /// <summary>Retry-After is either delta-seconds or an HTTP date.</summary>
    private static TimeSpan? RetryAfter(HttpResponseMessage response)
    {
        var header = response.Headers.RetryAfter;
        if (header?.Delta is { } delta)
        {
            return delta;
        }

        if (header?.Date is { } date)
        {
            var wait = date - DateTimeOffset.UtcNow;
            return wait > TimeSpan.Zero ? wait : TimeSpan.Zero;
        }

        return null;
    }

    private static ErrorBody ParseError(string body)
    {
        try
        {
            return JsonSerializer.Deserialize<ErrorBody>(body, JsonOptions) ?? new ErrorBody(body, null);
        }
        catch (JsonException)
        {
            return new ErrorBody(body, null);
        }
    }
}

Program.cs: the typed client registration, with the base URL from configuration (appsettings.json: { "Cartonization": { "BaseUrl": "https://cartonization.internal.example" } }), and the /api/cartonize endpoint.

csharp
// ASP.NET Core 8 minimal API with the /api/cartonize pattern: your frontend posts an order's lines, this backend maps them to a
// pack request with your product and box master data, calls the cartonization install, and returns its response body unchanged.
// appsettings.json: { "Cartonization": { "BaseUrl": "https://cartonization.internal.example" } }
using Cartonization;

var builder = WebApplication.CreateBuilder(args);

var baseUrl = builder.Configuration["Cartonization:BaseUrl"]
    ?? throw new InvalidOperationException("Set Cartonization:BaseUrl to your cartonization install");
builder.Services.AddHttpClient<CartonizationClient>(client =>
{
    // The trailing slash keeps a path prefix (e.g. https://host/cartonization/) when "api/pack" is appended.
    client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
    // Keep it above the request's options.totalTimeLimitMs (default 15 s).
    client.Timeout = TimeSpan.FromSeconds(60);
});
// Your frontend's origin, if it is not the same as this backend's.
builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
    policy.WithOrigins("https://shop.example").WithMethods("POST").WithHeaders("Content-Type")));

var app = builder.Build();
app.UseCors();

app.MapPost("/api/cartonize", async (CartonizeRequest order, CartonizationClient cartonization, HttpContext http, ILogger<Program> logger,
    CancellationToken cancellationToken) =>
{
    if (order.Lines is null || order.Lines.Count == 0)
    {
        return Results.BadRequest(new ErrorResponse("lines must be a non-empty array of { sku, quantity }"));
    }

    if (order.Lines.Any(l => string.IsNullOrWhiteSpace(l.Sku) || l.Quantity < 1))
    {
        return Results.BadRequest(new ErrorResponse("Each line needs a sku and a quantity of at least 1"));
    }

    var unknown = order.Lines.Select(l => l.Sku).Where(sku => !Catalog.Products.ContainsKey(sku)).Distinct().ToList();
    if (unknown.Count > 0)
    {
        return Results.BadRequest(new ErrorResponse($"Unknown SKU: {string.Join(", ", unknown)}"));
    }

    // Item ids must be unique in a pack request, so merge duplicate SKUs.
    var items = order.Lines
        .GroupBy(l => l.Sku)
        .Select(g => Catalog.Products[g.Key] with { Quantity = g.Sum(l => l.Quantity) })
        .ToList();
    var request = new PackRequest(Catalog.BoxTypes, items, new PackOptions(TimeLimitMs: 300), IncludeAllSolutions: true);

    try
    {
        var result = await cartonization.PackAsync(request, cancellationToken);
        var best = result.Response.Best;
        logger.LogInformation("Order {OrderId}: {Algorithm}, {Containers} container(s), {Unpacked} unit(s) unpacked",
            order.OrderId, best.Algorithm, best.Metrics.ContainerCount, best.Unpacked.Count);
        // Return the body as received: the components take it unchanged, and re-serializing the records would drop fields they don't map.
        return Results.Content(result.Json, "application/json");
    }
    catch (InvalidRequestException e)
    {
        // The request this backend built is invalid: a mapping bug on our side, not the user's.
        logger.LogError("POST /api/pack rejected our request: {Error} {Issues}", e.Message,
            string.Join("; ", e.Issues.Select(i => $"{string.Join(".", i.Path)}: {i.Message}")));
        return Results.Json(new ErrorResponse("Cartonization rejected the request"), statusCode: StatusCodes.Status502BadGateway);
    }
    catch (ServiceUnavailableException e)
    {
        if (e.RetryAfter is { } wait)
        {
            http.Response.Headers["Retry-After"] = ((int)Math.Ceiling(wait.TotalSeconds)).ToString();
        }

        return Results.Json(new ErrorResponse("Cartonization is busy, try again shortly"), statusCode: StatusCodes.Status503ServiceUnavailable);
    }
    catch (CartonizationException e)
    {
        logger.LogError(e, "Cartonization failed");
        return Results.Json(new ErrorResponse("Cartonization failed"), statusCode: StatusCodes.Status502BadGateway);
    }
});

app.Run();

public sealed record OrderLine(string Sku, int Quantity);

public sealed record CartonizeRequest(string OrderId, IReadOnlyList<OrderLine>? Lines);

public sealed record ErrorResponse(string Error);

/// <summary>Your box master and item master. In a real service these come from your database.</summary>
internal static class Catalog
{
    public static readonly IReadOnlyList<Container> BoxTypes =
    [
        new("BOX-S", 20, 15, 10, MaxWeight: 10, Cost: 0.4),
        new("BOX-M", 30, 25, 20, MaxWeight: 20, Cost: 0.8),
        new("BOX-L", 50, 40, 30, MaxWeight: 30, Cost: 1.5),
        new("HZ-BOX", 30, 20, 20, MaxWeight: 15, Cost: 3, Hazmat: true, HazmatClasses: ["3"]),
    ];

    public static readonly IReadOnlyDictionary<string, Item> Products = new Dictionary<string, Item>
    {
        ["BOOK"] = new("BOOK", 22, 15, 3, 0.6),
        ["MUG"] = new("MUG", 12, 9, 10, 0.4, Rotation: "upright"),
        ["LAMP"] = new("LAMP", 25, 18, 18, 1.5, Rotation: "upright", Stackable: false),
        ["CABLE"] = new("CABLE", 10, 8, 4, 0.2),
        ["LIGHTER-FLUID"] = new("LIGHTER-FLUID", 8, 5, 15, 0.3, Rotation: "upright", HazmatClass: "3"),
    };
}

Python#

Python 3.8+, standard library only. This example also calls the capacity endpoint, and prints:

text
extreme-points: 1 box(es), fill 48.6%
  #1 BOX-M: ['MUG', 'MUG', 'MUG', 'MUG', 'BOOK', 'BOOK', 'BOOK']
40 per pallet (geometry), 25 pallets for 1000
python
# Python 3.8+, standard library only. Run: python3 pack.py
import json
import os
import urllib.error
import urllib.request

BASE_URL = os.environ.get("CARTONIZATION_URL", "http://127.0.0.1:10081")


def post(path, body):
    request = urllib.request.Request(
        BASE_URL + path,
        data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=60) as response:
            return json.load(response)
    except urllib.error.HTTPError as e:
        # 400: invalid input ({"error", "issues"}); 503: solver busy or timed out, retry later.
        raise RuntimeError(f"{path} failed ({e.code}): {e.read().decode()}") from None


result = post("/api/pack", {
    "containers": [
        {"id": "BOX-S", "length": 20, "width": 15, "height": 10, "maxWeight": 10},
        {"id": "BOX-M", "length": 30, "width": 25, "height": 20, "maxWeight": 20},
    ],
    "items": [
        {"id": "MUG", "length": 12, "width": 9, "height": 10, "weight": 0.4, "quantity": 4, "rotation": "upright"},
        {"id": "BOOK", "length": 22, "width": 15, "height": 3, "weight": 0.6, "quantity": 3},
    ],
    "algorithm": "best",
    "options": {"timeLimitMs": 500},
})
best = result["best"]
print(f"{best['algorithm']}: {best['metrics']['containerCount']} box(es), fill {best['metrics']['fillRate']:.1%}")
for c in best["containers"]:
    print(f"  #{c['index']} {c['containerId']}: {[p['itemId'] for p in c['placements']]}")

# How many 40x30x30 cartons fit on a Euro pallet, and how many pallets for 1000?
capacity = post("/api/capacity", {
    "container": {"id": "EUR", "length": 120, "width": 80, "height": 160, "maxWeight": 800},
    "item": {"id": "CTN", "length": 40, "width": 30, "height": 30, "weight": 9, "rotation": "upright"},
    "demand": 1000,
    "options": {"timeLimitMs": 300},
})
print(f"{capacity['unitsPerContainer']} per pallet ({capacity['limitedBy']}), {capacity['containersNeeded']} pallets for 1000")