Repository navigation
Expand file tree
/
Copy pathrequest-errors.js
More file actions
686 lines (623 loc) · 34.6 KB
/
Copy pathrequest-errors.js
File metadata and controls
686 lines (623 loc) · 34.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
/**
* Structured request errors: one type, a closed reason, and the JSON Pointer of the bad value.
*
* Held to `packvium.request_errors`. A caller who sends a malformed request needs to know which
* value is wrong and why in a form a program can branch on, and four engines phrasing the prose
* four ways answers neither. So every request error is an `InvalidRequestError` with:
*
* - `code`: `invalid_request` (a subclass such as `FixedPlacementError` names its own);
* - `reason`: one of a closed set shared by all four engines (`packvium.request_errors.REASONS`);
* - `field`: an RFC 6901 JSON Pointer to the offending value (`/items/0/quantity`), or `""` for
* the request as a whole;
* - `detail`: the fixed text for the reason, so the message is identical in every engine.
*
* `checkRequest` walks the schema's rules over the raw JSON before any model is built, and the
* first violation wins, in the order every engine follows: units, output, currency,
* configuration, items, containers. A cross-engine suite holds all four engines to the same reason, field and
* message.
*
* This module is package-internal: the error class is re-exported from `fallback.js` and the
* package root.
*/
import { compareCodePoints } from './commerce-model.js';
import { jsonSpelling } from './canonical-json.js';
import { boxCost } from './made-to-measure.js';
export const SOLVER_PROFILES = Object.freeze(['fast', 'balanced', 'quality', 'exact_small']);
export const OBJECTIVES = Object.freeze(['default', 'lowest_cost', 'shipping_cost', 'lowest_landed_cost', 'open_dimension_height', 'maximum_value', 'maximum_volume']);
export const ACCESS_DIRECTIONS = Object.freeze(['+x', '-x', '+y', '-y', '+z', '-z']);
// The schema's closed vocabularies, each refused as `not_allowed` like the two above.
export const SOLVERS = Object.freeze(['grid', 'extreme_points', 'homogeneous_blocks', 'layer', 'maximal_spaces', 'exact_small']);
// The six legacy orientation codes: a box's whole vocabulary and an item's default list.
export const ROTATIONS = Object.freeze(['LWH', 'LHW', 'WLH', 'WHL', 'HLW', 'HWL']);
/**
* The 24 proper rotations in canonical table order (1.10), `packvium.orientations`: the code,
* the declared side (L=0, W=1, H=2) each container axis x, y, z takes, and the sign it carries.
* The six legacy codes come first with the matrices they have always had -- an odd permutation
* negates x -- then, per permutation in that order, its three other determinant +1 sign patterns.
* The index in this table is the tie-break order everywhere; codes are never compared as strings.
*/
export const ORIENTATION_TABLE = Object.freeze([
['LWH', [0, 1, 2], [1, 1, 1]], ['LHW', [0, 2, 1], [-1, 1, 1]], ['WLH', [1, 0, 2], [-1, 1, 1]],
['WHL', [1, 2, 0], [1, 1, 1]], ['HLW', [2, 0, 1], [1, 1, 1]], ['HWL', [2, 1, 0], [-1, 1, 1]],
['+L-W-H', [0, 1, 2], [1, -1, -1]], ['-L+W-H', [0, 1, 2], [-1, 1, -1]], ['-L-W+H', [0, 1, 2], [-1, -1, 1]],
['+L-H+W', [0, 2, 1], [1, -1, 1]], ['+L+H-W', [0, 2, 1], [1, 1, -1]], ['-L-H-W', [0, 2, 1], [-1, -1, -1]],
['+W-L+H', [1, 0, 2], [1, -1, 1]], ['+W+L-H', [1, 0, 2], [1, 1, -1]], ['-W-L-H', [1, 0, 2], [-1, -1, -1]],
['+W-H-L', [1, 2, 0], [1, -1, -1]], ['-W+H-L', [1, 2, 0], [-1, 1, -1]], ['-W-H+L', [1, 2, 0], [-1, -1, 1]],
['+H-L-W', [2, 0, 1], [1, -1, -1]], ['-H+L-W', [2, 0, 1], [-1, 1, -1]], ['-H-L+W', [2, 0, 1], [-1, -1, 1]],
['+H-W+L', [2, 1, 0], [1, -1, 1]], ['+H+W-L', [2, 1, 0], [1, 1, -1]], ['-H-W-L', [2, 1, 0], [-1, -1, -1]],
].map((row) => Object.freeze(row)));
/** Every code, in table order: a convex hull's vocabulary. */
export const ORIENTATIONS = Object.freeze(ORIENTATION_TABLE.map(([code]) => code));
const SIGNED_ORIENTATIONS = ORIENTATIONS.slice(ROTATIONS.length);
/** The codes that keep the item's own height vertical and pointing up (z takes `+H`), in table order. */
export const UPRIGHT_ORIENTATIONS = Object.freeze(ORIENTATION_TABLE
.filter(([, axes, signs]) => axes[2] === 2 && signs[2] === 1).map(([code]) => code));
/** What a signed code on anything but a hull is refused with, at its own pointer. */
export const SIGNED_CODE_NEEDS_A_HULL = 'a signed rotation code requires shape_type convex_hull';
/** The codes an item may name: all 24 for a hull, the six legacy codes for anything else. */
export const orientationVocabulary = (hull) => (hull ? ORIENTATIONS : ROTATIONS);
/**
* Why `value` cannot orient an item of this shape, or `null` when it can: `invalid_value` for a
* signed code beside a non-hull -- a rule broken, not an unknown word -- and `not_allowed` for
* anything outside the item's vocabulary. Every field that names a requested item's orientation
* reads it through `refuseOrientation`. O(1).
*/
function orientationRefusal(value, hull) {
if (!hull && SIGNED_ORIENTATIONS.includes(value)) return 'invalid_value';
return orientationVocabulary(hull).includes(value) ? null : 'not_allowed';
}
/**
* Throw `error(reason, field, detail)` when `value` cannot orient an item of this shape. `where`
* names the value inside the detail, the way a fixed placement or a revision event words its own
* refusals. `packvium.request_errors.refuse_orientation`. O(1).
*/
export function refuseOrientation(value, hull, field, error, where = '') {
const reason = orientationRefusal(value, hull);
if (reason === 'invalid_value') {
throw error(reason, field, where ? `${where}: ${SIGNED_CODE_NEEDS_A_HULL}` : SIGNED_CODE_NEEDS_A_HULL);
}
if (reason !== null) {
const rule = `must be one of ${jsonSpelling([...orientationVocabulary(hull)])}`;
throw error(reason, field, where ? `${where} ${rule}` : rule);
}
}
/**
* The ids of the requested item types that are `convex_hull`, read from JSON items as written;
* anything that is not an item object names no hull. O(items).
*/
export function hullItemTypes(items) {
if (!Array.isArray(items)) return new Set();
return new Set(items.filter((item) => item !== null && typeof item === 'object' && !Array.isArray(item)
&& item.shape_type === 'convex_hull' && typeof item.id === 'string').map((item) => item.id));
}
/**
* An item's rotation list as the engines search it: its own list, or the six legacy codes, then
* narrowed to the upright codes under `keep_upright`, keeping the list's own order. O(r).
*
* The default is a plain copy, never the frozen `ROTATIONS` itself: the fallback's solver reads
* this list in its hottest sweep, and a frozen array is another elements kind there. Handing it
* the frozen constant (1.10) cost 1-3 MB of process peak in the frontier's Node 22 image, in
* TurboFan memory outside the JS heap, and bought nothing (docs/PERFORMANCE-PRACTICE.md).
*/
export function itemRotations(raw) {
const listed = raw.allowed_rotations ?? [...ROTATIONS];
return raw.keep_upright ? listed.filter((code) => UPRIGHT_ORIENTATIONS.includes(code)) : listed;
}
export const LENGTH_UNITS = Object.freeze(['mm', 'cm', 'm', 'in', 'ft']);
export const WEIGHT_UNITS = Object.freeze(['mg', 'g', 'kg', 'oz', 'lb']);
export const GROUND_CONTACT_RULES = Object.freeze(['free', 'covered', 'single', 'multiple']);
export const SHAPE_TYPES = Object.freeze(['rigid_cuboid', 'convex_hull', 'compressible']);
/** The request is not one any engine may answer. Nothing was solved. */
export class InvalidRequestError extends RangeError {
static code = 'invalid_request';
/** The message a subclass may respell; it runs before `this` exists, so it is static. */
static describe(field, detail) {
return field ? `${this.code}: ${field}: ${detail}` : `${this.code}: ${detail}`;
}
constructor(reason, field, detail) {
super(new.target.describe(field, detail));
this.name = 'InvalidRequestError';
this.code = new.target.code;
this.reason = reason;
this.field = field;
this.detail = detail;
}
}
/** The RFC 6901 pointer to a value, escaping `~` and `/` in keys. */
export function pointer(...parts) {
return parts.map((part) => `/${String(part).replace(/~/g, '~0').replace(/\//g, '~1')}`).join('');
}
// ------------------------------------------------------------------------------ primitives
const MAX_EXACT_MAGNITUDE = Number.MAX_SAFE_INTEGER;
/**
* The admission ceiling every engine refuses past: a length is a tick count
* every engine holds exactly, this one's `Number` included, and a box encloses no more than the
* bound path can sum, so `volume x 10^6` stays inside Rust's `i128`.
*/
const MAX_LENGTH_TICKS = MAX_EXACT_MAGNITUDE;
const MAX_VOLUME_TICKS3 = 10n ** 30n;
/**
* A weight, and every item's weight times its quantity plus the heaviest tare, fit a signed
* 64-bit integer, which is how Rust and PHP add a container's payload (R19-01).
*/
const MAX_WEIGHT_TICKS = 2n ** 63n - 1n;
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
function requireInteger(value, field, minimum) {
if (Number.isSafeInteger(value)) {
if (value < minimum) throw new InvalidRequestError('below_minimum', field, `must be at least ${minimum}`);
return;
}
// A whole number past 2^53 - 1 is out of range, not mistyped: say which way.
if (typeof value === 'number' && Number.isInteger(value)) {
if (value < minimum) throw new InvalidRequestError('below_minimum', field, `must be at least ${minimum}`);
throw new InvalidRequestError('above_maximum', field, `must be at most ${MAX_EXACT_MAGNITUDE}`);
}
throw new InvalidRequestError('wrong_type', field, 'must be an integer');
}
function requireRatio(value, field, maximum = 1) {
if (typeof value !== 'number') throw new InvalidRequestError('wrong_type', field, 'must be a number');
if (value < 0) throw new InvalidRequestError('below_minimum', field, 'must be at least 0');
if (maximum !== null && value > maximum) {
throw new InvalidRequestError('above_maximum', field, `must be at most ${maximum}`);
}
}
const requireUnbounded = (value, field) => requireRatio(value, field, null);
/** Only JSON `true`/`false`: a truthiness reading would pack `"no"` as yes. O(1). */
function requireBoolean(value, field) {
if (typeof value !== 'boolean') throw new InvalidRequestError('wrong_type', field, 'must be a boolean');
}
function requireOneOf(value, field, allowed) {
if (typeof value !== 'string' || !allowed.includes(value)) {
throw new InvalidRequestError('not_allowed', field, `must be one of ${jsonSpelling([...allowed])}`);
}
}
const oneOf = (allowed) => (value, field) => requireOneOf(value, field, allowed);
const eachOneOf = (allowed) => (raw, where) => {
requireList(raw, where).forEach((value, index) => requireOneOf(value, where + pointer(index), allowed));
};
/**
* The schema closes this object: a key it does not name is refused, never ignored. The first
* unknown key in code-point order is named, the order every engine can share.
*/
function requireKnownFields(value, field, known) {
const unknown = Object.keys(value).filter((key) => !known.includes(key)).sort(compareCodePoints);
if (unknown.length > 0) {
throw new InvalidRequestError('not_allowed', field + pointer(unknown[0]), 'is not a known field');
}
}
function requireObject(value, field) {
if (!isObject(value)) throw new InvalidRequestError('wrong_type', field, 'must be an object');
return value;
}
function requireList(value, field) {
if (!Array.isArray(value)) throw new InvalidRequestError('wrong_type', field, 'must be a list');
return value;
}
/**
* A measure is an integer, a string or `{value, unit}`, and never negative. `kind` is the
* engine's own parser for one quantity (`{units, parse}`), so what a measure may spell is the
* parser's to say, exactly as it is when the model is built.
*/
function requireMeasure(value, field, kind, unit) {
const whole = typeof value === 'number' && Number.isInteger(value);
if (!whole && typeof value !== 'string' && !isObject(value)) {
throw new InvalidRequestError('wrong_type', field, 'must be a measure');
}
if (isObject(value)) requireMeasureObject(value, field, kind, unit);
try {
return kind.parse(value, unit);
} catch (error) {
if (String(error?.message).includes('cannot be negative')) {
throw new InvalidRequestError('negative_measure', field, 'cannot be negative');
}
throw new InvalidRequestError('wrong_type', field, 'must be a measure');
}
}
function requireMeasureObject(value, field, kind, unit) {
if (!hasOwn(value, 'value')) throw new InvalidRequestError('wrong_type', field, 'must be a measure');
const stated = hasOwn(value, 'unit') ? value.unit : unit;
if (!knownUnit(stated, kind)) {
throw new InvalidRequestError('invalid_unit', field, `has an unknown unit ${jsonSpelling(stated)}`);
}
}
/**
* A length at most `MAX_LENGTH_TICKS`. The parser rounds its exact `BigInt` ticks to a `Number`,
* and every count past 2^53 - 1 rounds to at least 2^53, so the comparison stays exact.
*/
function requireLength(ticks, field) {
if (ticks > MAX_LENGTH_TICKS) {
throw new InvalidRequestError('above_maximum', field, `must be at most ${MAX_LENGTH_TICKS} ticks`);
}
return ticks;
}
/**
* A weight: a measure past 2^53 - 1 is written as a string, because a JSON number that large
* reached this engine already rounded; its exact ticks are at most `MAX_WEIGHT_TICKS`.
*/
function requireWeight(value, field, kind) {
const amount = isObject(value) ? value.value : value;
if (typeof amount === 'number' && Number.isInteger(amount) && !Number.isSafeInteger(amount)
&& (typeof value === 'number' || (isObject(value) && knownUnit(hasOwn(value, 'unit') ? value.unit : 'g', kind)))) {
if (amount < 0) throw new InvalidRequestError('negative_measure', field, 'cannot be negative');
throw new InvalidRequestError('invalid_value', field, `must be written as a string past ${MAX_EXACT_MAGNITUDE}`);
}
const ticks = requireMeasure(value, field, kind, 'g');
if (ticks > MAX_WEIGHT_TICKS) {
throw new InvalidRequestError('above_maximum', field, `must be at most ${MAX_WEIGHT_TICKS} ticks`);
}
return ticks;
}
/**
* Every item's weight times its quantity, plus the heaviest tare, against `MAX_WEIGHT_TICKS`:
* no container's gross weight can pass it. Each value was admitted above. O(items + containers).
*/
function requireTotalWeight(items, containers, weights) {
const ticks = (holder, name) => (holder[name] == null ? 0n : BigInt(weights.parse(holder[name], 'g')));
const total = items.reduce((sum, item) => sum + ticks(item, 'weight') * BigInt(item.quantity ?? 1),
containers.reduce((heaviest, container) => {
const tare = ticks(container, 'tare_weight');
return tare > heaviest ? tare : heaviest;
}, 0n));
if (total > MAX_WEIGHT_TICKS) {
throw new InvalidRequestError('above_maximum', '/items',
`must weigh at most ${MAX_WEIGHT_TICKS} ticks in all, with the heaviest tare`);
}
}
const knownUnit = (unit, kind) => typeof unit === 'string' && hasOwn(kind.units, unit.trim().toLowerCase());
// ------------------------------------------------------------------------------ the rules
const CONFIGURATION_INTEGERS = [['time_limit_ms', 1], ['alternatives', 1], ['max_containers', 1],
['exact_item_limit', 1], ['multi_start_orders', 1], ['max_candidates_per_item', 1],
['max_candidate_points', 16], ['container_plan_beam_width', 1], ['container_plan_node_limit', 1],
['dimensional_weight_divisor', 1]];
const EFFORT_LIMITS = ['max_candidates_evaluated', 'max_placement_attempts', 'max_search_nodes', 'max_restarts'];
/** Every key the request schema's `configuration` declares; it sets `additionalProperties: false`. */
const CONFIGURATION_FIELDS = ['alternatives', 'clearance', 'container_plan_beam_width',
'container_plan_node_limit', 'dimensional_weight_divisor', 'dimensional_weight_length_unit',
'dimensional_weight_weight_unit', 'effort_budget', 'exact_item_limit', 'max_candidate_points',
'max_candidates_per_item', 'max_containers', 'minimum_support_ratio', 'multi_start_orders', 'objective',
'require_placement_coordinates', 'seed', 'solver_profile', 'solvers', 'time_limit_ms'];
const SIDES = ['length', 'width', 'height'];
/** The item fields the request schema types `boolean`, in schema order. */
const ITEM_BOOLEANS = ['keep_upright', 'stackable', 'must_be_on_floor'];
const AXES = ['x', 'y', 'z'];
const atLeast = (minimum) => (value, field) => requireInteger(value, field, minimum);
/**
* Refuse the request with its first violation, or return. O(size of the request).
*
* `measures` is `{length, weight}`, each `{units, parse(value, unit)}`: the engine's own unit
* table and parser, passed in so this module stays below the solver it guards.
*/
export function checkRequest(data, measures) {
const request = requireObject(data, '');
const unit = checkUnits(request.units, measures.length);
checkOutput(request.output);
const currency = hasOwn(request, 'currency') ? request.currency : null;
optional(request, 'currency', '', currencyCode);
const length = (value, field) => requireLength(requireMeasure(value, field, measures.length, unit), field);
const weight = (value, field) => requireWeight(value, field, measures.weight);
checkConfiguration(request.configuration, length);
const items = requiredList(request, 'items', '');
const lengthTicks = (value) => measures.length.parse(value, unit);
items.forEach((raw, index) => checkItem(raw, pointer('items', index), length, weight, lengthTicks));
requireUniqueIds(items, 'items');
const containers = requiredList(request, 'containers', '');
containers.forEach((raw, index) => checkContainer(raw, pointer('containers', index), length, weight, lengthTicks, currency));
requireUniqueIds(containers, 'containers');
requireTotalWeight(items, containers, measures.weight);
}
function checkUnits(raw, lengths) {
if (raw == null) return 'mm';
const units = requireObject(raw, '/units');
// `{"length": "cm", "weight": "kg"}` used to read every bare weight as grams.
requireKnownFields(units, '/units', ['length']);
const length = units.length;
if (length == null) return 'mm';
if (!knownUnit(length, lengths)) {
throw new InvalidRequestError('invalid_unit', '/units/length', `has an unknown unit ${jsonSpelling(length)}`);
}
return length;
}
function checkOutput(raw) {
if (raw == null) return;
const output = requireObject(raw, '/output');
optional(output, 'length_unit', '/output', oneOf(LENGTH_UNITS));
optional(output, 'weight_unit', '/output', oneOf(WEIGHT_UNITS));
}
function checkConfiguration(raw, length) {
if (raw == null) return;
const configuration = requireObject(raw, '/configuration');
requireKnownFields(configuration, '/configuration', CONFIGURATION_FIELDS);
optional(configuration, 'solver_profile', '/configuration', oneOf(SOLVER_PROFILES));
optional(configuration, 'objective', '/configuration', oneOf(OBJECTIVES));
optional(configuration, 'solvers', '/configuration', eachOneOf(SOLVERS));
optional(configuration, 'dimensional_weight_length_unit', '/configuration', oneOf(LENGTH_UNITS));
optional(configuration, 'dimensional_weight_weight_unit', '/configuration', oneOf(WEIGHT_UNITS));
for (const [name, minimum] of CONFIGURATION_INTEGERS) optional(configuration, name, '/configuration', atLeast(minimum));
optional(configuration, 'minimum_support_ratio', '/configuration', requireRatio);
optional(configuration, 'clearance', '/configuration', length);
optional(configuration, 'require_placement_coordinates', '/configuration', requireBoolean);
if (configuration.effort_budget == null) return;
const budget = requireObject(configuration.effort_budget, '/configuration/effort_budget');
requireKnownFields(budget, '/configuration/effort_budget', EFFORT_LIMITS);
for (const name of EFFORT_LIMITS) optional(budget, name, '/configuration/effort_budget', atLeast(1));
}
function checkItem(raw, where, length, weight, lengthTicks) {
const item = requireObject(raw, where);
requiredString(item, 'id', where);
optional(item, 'quantity', where, atLeast(1));
dimensions(required(item, 'dimensions', where), `${where}/dimensions`, length);
optional(item, 'weight', where, weight);
optional(item, 'max_top_load', where, weight);
optional(item, 'nesting_height', where, length);
optional(item, 'nesting_height', where, (value, field) => nestingBelowHeight(value, field, item, lengthTicks));
optional(item, 'max_stacked_items', where, atLeast(1));
optional(item, 'stop_index', where, atLeast(0));
optional(item, 'value', where, atLeast(0));
optional(item, 'max_compression_pressure_kpa', where, atLeast(0));
optional(item, 'minimum_support_ratio', where, requireRatio);
optional(item, 'compression_ratio', where, requireUnbounded);
optional(item, 'stacking_group', where, stackingGroupName);
optional(item, 'accepts_stacking_groups', where, stackingGroupNames);
optional(item, 'allowed_rotations', where, (value, field) => allowedRotations(value, field, item));
optional(item, 'ground_contact_rule', where, oneOf(GROUND_CONTACT_RULES));
optional(item, 'shape_type', where, oneOf(SHAPE_TYPES));
for (const name of ITEM_BOOLEANS) optional(item, name, where, requireBoolean);
const listed = item.allowed_rotations;
if (listed != null && !listed.some((code) => !item.keep_upright || UPRIGHT_ORIENTATIONS.includes(code))) {
throw new InvalidRequestError('invalid_value', `${where}/allowed_rotations`,
'must allow at least one rotation after keep_upright');
}
}
/**
* Each code from the item's vocabulary (1.10): all 24 for a hull, the six legacy codes for
* anything else, where a signed code is a rule broken rather than an unknown word. The shape is
* read as written, so a shape no engine knows is not a hull. O(len(raw)).
*/
function allowedRotations(raw, where, item) {
const hull = item.shape_type === 'convex_hull';
requireList(raw, where).forEach((value, index) => refuseOrientation(value, hull, where + pointer(index),
(reason, field, detail) => new InvalidRequestError(reason, field, detail)));
}
/**
* A nested copy sinks by less than its own height, or it would vanish into the one below
*. Both measures have passed their own rules already. O(1).
*/
function nestingBelowHeight(value, field, item, lengthTicks) {
if (lengthTicks(value) >= lengthTicks(item.dimensions.height)) {
throw new InvalidRequestError('invalid_value', field, "must be less than the item's height");
}
}
function stackingGroupName(value, field) {
if (typeof value !== 'string') throw new InvalidRequestError('wrong_type', field, 'must be a string');
if (value === '') throw new InvalidRequestError('invalid_value', field, 'must not be empty');
}
function stackingGroupNames(raw, where) {
requireList(raw, where).forEach((name, index) => stackingGroupName(name, where + pointer(index)));
}
function checkContainer(raw, where, length, weight, lengthTicks, currency) {
const container = requireObject(raw, where);
requiredString(container, 'id', where);
optional(container, 'quantity', where, atLeast(1));
dimensions(required(container, 'inner_dimensions', where), `${where}/inner_dimensions`, length);
optional(container, 'outer_dimensions', where, (v, f) => dimensions(v, f, length));
for (const name of ['tare_weight', 'max_payload', 'max_stack_density']) optional(container, name, where, weight);
optional(container, 'max_items', where, atLeast(1));
optional(container, 'cost_minor', where, atLeast(0));
optional(container, 'void_fill_reserve_ratio', where, requireRatio);
optional(container, 'access_directions', where, accessDirections);
optional(container, 'tag_limits', where, tagLimits);
optional(container, 'rate_table', where, (v, f) => rateTable(v, f, currency));
const inner = SIDES.map((side) => lengthTicks(container.inner_dimensions[side]));
optional(container, 'obstacles', where, (v, f) => obstacles(v, f, length, inner));
optional(container, 'deformable_boundary', where, (v, f) => deformableBoundary(container, v, f, where, length, inner));
optional(container, 'made_to_measure', where, (v, f) => madeToMeasure(container, v, f, where, { length, weight, lengthTicks }));
optional(container, 'pallet_overhang_limit', where, (v, f) => palletOverhangLimit(container, v, f, length, lengthTicks));
}
/** The two axes `pallet_overhang_limit` requires, in the order they are checked. */
const PALLET_OVERHANG_FIELDS = ['length', 'width'];
/**
* Both axes, each a non-negative length, and a solving frame `(L + 2l, W + 2w, H)` held
* to the ceiling every box is: each side, then what it encloses. In `BigInt`, since a frame side
* may pass 2^53 - 1 where each measure does not. O(1).
*/
function palletOverhangLimit(container, raw, field, length, lengthTicks) {
const limit = requireObject(raw, field);
requireKnownFields(limit, field, PALLET_OVERHANG_FIELDS);
for (const axis of PALLET_OVERHANG_FIELDS) length(required(limit, axis, field), field + pointer(axis));
const [innerLength, innerWidth, height] = SIDES.map((side) => BigInt(lengthTicks(container.inner_dimensions[side])));
const frame = [innerLength + 2n * BigInt(lengthTicks(limit.length)), innerWidth + 2n * BigInt(lengthTicks(limit.width)), height];
frame.forEach((ticks, axis) => {
if (ticks > BigInt(MAX_LENGTH_TICKS)) {
throw new InvalidRequestError('above_maximum', field + pointer(SIDES[axis]), `must be at most ${MAX_LENGTH_TICKS} ticks`);
}
});
if (frame[0] * frame[1] * frame[2] > MAX_VOLUME_TICKS3) {
throw new InvalidRequestError('above_maximum', field, 'must enclose at most 10^30 cubic ticks');
}
}
/** Every key the request schema's `made_to_measure` declares; it sets `additionalProperties: false`. */
const MADE_TO_MEASURE_FIELDS = ['cost_per_square_metre_minor', 'increment', 'minimum', 'outer_allowance',
'tare_per_square_metre'];
/**
* The made-to-measure block's shape top to bottom, then each axis's four rules, then the
* price ceiling. Run after every other container field, so an existing error always wins. O(1).
*/
function madeToMeasure(container, raw, field, where, { length, weight, lengthTicks }) {
const machine = requireObject(raw, field);
requireKnownFields(machine, field, MADE_TO_MEASURE_FIELDS);
for (const name of ['minimum', 'increment']) required(machine, name, field);
for (const name of ['minimum', 'increment', 'outer_allowance']) optional(machine, name, field, (v, f) => dimensions(v, f, length, false));
optional(machine, 'cost_per_square_metre_minor', field, atLeast(0));
optional(machine, 'tare_per_square_metre', field, weight);
if (container.outer_dimensions != null) {
throw new InvalidRequestError('not_allowed', where + pointer('outer_dimensions'), 'cannot be combined with made_to_measure');
}
const ticks = (sides) => SIDES.map((side) => lengthTicks(sides[side]));
const inner = ticks(container.inner_dimensions), minimum = ticks(machine.minimum), increment = ticks(machine.increment);
SIDES.forEach((side, axis) => {
if (minimum[axis] === 0) throw new InvalidRequestError('invalid_value', field + pointer('minimum', side), 'must be positive');
if (increment[axis] === 0) throw new InvalidRequestError('invalid_value', field + pointer('increment', side), 'must be positive');
if (minimum[axis] > inner[axis]) {
throw new InvalidRequestError('invalid_value', field + pointer('minimum', side), 'exceeds inner_dimensions');
}
if ((inner[axis] - minimum[axis]) % increment[axis] !== 0) {
throw new InvalidRequestError('invalid_value', field + pointer('increment', side), 'does not divide inner_dimensions minus minimum');
}
});
if (boxCost(machine.cost_per_square_metre_minor ?? 0, container.cost_minor ?? 0, inner) > BigInt(MAX_EXACT_MAGNITUDE)) {
throw new InvalidRequestError('invalid_value', field + pointer('cost_per_square_metre_minor'),
`prices the largest box above ${MAX_EXACT_MAGNITUDE}`);
}
}
/**: the kinds `deformable_boundary` admits. */
export const DEFORMABLE_BOUNDARY_KINDS = Object.freeze(['flat_bag', 'gusseted_bag']);
/** Container fields a flat bag refuses, in reporting order (docs/DEFORMABLE-CONTAINERS.md). */
const BAG_EXCLUSIONS = Object.freeze(['obstacles', 'axles', 'access_directions', 'max_stack_density',
'outer_dimensions', 'void_fill_reserve_ratio', 'made_to_measure', 'pallet_overhang_limit']);
/**
* The boundary object top to bottom -- kind, unknown keys, the gusset (1.10) -- then the
* container fields a bag refuses. O(1).
*/
function deformableBoundary(container, raw, field, where, length, inner) {
const boundary = requireObject(raw, field);
const kind = required(boundary, 'kind', field);
requireOneOf(kind, field + pointer('kind'), DEFORMABLE_BOUNDARY_KINDS);
const gusseted = kind === 'gusseted_bag';
requireKnownFields(boundary, field, gusseted ? ['kind', 'side_gusset_depth'] : ['kind']);
if (gusseted) {
sideGussetDepth(required(boundary, 'side_gusset_depth', field), field + pointer('side_gusset_depth'), length, inner);
}
for (const name of BAG_EXCLUSIONS) {
if (setBesideABag(name, hasOwn(container, name) ? container[name] : null)) {
throw new InvalidRequestError('not_allowed', where + pointer(name), 'cannot be combined with deformable_boundary');
}
}
}
/** A length, positive once read in ticks, and no deeper than the bag's inner width. */
function sideGussetDepth(value, field, length, inner) {
const depth = length(value, field);
if (depth === 0) throw new InvalidRequestError('invalid_value', field, 'must be positive');
if (depth > inner[1]) throw new InvalidRequestError('invalid_value', field, 'exceeds inner_dimensions width');
}
// An empty obstacle list and a zero reserve change nothing, so only they are let through.
function setBesideABag(name, value) {
if (name === 'obstacles') return Array.isArray(value) && value.length > 0;
if (name === 'void_fill_reserve_ratio') return value != null && value > 0;
return value != null;
}
const accessDirections = eachOneOf(ACCESS_DIRECTIONS);
/**
* A box's three sides, each a measure; a box that holds or is something has no empty side
*, while a made-to-measure step states its own rule for zero.
*/
function dimensions(raw, where, length, positive = true) {
const sides = requireObject(raw, where);
const volume = SIDES.reduce((product, side) => {
const field = where + pointer(side), ticks = length(required(sides, side, where), field);
if (positive && ticks === 0) throw new InvalidRequestError('invalid_value', field, 'must be positive');
return product * BigInt(ticks);
}, 1n);
if (volume > MAX_VOLUME_TICKS3) {
throw new InvalidRequestError('above_maximum', where, 'must enclose at most 10^30 cubic ticks');
}
}
// Tags in code-point order, as every engine walks them: a JavaScript object lists integer-like
// keys first, so its own order would name a different bad tag than the others do.
function tagLimits(raw, where) {
const limits = requireObject(raw, where);
for (const tag of Object.keys(limits).sort(compareCodePoints)) requireInteger(limits[tag], where + pointer(tag), 1);
}
function rateTable(raw, where, currency) {
const table = requireObject(raw, where);
for (const [name, minimum] of [['weight_brackets_g', 1], ['prices_minor', 0]]) {
if (table[name] == null) continue;
requireList(table[name], where + pointer(name))
.forEach((value, index) => requireInteger(value, where + pointer(name, index), minimum));
}
optional(table, 'minimum_charge_minor', where, atLeast(0));
optional(table, 'fuel_surcharge_permille', where, atLeast(0));
optional(table, 'currency', where, (v, f) => rateTableCurrency(v, f, currency));
}
/**
* An ISO 4217 shape, `^[A-Z]{3}$`, with no code list: the engines never convert, so a code they
* do not know is still one they can echo. ASCII letters only, and no trailing newline. O(1).
*/
function currencyCode(value, field) {
if (typeof value !== 'string') throw new InvalidRequestError('wrong_type', field, 'must be a string');
if (!/^[A-Z]{3}$/.test(value)) {
throw new InvalidRequestError('invalid_value', field, 'must be three capital letters (ISO 4217)');
}
}
/** A rate table prices in the request's currency or says nothing: another one's minor units would be ranked as the request's. O(1). */
function rateTableCurrency(value, field, currency) {
currencyCode(value, field);
if (currency == null) {
throw new InvalidRequestError('missing_field', '/currency', 'is required when a rate_table declares a currency');
}
if (value !== currency) {
throw new InvalidRequestError('not_allowed', field, `must equal the request currency ${jsonSpelling(currency)}`);
}
}
function obstacles(raw, where, length, inner) {
requireList(raw, where).forEach((entry, index) => {
const at = where + pointer(index);
const obstacle = requireObject(entry, at);
placedBox(obstacle, at, length, inner);
if (obstacle.additional_boxes != null) {
const boxes = `${at}/additional_boxes`;
requireList(obstacle.additional_boxes, boxes).forEach((box, position) => {
const part = boxes + pointer(position);
placedBox(requireObject(box, part), part, length, inner);
});
}
});
}
/**
* An optional `origin` and required `dimensions`: an obstacle, or one more box of it,
* whose measures are read in the request's unit like every other length, and which lies inside
* the container.
*/
function placedBox(box, at, length, inner) {
const starts = [0, 0, 0];
if (box.origin != null) {
const point = requireObject(box.origin, `${at}/origin`);
AXES.forEach((axis, index) => {
if (point[axis] != null) starts[index] = length(point[axis], `${at}/origin/${axis}`);
});
}
const sides = required(box, 'dimensions', at);
dimensions(sides, `${at}/dimensions`, length);
SIDES.forEach((side, index) => {
if (starts[index] + length(sides[side], `${at}/dimensions/${side}`) > inner[index]) {
throw new InvalidRequestError('invalid_value', at, 'lies outside inner_dimensions');
}
});
}
// ------------------------------------------------------------------------------ plumbing
/** An optional field: absent and null are the default; anything else must pass. */
function optional(container, name, where, check) {
const value = hasOwn(container, name) ? container[name] : null;
if (value != null) check(value, where + pointer(name));
}
function required(container, name, where) {
const value = hasOwn(container, name) ? container[name] : null;
if (value == null) throw new InvalidRequestError('missing_field', where + pointer(name), 'is required');
return value;
}
const requiredList = (container, name, where) => requireList(required(container, name, where), where + pointer(name));
function requiredString(container, name, where) {
if (typeof required(container, name, where) !== 'string') {
throw new InvalidRequestError('wrong_type', where + pointer(name), 'must be a string');
}
}
/** Checked once every entry is well formed, so the later of two equal ids is named. */
function requireUniqueIds(entries, key) {
const seen = new Set();
entries.forEach((entry, index) => {
if (seen.has(entry.id)) {
throw new InvalidRequestError('duplicate_id', pointer(key, index, 'id'), `repeats the id ${jsonSpelling(entry.id)}`);
}
seen.add(entry.id);
});
}