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#
- The base URL comes from your configuration, for example an environment variable
CARTONIZATION_URLor acartonization.base-urlproperty. The default install listens onhttp://127.0.0.1:10081. - There is no authentication: anyone who can reach the port can use it. The install listens on
127.0.0.1by default; operators run it on an internal network or behind an API gateway or reverse proxy. A license key, checked at startup, is coming. - The server speaks plain HTTP. Terminate TLS at your reverse proxy or load balancer if you need HTTPS.
- Send
Content-Type: application/json. Request bodies can be up to 5 MB.
| 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:
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:
- 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.
- 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). - Calls
POST /api/packwithincludeAllSolutions: truewhen the page shows the ranking: without it, ranking rows can't open the other algorithms' solutions (the ranking shows a hint instead). - Reads what it needs, for example to log the result or save the chosen boxes:
best.containers(the boxes, each with itsplacements),best.unpacked(units left out, each with areason) andbest.metrics. - 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:
/**
* 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 }));
}/** 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:
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#
- Pass the body through as received. Don't parse it into your own classes and serialize them again: classes that map only
some fields drop the rest (for example
solutionsor the placements' weights), and the components need them. The clients below return both the parsed response, for your backend's logic, and the raw JSON, for your frontend. - Map the errors to what your frontend should show:
| 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 |
- Units left out are not an error. The response is 200, and
best.unpackedlists each unit with its reason (too large, too heavy, no certified hazmat container, no container left). Check it, and decide what your application does with them. - If your frontend runs on another origin than your backend, your endpoint needs CORS for that origin, including the preflight
for a JSON
POST(Access-Control-Allow-Methods: POST,Access-Control-Allow-Headers: Content-Type).
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:
{"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)"}]}- 402. Checked before the request body is read, so the same request will keep failing until a valid license key is installed and the service restarted. The clients below don't retry it: they raise their general error with status 402 and the server's message.
- Trial license. On an evaluation license every 200 response has a top-level
"license": { "trial": true }. Pass the response to the components unchanged and they show a TRIAL mark; with a production license the field is absent. - Retry-After. The clients below retry a 503 a few times. They wait for the
Retry-Afterheader when the response has one (in seconds or as an HTTP date), and otherwise back off 1 s, 2 s, ... The install sendsRetry-After: 1when the solver is busy (allSOLVER_MAX_CONCURRENTslots in use), and no header when a request hitSOLVER_TIMEOUT_MS. - Timeouts. Solving runs on the server, so allow a client timeout above the request's time budget:
options.totalTimeLimitMs(default 15 s) plus a few seconds. The install also stops any request afterSOLVER_TIMEOUT_MS(default 200 s) with a 503. Don't retry a client-side timeout automatically; report it.
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:
<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.
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:
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:
extreme-points: 1 box(es), fill 48.6%
#1 BOX-M: MUG, MUG, MUG, MUG, BOOK, BOOK, BOOK
9 solutions in the responseSpring 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.
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.
// 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:
// 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.
// 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<CartonizationClient>(...)</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.
// 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:
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 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")