|
| 1 | +# Numeric format width validation (Wave 2 item 8) |
| 2 | + |
| 3 | +**Status:** design approved 2026-05-08 |
| 4 | +**Source inventory:** `docs/superpowers/specs/2026-05-07-openapi-refactor-design.md` §9, Wave 2 item 8 |
| 5 | + |
| 6 | +## Goal |
| 7 | + |
| 8 | +Honor `format` on `IntegerSchema` and `NumberSchema` for the four OpenAPI-defined numeric widths: |
| 9 | + |
| 10 | +- `int32` — value must fit in 32-bit signed (`[Integer.MIN_VALUE, Integer.MAX_VALUE]`). |
| 11 | +- `int64` — recognized, always passes (already enforced by the validator's internal `long` coercion). |
| 12 | +- `float` — value's magnitude must not exceed `Float.MAX_VALUE` (cast to `float` would otherwise yield ±Infinity). NaN / Infinity inputs also fail. |
| 13 | +- `double` — recognized, always passes (already enforced by the validator's internal `double` coercion). |
| 14 | + |
| 15 | +Today `validateStringFormat` exists, but `validateInteger` / `validateNumber` ignore the `format` field entirely. |
| 16 | + |
| 17 | +## Non-goals |
| 18 | + |
| 19 | +- Decimal-precision validation for `float` (option B from brainstorming, rejected). A strict `(float)n != n` check would reject nearly all legitimate non-integer JSON values (`0.1`, `1.1`, …). Industry validators (AJV, jsonschema-validator) check overflow only. |
| 20 | +- BigInteger / BigDecimal inputs larger than `long` / `double`. Those already fail upstream with `"type" expected integer/number` and never reach the format check. |
| 21 | +- Consumer-defined numeric formats / SPI. Deferred, non-breaking to add later (mirroring the decision made for string formats). |
| 22 | +- Toggling assertion vs. annotation behavior — we always assert. |
| 23 | +- Changes to `IntegerSchema` / `NumberSchema` record shapes or `Spec` parsing. |
| 24 | + |
| 25 | +## Decisions |
| 26 | + |
| 27 | +- **Overflow only for `float`.** Matches widespread validator behavior. |
| 28 | +- **`int64` and `double` are recognized no-ops.** Documents that they're known formats rather than unknown-and-ignored. Same pattern Wave 2 #5 used for `binary` / `password`. |
| 29 | +- **Unknown numeric formats remain silently ignored.** Consistent with the string-format contract. |
| 30 | + |
| 31 | +## Per-format strategy |
| 32 | + |
| 33 | +| Format | Schema | Predicate | Failure message | |
| 34 | +|---------|--------------|------------------------------------------------------------------------|------------------------------| |
| 35 | +| `int32` | `IntegerSchema` | `n >= Integer.MIN_VALUE && n <= Integer.MAX_VALUE` | `"value does not fit in int32"` | |
| 36 | +| `int64` | `IntegerSchema` | `n -> true` | `"value does not fit in int64"` (unreachable) | |
| 37 | +| `float` | `NumberSchema` | `!Double.isNaN(n) && !Double.isInfinite(n) && Math.abs(n) <= Float.MAX_VALUE` | `"value does not fit in float"` | |
| 38 | +| `double`| `NumberSchema` | `n -> true` | `"value does not fit in double"` (unreachable) | |
| 39 | + |
| 40 | +## Code organization |
| 41 | + |
| 42 | +Two new dispatch maps inside `DefaultValidator`, mirroring the `FORMAT_CHECKS` pattern used for strings: |
| 43 | + |
| 44 | +```java |
| 45 | +private record IntegerFormatCheck(LongPredicate isValid, String message) {} |
| 46 | +private record NumberFormatCheck(DoublePredicate isValid, String message) {} |
| 47 | + |
| 48 | +private static final Map<String, IntegerFormatCheck> INTEGER_FORMAT_CHECKS = Map.of( |
| 49 | + "int32", new IntegerFormatCheck( |
| 50 | + n -> n >= Integer.MIN_VALUE && n <= Integer.MAX_VALUE, |
| 51 | + "value does not fit in int32"), |
| 52 | + "int64", new IntegerFormatCheck(n -> true, "value does not fit in int64")); |
| 53 | + |
| 54 | +private static final Map<String, NumberFormatCheck> NUMBER_FORMAT_CHECKS = Map.of( |
| 55 | + "float", new NumberFormatCheck( |
| 56 | + n -> !Double.isNaN(n) && !Double.isInfinite(n) && Math.abs(n) <= Float.MAX_VALUE, |
| 57 | + "value does not fit in float"), |
| 58 | + "double", new NumberFormatCheck(n -> true, "value does not fit in double")); |
| 59 | +``` |
| 60 | + |
| 61 | +Two new private methods: |
| 62 | + |
| 63 | +```java |
| 64 | +private void validateIntegerFormat(long n, String format, String pointer); |
| 65 | +private void validateNumberFormat(double n, String format, String pointer); |
| 66 | +``` |
| 67 | + |
| 68 | +Each is a single map lookup; missing key → no-op (preserves the "unknown format silently ignored" contract). |
| 69 | + |
| 70 | +Called from the existing `validateInteger` / `validateNumber` at the end, guarded by `s.format() != null`. |
| 71 | + |
| 72 | +Failure renders via the existing `fail(pointer, FORMAT_KEYWORD, message, n)` path — same RFC 7807 400 response shape as string-format failures. |
| 73 | + |
| 74 | +## Tests |
| 75 | + |
| 76 | +Add to `src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java` (despite the name, this file already covers integer and number formats): |
| 77 | + |
| 78 | +- `integerFormatInt32` — `Integer.MAX_VALUE` passes; `Integer.MAX_VALUE + 1L` and `Integer.MIN_VALUE - 1L` fail with keyword `format`. |
| 79 | +- `integerFormatInt64NoOp` — `Long.MAX_VALUE`, `Long.MIN_VALUE`, and arbitrary mid-range values pass. |
| 80 | +- `numberFormatFloat` — `1.5` passes; `1e40` fails with keyword `format`. Negative overflow (`-1e40`) also fails. |
| 81 | +- `numberFormatDoubleNoOp` — `Double.MAX_VALUE`, `-Double.MAX_VALUE`, small values pass. |
| 82 | +- `integerFormatUnknownIsIgnored` / `numberFormatUnknownIsIgnored` — lock in the silent-ignore contract for unknown formats. |
| 83 | + |
| 84 | +Integration coverage: one IT case in `src/test/java/com/retailsvc/http/OpenApiServerIT.java` exercising `format: int32` via a query parameter, asserting 400 + `application/problem+json` on overflow input and 200 on a valid value. Test fixtures: add the corresponding operation to `src/test/resources/openapi.json` and mirror it in `src/test/resources/openapi.yaml` (project rule). |
| 85 | + |
| 86 | +## Acceptance criteria |
| 87 | + |
| 88 | +- `int32` values outside the 32-bit signed range produce a 400 with `format` in the violation pointer. |
| 89 | +- `int64` / `double` formats are recognized but never produce failures from format checks alone (type/range checks elsewhere are unchanged). |
| 90 | +- `float` values whose magnitude exceeds `Float.MAX_VALUE`, plus NaN/Infinity inputs, produce a 400. |
| 91 | +- Unknown numeric `format` values are still silently ignored. |
| 92 | +- String format behavior (Wave 2 item 5) is unchanged byte-for-byte. |
| 93 | +- No new runtime dependencies. |
| 94 | +- `mvn verify` passes. |
0 commit comments