From 0c5b50cd0e8264d42fe655d6d92dd167e864b1c3 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 8 May 2026 17:08:15 +0200 Subject: [PATCH 01/18] docs: Add design for Wave 2 item 5 (string format expansion) --- ...26-05-08-string-format-expansion-design.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-08-string-format-expansion-design.md diff --git a/docs/superpowers/specs/2026-05-08-string-format-expansion-design.md b/docs/superpowers/specs/2026-05-08-string-format-expansion-design.md new file mode 100644 index 0000000..f036326 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-string-format-expansion-design.md @@ -0,0 +1,81 @@ +# String format expansion (Wave 2 item 5) + +**Status:** design approved 2026-05-08 +**Source inventory:** `docs/superpowers/specs/2026-05-07-openapi-refactor-design.md` §9, Wave 2 item 5 + +## Goal + +Extend `DefaultValidator` to recognize 10 additional `format` values defined by OpenAPI 3.1 / JSON Schema 2020-12 on `StringSchema`: + +`email`, `uri`, `uri-reference`, `hostname`, `ipv4`, `ipv6`, `regex`, `byte`, `binary`, `password`. + +These join the three already supported (`uuid`, `date`, `date-time`). + +## Non-goals + +- Numeric format-width validation (`int32`, `int64`, `float`, `double`) — Wave 2 item 8, separate spec/PR. +- Consumer-defined custom formats / `FormatValidator` SPI — deferred; non-breaking to add later. +- Toggling the JSON Schema 2020-12 `format-assertion` vocabulary on/off — we always assert, matching current behavior. +- Changes to `StringSchema` record shape or `Spec` parsing. + +## Decisions + +- **Closed set.** Only the 13 well-known formats (current 3 + the 10 below) are recognized. Unknown `format` values continue to be silently ignored. (User decision, 2026-05-08.) +- **Always assert.** Consistent with current behavior of `uuid` / `date` / `date-time`. +- **Syntactic-only network checks.** No DNS lookups for `hostname`, `ipv4`, `ipv6`, `uri`. Avoid `InetAddress.getByName`. +- **No new dependencies.** Java stdlib + regex only. + +## Per-format strategy + +| Format | Strategy | +|---|---| +| `email` | Regex `^[^\s@]+@[^\s@]+\.[^\s@]+$`. Pragmatic; matches what most JSON Schema validators do in practice. Full RFC 5322 grammar is not worth the complexity. | +| `uri` | `URI.create(str)` succeeds *and* `isAbsolute()` is true. | +| `uri-reference` | `URI.create(str)` succeeds. | +| `hostname` | Regex per RFC 1123: labels 1–63 chars, alphanumeric + hyphens, hyphens not at label boundaries, total length ≤ 253. | +| `ipv4` | Regex `^((25[0-5]\|2[0-4]\d\|1?\d?\d)\.){3}(25[0-5]\|2[0-4]\d\|1?\d?\d)$`. Strict dotted-quad. | +| `ipv6` | The standard JSON Schema 2020-12 IPv6 regex: 8 hex groups with `::` compression and optional embedded IPv4 trailer. Single explicit regex, not the `URI("http://[…]/")` hack — avoids surprises around zone IDs and mapped forms. | +| `regex` | `Pattern.compile(str)`, catch `PatternSyntaxException`. | +| `byte` | `Base64.getDecoder().decode(str)` (strict, not MIME), catch `IllegalArgumentException`. | +| `binary` | No-op (always passes). Not meaningful as a JSON string format. | +| `password` | No-op. UI hint per OAS. | + +## Code organization + +Current state: `DefaultValidator.validateStringFormat` is a `switch` with a `default` that ignores unknown formats. Adding 10 more arms makes the method noisy and a bad fit for `switch`. + +Refactor in this PR: + +- Introduce a private static registry inside `DefaultValidator`: + ```java + private record FormatCheck(Predicate isValid, String message) {} + private static final Map FORMAT_CHECKS = Map.ofEntries(...); + ``` +- `validateStringFormat` becomes a single map lookup; missing key → ignore (preserves current "unknown format ignored" behavior). +- Pre-compiled `Pattern` constants live as `private static final` fields next to the registry. +- No-op formats (`binary`, `password`) are entries with `s -> true`. Keeping them in the map (rather than as omissions falling through to the ignore branch) documents that they're recognized-and-intentionally-permissive, not unknown. + +Error rendering is unchanged: `fail(pointer, "format", message, value)` produces the same RFC 7807 400 response shape as today. + +## Tests + +Plan to put new tests next to existing format tests. Before writing, check whether a `StringFormatValidationTest` (or similar) already exists; extend it if so, otherwise create one. + +For each newly added format: + +- ≥ 1 valid example (passes validation). +- ≥ 2 invalid examples covering distinct failure modes (e.g., `ipv4`: out-of-range octet *and* wrong group count). + +Integration coverage: at least two formats wired through `OpenApiServer` end-to-end in an `*IT.java` to confirm a 400 with the `application/problem+json` body shape currently produced for `uuid`/`date`/`date-time`. One should be a regex-based format and one should be a parsing-based format (e.g., `email` + `byte`). + +No-op formats (`binary`, `password`) get a single test each: any string passes, including obviously non-binary / non-password content, to lock in the no-op semantics. + +Test fixtures: `src/test/resources/openapi.json` and the parallel `openapi.yaml` will gain a small operation that exercises one of the new formats end-to-end (the IT case). Per project rule, both files must mirror each other. + +## Acceptance criteria + +- All 10 formats recognized; valid inputs pass, invalid inputs produce a 400 with `format` in the violation pointer and a human-readable message. +- `uuid`, `date`, `date-time` behavior is byte-for-byte unchanged. +- Unknown `format` values are still silently ignored. +- No new runtime dependencies. +- `mvn verify` passes; coverage for the new branches reflected in the JaCoCo report. From eee7ae8a825059e9de38043396e26e4ba6b35dff Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 8 May 2026 20:34:05 +0200 Subject: [PATCH 02/18] docs: Add implementation plan for string format expansion --- .../2026-05-08-string-format-expansion.md | 900 ++++++++++++++++++ 1 file changed, 900 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-08-string-format-expansion.md diff --git a/docs/superpowers/plans/2026-05-08-string-format-expansion.md b/docs/superpowers/plans/2026-05-08-string-format-expansion.md new file mode 100644 index 0000000..dea0acd --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-string-format-expansion.md @@ -0,0 +1,900 @@ +# String Format Expansion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add OpenAPI 3.1 string `format` validation for `email`, `uri`, `uri-reference`, `hostname`, `ipv4`, `ipv6`, `regex`, `byte`, `binary`, `password` to `DefaultValidator`. + +**Architecture:** Refactor the existing 3-arm `switch` on `format` in `DefaultValidator` into a static `Map` registry keyed by format name. Each entry holds a `Predicate` plus a human-readable error message. Add the 10 new entries. No changes to `StringSchema` record shape, `Spec` parsing, or error rendering. + +**Tech Stack:** Java 25, JUnit 5, AssertJ, Maven (Surefire for unit tests, Failsafe for `*IT.java`). + +**Spec:** `docs/superpowers/specs/2026-05-08-string-format-expansion-design.md` + +**Conventions to honor:** +- Google Java Formatter (pre-commit auto-runs; never hand-format). +- Always use curly braces — no brace-less one-liners. +- Test method names: camelCase (e.g., `stringFormatEmail`), never `snake_case`. +- `openapi.json` and `openapi.yaml` test fixtures must mirror each other. +- Conventional Commits (commitlint enforces). +- No `Co-Authored-By` trailer. +- LSP diagnostics check after each edit; fix type errors immediately. + +--- + +## File Structure + +**Modify:** +- `src/main/java/com/retailsvc/http/validate/DefaultValidator.java` — replace `validateStringFormat` switch with map-based dispatch; add 10 format entries. +- `src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java` — add per-format unit tests next to the existing `stringFormatUuid` test. +- `src/test/resources/openapi.json` — add one operation exercising a new format (for the IT case). +- `src/test/resources/openapi.yaml` — mirror the JSON change. +- `src/test/java/com/retailsvc/http/OpenApiServerIT.java` — add an IT case for the new operation. + +**No new files.** + +--- + +## Task 1: Refactor existing format dispatch to a registry + +Pure refactor. After this task, `mvn test` is fully green and `stringFormatUuid` still passes. No behavior change. + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/validate/DefaultValidator.java` + +- [ ] **Step 1: Verify baseline is green** + +Run: `mvn test -Dtest=StringIntegerNumberTest` +Expected: BUILD SUCCESS, all tests pass including `stringFormatUuid`, `stringFormatDate` (if present). + +- [ ] **Step 2: Add the registry and rewrite `validateStringFormat`** + +Replace the existing `validateStringFormat` method body in `src/main/java/com/retailsvc/http/validate/DefaultValidator.java` (currently ~lines 112–139) with the registry-driven version. + +Add these imports if not already present: +```java +import java.util.function.Predicate; +``` + +Add these `private static final` members near the top of the class (after `FORMAT_KEYWORD`): + +```java +private record FormatCheck(Predicate isValid, String message) {} + +private static final Map FORMAT_CHECKS = + Map.of( + "uuid", new FormatCheck(DefaultValidator::isUuid, "not a valid uuid"), + "date", new FormatCheck(DefaultValidator::isDate, "not a valid date"), + "date-time", new FormatCheck(DefaultValidator::isDateTime, "not a valid date-time")); +``` + +Replace `validateStringFormat` with: + +```java +private void validateStringFormat(String str, String format, String pointer) { + FormatCheck check = FORMAT_CHECKS.get(format); + if (check == null) { + return; + } + if (!check.isValid().test(str)) { + fail(pointer, FORMAT_KEYWORD, check.message(), str); + } +} +``` + +Add the three predicate helpers as `private static` methods on the class: + +```java +private static boolean isUuid(String s) { + try { + UUID.fromString(s); + return true; + } catch (IllegalArgumentException _) { + return false; + } +} + +private static boolean isDate(String s) { + try { + LocalDate.parse(s); + return true; + } catch (DateTimeParseException _) { + return false; + } +} + +private static boolean isDateTime(String s) { + try { + OffsetDateTime.parse(s); + return true; + } catch (DateTimeParseException _) { + return false; + } +} +``` + +- [ ] **Step 3: Run pre-commit formatter via build, verify all tests still pass** + +Run: `mvn test -Dtest=StringIntegerNumberTest` +Expected: BUILD SUCCESS, all tests pass. + +Run: `mvn test` +Expected: BUILD SUCCESS, all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/com/retailsvc/http/validate/DefaultValidator.java +git commit -m "refactor: Replace string format switch with registry" +``` + +--- + +## Task 2: Add `email` format + +**Files:** +- Modify: `src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java` +- Modify: `src/main/java/com/retailsvc/http/validate/DefaultValidator.java` + +- [ ] **Step 1: Write the failing test** + +Append after `stringFormatUuid` in `StringIntegerNumberTest.java`: + +```java +@Test +void stringFormatEmail() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "email", null); + assertThatCode(() -> v.validate("user@example.com", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("not-an-email", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("missing@dot", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=StringIntegerNumberTest#stringFormatEmail` +Expected: FAIL — the second/third assertion fails because unknown format is silently ignored. + +- [ ] **Step 3: Add the email pattern + registry entry** + +In `DefaultValidator.java`, add a static field near the other constants: + +```java +private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); +``` + +Add to `FORMAT_CHECKS` map: + +```java +"email", new FormatCheck(s -> EMAIL.matcher(s).matches(), "not a valid email"), +``` + +(Note: `Map.of` has a 10-entry limit. Once the map hits 11 entries, switch to `Map.ofEntries(Map.entry(...), ...)`. This first addition keeps it at 4 entries — fine. Subsequent tasks will switch.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn test -Dtest=StringIntegerNumberTest#stringFormatEmail` +Expected: PASS. + +Run: `mvn test -Dtest=StringIntegerNumberTest` +Expected: BUILD SUCCESS, all tests in the class pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/validate/DefaultValidator.java src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +git commit -m "feat: Validate string format 'email'" +``` + +--- + +## Task 3: Add `uri` and `uri-reference` formats + +**Files:** +- Modify: `src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java` +- Modify: `src/main/java/com/retailsvc/http/validate/DefaultValidator.java` + +- [ ] **Step 1: Write the failing tests** + +Append to `StringIntegerNumberTest.java`: + +```java +@Test +void stringFormatUri() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "uri", null); + assertThatCode(() -> v.validate("https://example.com/path", s, "/v")) + .doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("/relative/path", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("not a uri at all", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); +} + +@Test +void stringFormatUriReference() { + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, "uri-reference", null); + assertThatCode(() -> v.validate("https://example.com", s, "/v")) + .doesNotThrowAnyException(); + assertThatCode(() -> v.validate("/relative/path", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("ht tp://broken", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn test -Dtest=StringIntegerNumberTest#stringFormatUri+stringFormatUriReference` +Expected: FAIL. + +- [ ] **Step 3: Add the predicates + registry entries** + +In `DefaultValidator.java`, add the following imports if not present: + +```java +import java.net.URI; +import java.net.URISyntaxException; +``` + +Add static helper methods on the class: + +```java +private static boolean isUri(String s) { + try { + return new URI(s).isAbsolute(); + } catch (URISyntaxException _) { + return false; + } +} + +private static boolean isUriReference(String s) { + try { + new URI(s); + return true; + } catch (URISyntaxException _) { + return false; + } +} +``` + +Note: use `new URI(s)` (throws `URISyntaxException`) rather than `URI.create(s)` (throws unchecked) so the catch is checked-exception-clean. + +Convert `FORMAT_CHECKS` to `Map.ofEntries(...)` (the entry count is now 6, still under 10, but the next task will push past 10 — switching now keeps the diff smaller later). Final shape: + +```java +private static final Map FORMAT_CHECKS = + Map.ofEntries( + Map.entry("uuid", new FormatCheck(DefaultValidator::isUuid, "not a valid uuid")), + Map.entry("date", new FormatCheck(DefaultValidator::isDate, "not a valid date")), + Map.entry( + "date-time", + new FormatCheck(DefaultValidator::isDateTime, "not a valid date-time")), + Map.entry( + "email", + new FormatCheck(s -> EMAIL.matcher(s).matches(), "not a valid email")), + Map.entry("uri", new FormatCheck(DefaultValidator::isUri, "not a valid uri")), + Map.entry( + "uri-reference", + new FormatCheck(DefaultValidator::isUriReference, "not a valid uri-reference"))); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mvn test -Dtest=StringIntegerNumberTest` +Expected: BUILD SUCCESS, all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/validate/DefaultValidator.java src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +git commit -m "feat: Validate string formats 'uri' and 'uri-reference'" +``` + +--- + +## Task 4: Add `hostname` format + +**Files:** +- Modify: `src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java` +- Modify: `src/main/java/com/retailsvc/http/validate/DefaultValidator.java` + +- [ ] **Step 1: Write the failing test** + +```java +@Test +void stringFormatHostname() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "hostname", null); + assertThatCode(() -> v.validate("example.com", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate("a.b.c.example", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("-leading-hyphen.com", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("invalid host name", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=StringIntegerNumberTest#stringFormatHostname` +Expected: FAIL. + +- [ ] **Step 3: Add the hostname pattern + registry entry** + +Add static field: + +```java +private static final Pattern HOSTNAME = + Pattern.compile( + "^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)" + + "(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"); +``` + +Add to `FORMAT_CHECKS`: + +```java +Map.entry( + "hostname", + new FormatCheck(s -> HOSTNAME.matcher(s).matches(), "not a valid hostname")), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mvn test -Dtest=StringIntegerNumberTest` +Expected: BUILD SUCCESS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/validate/DefaultValidator.java src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +git commit -m "feat: Validate string format 'hostname'" +``` + +--- + +## Task 5: Add `ipv4` format + +**Files:** +- Modify: `src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java` +- Modify: `src/main/java/com/retailsvc/http/validate/DefaultValidator.java` + +- [ ] **Step 1: Write the failing test** + +```java +@Test +void stringFormatIpv4() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "ipv4", null); + assertThatCode(() -> v.validate("192.168.0.1", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate("0.0.0.0", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate("255.255.255.255", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("256.0.0.1", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("1.2.3", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=StringIntegerNumberTest#stringFormatIpv4` +Expected: FAIL. + +- [ ] **Step 3: Add the ipv4 pattern + registry entry** + +```java +private static final Pattern IPV4 = + Pattern.compile("^((25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1?\\d?\\d)$"); +``` + +Add to `FORMAT_CHECKS`: + +```java +Map.entry("ipv4", new FormatCheck(s -> IPV4.matcher(s).matches(), "not a valid ipv4")), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mvn test -Dtest=StringIntegerNumberTest` +Expected: BUILD SUCCESS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/validate/DefaultValidator.java src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +git commit -m "feat: Validate string format 'ipv4'" +``` + +--- + +## Task 6: Add `ipv6` format + +**Files:** +- Modify: `src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java` +- Modify: `src/main/java/com/retailsvc/http/validate/DefaultValidator.java` + +- [ ] **Step 1: Write the failing test** + +```java +@Test +void stringFormatIpv6() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "ipv6", null); + assertThatCode(() -> v.validate("2001:0db8:85a3:0000:0000:8a2e:0370:7334", s, "/v")) + .doesNotThrowAnyException(); + assertThatCode(() -> v.validate("2001:db8::1", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate("::1", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("not:an:ipv6", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("12345::1", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=StringIntegerNumberTest#stringFormatIpv6` +Expected: FAIL. + +- [ ] **Step 3: Add the ipv6 pattern + registry entry** + +The standard JSON Schema IPv6 regex (no IPv4 trailer; sufficient for OpenAPI use): + +```java +private static final Pattern IPV6 = + Pattern.compile( + "^(" + + "([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}" + + "|([0-9a-fA-F]{1,4}:){1,7}:" + + "|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}" + + "|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}" + + "|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}" + + "|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}" + + "|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}" + + "|[0-9a-fA-F]{1,4}:(:[0-9a-fA-F]{1,4}){1,6}" + + "|:((:[0-9a-fA-F]{1,4}){1,7}|:)" + + ")$"); +``` + +Add to `FORMAT_CHECKS`: + +```java +Map.entry("ipv6", new FormatCheck(s -> IPV6.matcher(s).matches(), "not a valid ipv6")), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mvn test -Dtest=StringIntegerNumberTest` +Expected: BUILD SUCCESS. + +If `::1` fails: the regex variant is rejecting that case — tweak the final alternative to `|:(:[0-9a-fA-F]{1,4}){1,7}|::` and re-run. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/validate/DefaultValidator.java src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +git commit -m "feat: Validate string format 'ipv6'" +``` + +--- + +## Task 7: Add `regex` format + +**Files:** +- Modify: `src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java` +- Modify: `src/main/java/com/retailsvc/http/validate/DefaultValidator.java` + +- [ ] **Step 1: Write the failing test** + +```java +@Test +void stringFormatRegex() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "regex", null); + assertThatCode(() -> v.validate("^[a-z]+$", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("[unclosed", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("(? ((ValidationException) t).error().keyword()) + .isEqualTo("format"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=StringIntegerNumberTest#stringFormatRegex` +Expected: FAIL. + +- [ ] **Step 3: Add the predicate + registry entry** + +Add import if not present: +```java +import java.util.regex.PatternSyntaxException; +``` + +Add static helper: + +```java +private static boolean isRegex(String s) { + try { + Pattern.compile(s); + return true; + } catch (PatternSyntaxException _) { + return false; + } +} +``` + +Add to `FORMAT_CHECKS`: + +```java +Map.entry("regex", new FormatCheck(DefaultValidator::isRegex, "not a valid regex")), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mvn test -Dtest=StringIntegerNumberTest` +Expected: BUILD SUCCESS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/validate/DefaultValidator.java src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +git commit -m "feat: Validate string format 'regex'" +``` + +--- + +## Task 8: Add `byte` format + +**Files:** +- Modify: `src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java` +- Modify: `src/main/java/com/retailsvc/http/validate/DefaultValidator.java` + +- [ ] **Step 1: Write the failing test** + +```java +@Test +void stringFormatByte() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "byte", null); + assertThatCode(() -> v.validate("aGVsbG8=", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate("", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("not base64!!", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("a===", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=StringIntegerNumberTest#stringFormatByte` +Expected: FAIL. + +- [ ] **Step 3: Add the predicate + registry entry** + +Add import: +```java +import java.util.Base64; +``` + +Add static helper: + +```java +private static boolean isByte(String s) { + try { + Base64.getDecoder().decode(s); + return true; + } catch (IllegalArgumentException _) { + return false; + } +} +``` + +Add to `FORMAT_CHECKS`: + +```java +Map.entry("byte", new FormatCheck(DefaultValidator::isByte, "not valid base64")), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mvn test -Dtest=StringIntegerNumberTest` +Expected: BUILD SUCCESS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/validate/DefaultValidator.java src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +git commit -m "feat: Validate string format 'byte'" +``` + +--- + +## Task 9: Add `binary` and `password` no-op formats + +These are recognized but always pass — locking in OpenAPI-spec semantics. + +**Files:** +- Modify: `src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java` +- Modify: `src/main/java/com/retailsvc/http/validate/DefaultValidator.java` + +- [ ] **Step 1: Write the failing tests** + +```java +@Test +void stringFormatBinaryAcceptsAnyString() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "binary", null); + assertThatCode(() -> v.validate("anything goes", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate("", s, "/v")).doesNotThrowAnyException(); +} + +@Test +void stringFormatPasswordAcceptsAnyString() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "password", null); + assertThatCode(() -> v.validate("anything goes", s, "/v")).doesNotThrowAnyException(); +} +``` + +These pass even before the registry change (because unknown formats are silently ignored). The intent is to lock in the no-op semantics so a future change can't accidentally start asserting against `binary`/`password`. + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `mvn test -Dtest=StringIntegerNumberTest#stringFormatBinaryAcceptsAnyString+stringFormatPasswordAcceptsAnyString` +Expected: PASS. + +- [ ] **Step 3: Add explicit registry entries** + +Add to `FORMAT_CHECKS`: + +```java +Map.entry("binary", new FormatCheck(s -> true, "not valid binary")), +Map.entry("password", new FormatCheck(s -> true, "not valid password")), +``` + +(The messages are unreachable but required by the record; they document the slot.) + +- [ ] **Step 4: Re-run tests** + +Run: `mvn test -Dtest=StringIntegerNumberTest` +Expected: BUILD SUCCESS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/validate/DefaultValidator.java src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +git commit -m "feat: Recognize 'binary' and 'password' string formats as no-ops" +``` + +--- + +## Task 10: Wire one format end-to-end through `OpenApiServer` (IT) + +Add a small operation to the test fixtures and an integration test confirming a 400 + `application/problem+json` response when validation fails. + +**Files:** +- Modify: `src/test/resources/openapi.json` +- Modify: `src/test/resources/openapi.yaml` +- Modify: `src/test/java/com/retailsvc/http/OpenApiServerIT.java` + +- [ ] **Step 1: Add two new operations to `openapi.json`** + +Add two new path entries inside the `paths` object (alongside the existing operations). One exercises a regex-based format (`email`), the other a parsing-based format (`byte`): + +```json +"/format/email": { + "get": { + "operationId": "format-email", + "parameters": [ + { + "in": "query", + "name": "addr", + "required": true, + "schema": { + "type": "string", + "format": "email" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } +}, +"/format/byte": { + "get": { + "operationId": "format-byte", + "parameters": [ + { + "in": "query", + "name": "data", + "required": true, + "schema": { + "type": "string", + "format": "byte" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } +} +``` + +- [ ] **Step 2: Mirror the change in `openapi.yaml`** + +Add the equivalent two blocks in `src/test/resources/openapi.yaml` so both fixtures describe the same API. Verify by inspection that both files now have a `format-email` operation at `/format/email` and a `format-byte` operation at `/format/byte` with matching parameter shapes. + +- [ ] **Step 3: Write the failing IT case** + +Find an existing IT case in `OpenApiServerIT.java` that returns 400 with `application/problem+json` (e.g., `getDataShouldReturnBadRequestOnInvalidXNameHeader` around line 57) and add a parallel test in the same nested class. Also register a handler for `format-email` in whatever fixture wires up handlers (look near the top of the IT for `registerHandler` or an equivalent map entry). + +```java +@Test +void formatEmailShouldReturnBadRequestOnInvalidEmail() { + try (var server = serverWithDefaultHandlers(); + var client = httpClient()) { + var response = + client.send( + HttpRequest.newBuilder() + .uri(URI.create(baseUri(server) + "/format/email?addr=not-an-email")) + .GET() + .build(), + BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(400); + assertThat(response.headers().firstValue("content-type").orElseThrow()) + .contains("application/problem+json"); + assertThat(response.body()).contains("\"format\""); + } +} + +@Test +void formatEmailShouldReturnOkOnValidEmail() { + try (var server = serverWithDefaultHandlers(); + var client = httpClient()) { + var response = + client.send( + HttpRequest.newBuilder() + .uri(URI.create(baseUri(server) + "/format/email?addr=user%40example.com")) + .GET() + .build(), + BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + } +} + +@Test +void formatByteShouldReturnBadRequestOnInvalidBase64() { + try (var server = serverWithDefaultHandlers(); + var client = httpClient()) { + var response = + client.send( + HttpRequest.newBuilder() + .uri(URI.create(baseUri(server) + "/format/byte?data=not%20base64!!")) + .GET() + .build(), + BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(400); + assertThat(response.headers().firstValue("content-type").orElseThrow()) + .contains("application/problem+json"); + assertThat(response.body()).contains("\"format\""); + } +} + +@Test +void formatByteShouldReturnOkOnValidBase64() { + try (var server = serverWithDefaultHandlers(); + var client = httpClient()) { + var response = + client.send( + HttpRequest.newBuilder() + .uri(URI.create(baseUri(server) + "/format/byte?data=aGVsbG8%3D")) + .GET() + .build(), + BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + } +} +``` + +Adjust the `serverWithDefaultHandlers()` / `baseUri(server)` calls to match this file's existing helpers — copy the surrounding pattern of an adjacent test exactly. Add a handler registration for `format-email` that returns 200 with empty body, mirroring how other simple handlers in this fixture are registered. + +- [ ] **Step 4: Run the IT to verify it fails** + +Run: `mvn verify -Dit.test=OpenApiServerIT#formatEmailShouldReturnBadRequestOnInvalidEmail+formatEmailShouldReturnOkOnValidEmail -DfailIfNoTests=false` +Expected: FAIL — likely "no handler for operation format-email" or similar, until handler is registered. If failure is "404 not found", confirm both fixture files were updated and re-run. + +- [ ] **Step 5: Register the `format-email` and `format-byte` handlers** + +In `OpenApiServerIT.java`, find where the test server's handler map is built (search for an existing operationId like `get-data` to locate the registration site). Add two handlers that simply return 200 with no body: + +```java +.handler("format-email", exchange -> { + exchange.sendResponseHeaders(200, -1); +}) +.handler("format-byte", exchange -> { + exchange.sendResponseHeaders(200, -1); +}) +``` + +Use whatever `.handler(...)` / `Map.of(...)` / builder pattern the file already uses. Match style exactly. + +- [ ] **Step 6: Run the IT to verify it passes** + +Run: `mvn verify -Dit.test=OpenApiServerIT -DfailIfNoTests=false` +Expected: BUILD SUCCESS, all IT cases pass including the two new ones. + +- [ ] **Step 7: Run the full build** + +Run: `mvn verify` +Expected: BUILD SUCCESS, all unit + IT tests pass, JaCoCo report generated. + +- [ ] **Step 8: Commit** + +```bash +git add src/test/resources/openapi.json src/test/resources/openapi.yaml src/test/java/com/retailsvc/http/OpenApiServerIT.java +git commit -m "test: Verify string format validation end-to-end via OpenApiServer" +``` + +--- + +## Task 11: Final verification + +- [ ] **Step 1: Confirm full build is clean** + +Run: `mvn verify` +Expected: BUILD SUCCESS. No test failures. No skipped tests beyond the usual. + +- [ ] **Step 2: Sanity-check the format registry has all 13 entries** + +Open `src/main/java/com/retailsvc/http/validate/DefaultValidator.java` and confirm `FORMAT_CHECKS` contains exactly these keys: `uuid`, `date`, `date-time`, `email`, `uri`, `uri-reference`, `hostname`, `ipv4`, `ipv6`, `regex`, `byte`, `binary`, `password`. + +- [ ] **Step 3: Sanity-check that an unknown format is still ignored** + +Add a one-off test (then revert / keep, your call) — the design requires unknown formats to remain silently ignored: + +```java +@Test +void stringFormatUnknownIsIgnored() { + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, "definitely-not-a-format", null); + assertThatCode(() -> v.validate("anything", s, "/v")).doesNotThrowAnyException(); +} +``` + +Run: `mvn test -Dtest=StringIntegerNumberTest#stringFormatUnknownIsIgnored` +Expected: PASS. Keep this test — it locks in the contract for unknown formats. Commit: + +```bash +git add src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +git commit -m "test: Lock in 'unknown format silently ignored' contract" +``` + +- [ ] **Step 4: Final `mvn verify` and push the branch** + +Run: `mvn verify` +Expected: BUILD SUCCESS. + +Push the branch (per repo memory: gh CLI cannot create PRs here; user opens it manually): + +```bash +git push -u origin HEAD +``` + +Notify the user the branch is pushed and ready for them to open the PR. From 4f5b2349015e99922d73422974c7dcb701f4648b Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 8 May 2026 20:36:08 +0200 Subject: [PATCH 03/18] refactor: Replace string format switch with registry --- .../http/validate/DefaultValidator.java | 67 ++++++++++++------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index 1be9491..ff70716 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -34,12 +34,21 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.function.Function; +import java.util.function.Predicate; import java.util.regex.Pattern; public final class DefaultValidator implements Validator { private static final String FORMAT_KEYWORD = "format"; + private record FormatCheck(Predicate isValid, String message) {} + + private static final Map FORMAT_CHECKS = + Map.of( + "uuid", new FormatCheck(DefaultValidator::isUuid, "not a valid uuid"), + "date", new FormatCheck(DefaultValidator::isDate, "not a valid date"), + "date-time", new FormatCheck(DefaultValidator::isDateTime, "not a valid date-time")); + private final Function refResolver; private final ConcurrentMap compiledPatterns = new ConcurrentHashMap<>(); @@ -110,31 +119,39 @@ private void validateString(Object value, StringSchema s, String pointer) { } private void validateStringFormat(String str, String format, String pointer) { - switch (format) { - case "uuid" -> { - try { - UUID.fromString(str); - } catch (IllegalArgumentException _) { - fail(pointer, FORMAT_KEYWORD, "not a valid uuid", str); - } - } - case "date" -> { - try { - LocalDate.parse(str); - } catch (DateTimeParseException _) { - fail(pointer, FORMAT_KEYWORD, "not a valid date", str); - } - } - case "date-time" -> { - try { - OffsetDateTime.parse(str); - } catch (DateTimeParseException _) { - fail(pointer, FORMAT_KEYWORD, "not a valid date-time", str); - } - } - default -> { - /* unknown format ignored — handled in 3.1 follow-up */ - } + FormatCheck check = FORMAT_CHECKS.get(format); + if (check == null) { + return; + } + if (!check.isValid().test(str)) { + fail(pointer, FORMAT_KEYWORD, check.message(), str); + } + } + + private static boolean isUuid(String s) { + try { + UUID.fromString(s); + return true; + } catch (IllegalArgumentException _) { + return false; + } + } + + private static boolean isDate(String s) { + try { + LocalDate.parse(s); + return true; + } catch (DateTimeParseException _) { + return false; + } + } + + private static boolean isDateTime(String s) { + try { + OffsetDateTime.parse(s); + return true; + } catch (DateTimeParseException _) { + return false; } } From 2f363d42c1747a101b1e50639a2f7c2d80a6cae1 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 8 May 2026 20:41:52 +0200 Subject: [PATCH 04/18] feat: Validate string format 'email' --- .../retailsvc/http/validate/DefaultValidator.java | 5 ++++- .../http/validate/StringIntegerNumberTest.java | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index ff70716..b0e4698 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -43,11 +43,14 @@ public final class DefaultValidator implements Validator { private record FormatCheck(Predicate isValid, String message) {} + private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); + private static final Map FORMAT_CHECKS = Map.of( "uuid", new FormatCheck(DefaultValidator::isUuid, "not a valid uuid"), "date", new FormatCheck(DefaultValidator::isDate, "not a valid date"), - "date-time", new FormatCheck(DefaultValidator::isDateTime, "not a valid date-time")); + "date-time", new FormatCheck(DefaultValidator::isDateTime, "not a valid date-time"), + "email", new FormatCheck(s -> EMAIL.matcher(s).matches(), "not a valid email")); private final Function refResolver; private final ConcurrentMap compiledPatterns = new ConcurrentHashMap<>(); diff --git a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java index c9ee860..c9c01f9 100644 --- a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +++ b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java @@ -67,6 +67,18 @@ void stringFormatUuid() { .isEqualTo("format"); } + @Test + void stringFormatEmail() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "email", null); + assertThatCode(() -> v.validate("user@example.com", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("not-an-email", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("missing@dot", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + } + @Test void integerWithMinMax() { IntegerSchema s = From 47f5985c7d4f8f71d94fdd304c030648d925574e Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 8 May 2026 20:43:10 +0200 Subject: [PATCH 05/18] feat: Validate string formats 'uri' and 'uri-reference' --- .../http/validate/DefaultValidator.java | 34 ++++++++++++++++--- .../validate/StringIntegerNumberTest.java | 24 +++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index b0e4698..a226184 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -21,6 +21,8 @@ import com.retailsvc.http.spec.schema.StringSchema; import com.retailsvc.http.spec.schema.TypeName; import java.math.BigDecimal; +import java.net.URI; +import java.net.URISyntaxException; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.format.DateTimeParseException; @@ -46,11 +48,16 @@ private record FormatCheck(Predicate isValid, String message) {} private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); private static final Map FORMAT_CHECKS = - Map.of( - "uuid", new FormatCheck(DefaultValidator::isUuid, "not a valid uuid"), - "date", new FormatCheck(DefaultValidator::isDate, "not a valid date"), - "date-time", new FormatCheck(DefaultValidator::isDateTime, "not a valid date-time"), - "email", new FormatCheck(s -> EMAIL.matcher(s).matches(), "not a valid email")); + Map.ofEntries( + Map.entry("uuid", new FormatCheck(DefaultValidator::isUuid, "not a valid uuid")), + Map.entry("date", new FormatCheck(DefaultValidator::isDate, "not a valid date")), + Map.entry( + "date-time", new FormatCheck(DefaultValidator::isDateTime, "not a valid date-time")), + Map.entry("email", new FormatCheck(s -> EMAIL.matcher(s).matches(), "not a valid email")), + Map.entry("uri", new FormatCheck(DefaultValidator::isUri, "not a valid uri")), + Map.entry( + "uri-reference", + new FormatCheck(DefaultValidator::isUriReference, "not a valid uri-reference"))); private final Function refResolver; private final ConcurrentMap compiledPatterns = new ConcurrentHashMap<>(); @@ -158,6 +165,23 @@ private static boolean isDateTime(String s) { } } + private static boolean isUri(String s) { + try { + return new URI(s).isAbsolute(); + } catch (URISyntaxException _) { + return false; + } + } + + private static boolean isUriReference(String s) { + try { + new URI(s); + return true; + } catch (URISyntaxException _) { + return false; + } + } + private void validateInteger(Object value, IntegerSchema s, String pointer) { long n; switch (value) { diff --git a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java index c9c01f9..f1ef57c 100644 --- a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +++ b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java @@ -79,6 +79,30 @@ void stringFormatEmail() { .isEqualTo("format"); } + @Test + void stringFormatUri() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "uri", null); + assertThatCode(() -> v.validate("https://example.com/path", s, "/v")) + .doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("/relative/path", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("not a uri at all", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + } + + @Test + void stringFormatUriReference() { + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, "uri-reference", null); + assertThatCode(() -> v.validate("https://example.com", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate("/relative/path", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("ht tp://broken", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + } + @Test void integerWithMinMax() { IntegerSchema s = From 6a23811633dad4b05719ddfef02a3a636b4cd04b Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 8 May 2026 20:44:12 +0200 Subject: [PATCH 06/18] feat: Validate string format 'hostname' --- .../retailsvc/http/validate/DefaultValidator.java | 10 +++++++++- .../http/validate/StringIntegerNumberTest.java | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index a226184..49ff23b 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -47,6 +47,11 @@ private record FormatCheck(Predicate isValid, String message) {} private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); + private static final Pattern HOSTNAME = + Pattern.compile( + "^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)" + + "(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"); + private static final Map FORMAT_CHECKS = Map.ofEntries( Map.entry("uuid", new FormatCheck(DefaultValidator::isUuid, "not a valid uuid")), @@ -57,7 +62,10 @@ private record FormatCheck(Predicate isValid, String message) {} Map.entry("uri", new FormatCheck(DefaultValidator::isUri, "not a valid uri")), Map.entry( "uri-reference", - new FormatCheck(DefaultValidator::isUriReference, "not a valid uri-reference"))); + new FormatCheck(DefaultValidator::isUriReference, "not a valid uri-reference")), + Map.entry( + "hostname", + new FormatCheck(s -> HOSTNAME.matcher(s).matches(), "not a valid hostname"))); private final Function refResolver; private final ConcurrentMap compiledPatterns = new ConcurrentHashMap<>(); diff --git a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java index f1ef57c..8e2748e 100644 --- a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +++ b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java @@ -103,6 +103,19 @@ void stringFormatUriReference() { .isEqualTo("format"); } + @Test + void stringFormatHostname() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "hostname", null); + assertThatCode(() -> v.validate("example.com", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate("a.b.c.example", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("-leading-hyphen.com", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("invalid host name", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + } + @Test void integerWithMinMax() { IntegerSchema s = From 4f09f166715156fcae408b8af1273c19af2905cd Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 8 May 2026 20:46:04 +0200 Subject: [PATCH 07/18] feat: Validate string format 'ipv4' --- .../retailsvc/http/validate/DefaultValidator.java | 6 +++++- .../http/validate/StringIntegerNumberTest.java | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index 49ff23b..61858fe 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -47,6 +47,9 @@ private record FormatCheck(Predicate isValid, String message) {} private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); + private static final Pattern IPV4 = + Pattern.compile("^((25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1?\\d?\\d)$"); + private static final Pattern HOSTNAME = Pattern.compile( "^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)" @@ -65,7 +68,8 @@ private record FormatCheck(Predicate isValid, String message) {} new FormatCheck(DefaultValidator::isUriReference, "not a valid uri-reference")), Map.entry( "hostname", - new FormatCheck(s -> HOSTNAME.matcher(s).matches(), "not a valid hostname"))); + new FormatCheck(s -> HOSTNAME.matcher(s).matches(), "not a valid hostname")), + Map.entry("ipv4", new FormatCheck(s -> IPV4.matcher(s).matches(), "not a valid ipv4"))); private final Function refResolver; private final ConcurrentMap compiledPatterns = new ConcurrentHashMap<>(); diff --git a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java index 8e2748e..71cb5c1 100644 --- a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +++ b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java @@ -116,6 +116,20 @@ void stringFormatHostname() { .isEqualTo("format"); } + @Test + void stringFormatIpv4() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "ipv4", null); + assertThatCode(() -> v.validate("192.168.0.1", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate("0.0.0.0", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate("255.255.255.255", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("256.0.0.1", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("1.2.3", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + } + @Test void integerWithMinMax() { IntegerSchema s = From c1c90685d014fa85fbb70a60d6dc7089a3c23877 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 8 May 2026 20:47:11 +0200 Subject: [PATCH 08/18] feat: Validate string format 'ipv6' --- .../http/validate/DefaultValidator.java | 17 ++++++++++++++++- .../http/validate/StringIntegerNumberTest.java | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index 61858fe..836297e 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -50,6 +50,20 @@ private record FormatCheck(Predicate isValid, String message) {} private static final Pattern IPV4 = Pattern.compile("^((25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1?\\d?\\d)$"); + private static final Pattern IPV6 = + Pattern.compile( + "^(" + + "([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}" + + "|([0-9a-fA-F]{1,4}:){1,7}:" + + "|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}" + + "|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}" + + "|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}" + + "|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}" + + "|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}" + + "|[0-9a-fA-F]{1,4}:(:[0-9a-fA-F]{1,4}){1,6}" + + "|:((:[0-9a-fA-F]{1,4}){1,7}|:)" + + ")$"); + private static final Pattern HOSTNAME = Pattern.compile( "^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)" @@ -69,7 +83,8 @@ private record FormatCheck(Predicate isValid, String message) {} Map.entry( "hostname", new FormatCheck(s -> HOSTNAME.matcher(s).matches(), "not a valid hostname")), - Map.entry("ipv4", new FormatCheck(s -> IPV4.matcher(s).matches(), "not a valid ipv4"))); + Map.entry("ipv4", new FormatCheck(s -> IPV4.matcher(s).matches(), "not a valid ipv4")), + Map.entry("ipv6", new FormatCheck(s -> IPV6.matcher(s).matches(), "not a valid ipv6"))); private final Function refResolver; private final ConcurrentMap compiledPatterns = new ConcurrentHashMap<>(); diff --git a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java index 71cb5c1..9c75763 100644 --- a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +++ b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java @@ -130,6 +130,21 @@ void stringFormatIpv4() { .isEqualTo("format"); } + @Test + void stringFormatIpv6() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "ipv6", null); + assertThatCode(() -> v.validate("2001:0db8:85a3:0000:0000:8a2e:0370:7334", s, "/v")) + .doesNotThrowAnyException(); + assertThatCode(() -> v.validate("2001:db8::1", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate("::1", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("not:an:ipv6", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("12345::1", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + } + @Test void integerWithMinMax() { IntegerSchema s = From 12fe5fd67f1d7c5ac29c06d0c5394ba6c08f599c Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 8 May 2026 20:48:26 +0200 Subject: [PATCH 09/18] feat: Validate string format 'regex' --- .../retailsvc/http/validate/DefaultValidator.java | 13 ++++++++++++- .../http/validate/StringIntegerNumberTest.java | 10 ++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index 836297e..60fb9ac 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -38,6 +38,7 @@ import java.util.function.Function; import java.util.function.Predicate; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; public final class DefaultValidator implements Validator { @@ -84,7 +85,8 @@ private record FormatCheck(Predicate isValid, String message) {} "hostname", new FormatCheck(s -> HOSTNAME.matcher(s).matches(), "not a valid hostname")), Map.entry("ipv4", new FormatCheck(s -> IPV4.matcher(s).matches(), "not a valid ipv4")), - Map.entry("ipv6", new FormatCheck(s -> IPV6.matcher(s).matches(), "not a valid ipv6"))); + Map.entry("ipv6", new FormatCheck(s -> IPV6.matcher(s).matches(), "not a valid ipv6")), + Map.entry("regex", new FormatCheck(DefaultValidator::isRegex, "not a valid regex"))); private final Function refResolver; private final ConcurrentMap compiledPatterns = new ConcurrentHashMap<>(); @@ -200,6 +202,15 @@ private static boolean isUri(String s) { } } + private static boolean isRegex(String s) { + try { + Pattern.compile(s); + return true; + } catch (PatternSyntaxException _) { + return false; + } + } + private static boolean isUriReference(String s) { try { new URI(s); diff --git a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java index 9c75763..8c5a2a2 100644 --- a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +++ b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java @@ -187,6 +187,16 @@ void numberAcceptsDoublesAndIntegers() { .isEqualTo("maximum"); } + @Test + void stringFormatRegex() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "regex", null); + assertThatCode(() -> v.validate("^[a-z]+$", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate("\\d{3}-\\d{4}", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("[invalid", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + } + @Test void stringRejectsNonString() { StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, null, null); From d5f35d55651fdd48de32cee5d37c292648904ad0 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 8 May 2026 20:49:58 +0200 Subject: [PATCH 10/18] feat: Validate string format 'byte' --- .../retailsvc/http/validate/DefaultValidator.java | 13 ++++++++++++- .../http/validate/StringIntegerNumberTest.java | 13 +++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index 60fb9ac..d147338 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -27,6 +27,7 @@ import java.time.OffsetDateTime; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Base64; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -86,7 +87,8 @@ private record FormatCheck(Predicate isValid, String message) {} new FormatCheck(s -> HOSTNAME.matcher(s).matches(), "not a valid hostname")), Map.entry("ipv4", new FormatCheck(s -> IPV4.matcher(s).matches(), "not a valid ipv4")), Map.entry("ipv6", new FormatCheck(s -> IPV6.matcher(s).matches(), "not a valid ipv6")), - Map.entry("regex", new FormatCheck(DefaultValidator::isRegex, "not a valid regex"))); + Map.entry("regex", new FormatCheck(DefaultValidator::isRegex, "not a valid regex")), + Map.entry("byte", new FormatCheck(DefaultValidator::isByte, "not valid base64"))); private final Function refResolver; private final ConcurrentMap compiledPatterns = new ConcurrentHashMap<>(); @@ -211,6 +213,15 @@ private static boolean isRegex(String s) { } } + private static boolean isByte(String s) { + try { + Base64.getDecoder().decode(s); + return true; + } catch (IllegalArgumentException _) { + return false; + } + } + private static boolean isUriReference(String s) { try { new URI(s); diff --git a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java index 8c5a2a2..c86cecc 100644 --- a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +++ b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java @@ -197,6 +197,19 @@ void stringFormatRegex() { .isEqualTo("format"); } + @Test + void stringFormatByte() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "byte", null); + assertThatCode(() -> v.validate("aGVsbG8=", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate("", s, "/v")).doesNotThrowAnyException(); + assertThatThrownBy(() -> v.validate("not base64!!", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("a===", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); + } + @Test void stringRejectsNonString() { StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, null, null); From 242c3f487c148a791e6f81c4a5958a84bdbd009f Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 8 May 2026 20:50:51 +0200 Subject: [PATCH 11/18] feat: Recognize 'binary' and 'password' string formats as no-ops --- .../retailsvc/http/validate/DefaultValidator.java | 4 +++- .../http/validate/StringIntegerNumberTest.java | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index d147338..0cd2c87 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -88,7 +88,9 @@ private record FormatCheck(Predicate isValid, String message) {} Map.entry("ipv4", new FormatCheck(s -> IPV4.matcher(s).matches(), "not a valid ipv4")), Map.entry("ipv6", new FormatCheck(s -> IPV6.matcher(s).matches(), "not a valid ipv6")), Map.entry("regex", new FormatCheck(DefaultValidator::isRegex, "not a valid regex")), - Map.entry("byte", new FormatCheck(DefaultValidator::isByte, "not valid base64"))); + Map.entry("byte", new FormatCheck(DefaultValidator::isByte, "not valid base64")), + Map.entry("binary", new FormatCheck(s -> true, "not valid binary")), + Map.entry("password", new FormatCheck(s -> true, "not valid password"))); private final Function refResolver; private final ConcurrentMap compiledPatterns = new ConcurrentHashMap<>(); diff --git a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java index c86cecc..c627bd1 100644 --- a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +++ b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java @@ -210,6 +210,19 @@ void stringFormatByte() { .isEqualTo("format"); } + @Test + void stringFormatBinaryAcceptsAnyString() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "binary", null); + assertThatCode(() -> v.validate("anything goes", s, "/v")).doesNotThrowAnyException(); + assertThatCode(() -> v.validate(" ", s, "/v")).doesNotThrowAnyException(); + } + + @Test + void stringFormatPasswordAcceptsAnyString() { + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "password", null); + assertThatCode(() -> v.validate("anything goes", s, "/v")).doesNotThrowAnyException(); + } + @Test void stringRejectsNonString() { StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, null, null); From caf5dbf99a53268de2e84f2c7b84c8d7efc55570 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 8 May 2026 20:53:12 +0200 Subject: [PATCH 12/18] test: Verify string format validation end-to-end via OpenApiServer Add /format/email and /format/byte operations to openapi.json and openapi.yaml fixtures. Register inline handlers in OpenApiServerIT and add four IT methods covering valid/invalid email and base64 inputs. --- .../com/retailsvc/http/OpenApiServerIT.java | 104 ++++++++++++++++++ src/test/resources/openapi.json | 42 +++++++ src/test/resources/openapi.yaml | 28 +++++ 3 files changed, 174 insertions(+) diff --git a/src/test/java/com/retailsvc/http/OpenApiServerIT.java b/src/test/java/com/retailsvc/http/OpenApiServerIT.java index cfa7e67..945b04b 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerIT.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerIT.java @@ -599,6 +599,110 @@ void postBlockedForbiddenTokenReturns400() { } } + @Nested + class FormatEmail { + + String path = "/format/email"; + + @Test + void formatEmailShouldReturnBadRequestOnInvalidEmail() { + try (var server = + newServer(Map.of("format-email", exchange -> exchange.sendResponseHeaders(200, -1))); + var client = httpClient()) { + + var request = newRequest(server, path + "?addr=not-an-email", "GET", noBody()); + + var response = client.send(request, BodyHandlers.ofString()); + var statusCode = response.statusCode(); + var contentType = response.headers().firstValue("Content-Type").orElse(""); + var responseBody = response.body(); + + assertThat(statusCode).isEqualTo(400); + assertThat(contentType).contains("application/problem+json"); + assertThat(responseBody).contains("\"format\""); + + } catch (IOException e) { + fail(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail(e); + } + } + + @Test + void formatEmailShouldReturnOkOnValidEmail() { + try (var server = + newServer(Map.of("format-email", exchange -> exchange.sendResponseHeaders(200, -1))); + var client = httpClient()) { + + var request = newRequest(server, path + "?addr=user%40example.com", "GET", noBody()); + + var response = client.send(request, BodyHandlers.ofString()); + var statusCode = response.statusCode(); + + assertThat(statusCode).isEqualTo(200); + + } catch (IOException e) { + fail(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail(e); + } + } + } + + @Nested + class FormatByte { + + String path = "/format/byte"; + + @Test + void formatByteShouldReturnBadRequestOnInvalidBase64() { + try (var server = + newServer(Map.of("format-byte", exchange -> exchange.sendResponseHeaders(200, -1))); + var client = httpClient()) { + + var request = newRequest(server, path + "?data=not%20base64!!", "GET", noBody()); + + var response = client.send(request, BodyHandlers.ofString()); + var statusCode = response.statusCode(); + var contentType = response.headers().firstValue("Content-Type").orElse(""); + var responseBody = response.body(); + + assertThat(statusCode).isEqualTo(400); + assertThat(contentType).contains("application/problem+json"); + assertThat(responseBody).contains("\"format\""); + + } catch (IOException e) { + fail(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail(e); + } + } + + @Test + void formatByteShouldReturnOkOnValidBase64() { + try (var server = + newServer(Map.of("format-byte", exchange -> exchange.sendResponseHeaders(200, -1))); + var client = httpClient()) { + + var request = newRequest(server, path + "?data=aGVsbG8%3D", "GET", noBody()); + + var response = client.send(request, BodyHandlers.ofString()); + var statusCode = response.statusCode(); + + assertThat(statusCode).isEqualTo(200); + + } catch (IOException e) { + fail(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail(e); + } + } + } + @Nested class Gates { diff --git a/src/test/resources/openapi.json b/src/test/resources/openapi.json index 70f519e..55a7ea1 100644 --- a/src/test/resources/openapi.json +++ b/src/test/resources/openapi.json @@ -251,6 +251,48 @@ "responses": { "200": { "description": "OK" } } } }, + "/format/email": { + "get": { + "operationId": "format-email", + "parameters": [ + { + "in": "query", + "name": "addr", + "required": true, + "schema": { + "type": "string", + "format": "email" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/format/byte": { + "get": { + "operationId": "format-byte", + "parameters": [ + { + "in": "query", + "name": "data", + "required": true, + "schema": { + "type": "string", + "format": "byte" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/gates": { "post": { "operationId": "post-gate", diff --git a/src/test/resources/openapi.yaml b/src/test/resources/openapi.yaml index 9cf01be..85cb5f9 100644 --- a/src/test/resources/openapi.yaml +++ b/src/test/resources/openapi.yaml @@ -177,6 +177,34 @@ paths: "200": description: OK + /format/email: + get: + operationId: format-email + parameters: + - in: query + name: addr + required: true + schema: + type: string + format: email + responses: + "200": + description: OK + + /format/byte: + get: + operationId: format-byte + parameters: + - in: query + name: data + required: true + schema: + type: string + format: byte + responses: + "200": + description: OK + /gates: post: operationId: post-gate From 0a2333177e2a22b23e92f3ab82361b1995600dd0 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 8 May 2026 20:55:30 +0200 Subject: [PATCH 13/18] test: Lock in 'unknown format silently ignored' contract --- .../retailsvc/http/validate/StringIntegerNumberTest.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java index c627bd1..a27fb1a 100644 --- a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +++ b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java @@ -230,4 +230,12 @@ void stringRejectsNonString() { .extracting(t -> ((ValidationException) t).error().keyword()) .isEqualTo("type"); } + + @Test + void stringFormatUnknownIsIgnored() { + StringSchema s = + new StringSchema( + Set.of(TypeName.STRING), null, null, null, "definitely-not-a-format", null); + assertThatCode(() -> v.validate("anything", s, "/v")).doesNotThrowAnyException(); + } } From 6d6276ed24dccac2b3de3221d78c2b43437b5adc Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 11 May 2026 09:33:37 +0200 Subject: [PATCH 14/18] fix: Reject leading zeros in ipv4 format octets --- .../java/com/retailsvc/http/validate/DefaultValidator.java | 3 ++- .../com/retailsvc/http/validate/StringIntegerNumberTest.java | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index 0cd2c87..7b26620 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -50,7 +50,8 @@ private record FormatCheck(Predicate isValid, String message) {} private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); private static final Pattern IPV4 = - Pattern.compile("^((25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1?\\d?\\d)$"); + Pattern.compile( + "^((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]\\d|\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]\\d|\\d)$"); private static final Pattern IPV6 = Pattern.compile( diff --git a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java index a27fb1a..df22134 100644 --- a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +++ b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java @@ -128,6 +128,9 @@ void stringFormatIpv4() { assertThatThrownBy(() -> v.validate("1.2.3", s, "/v")) .extracting(t -> ((ValidationException) t).error().keyword()) .isEqualTo("format"); + assertThatThrownBy(() -> v.validate("01.02.03.04", s, "/v")) + .extracting(t -> ((ValidationException) t).error().keyword()) + .isEqualTo("format"); } @Test From cc7fd423b5d15521b93717dbad226f95eea82adc Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 11 May 2026 10:29:22 +0200 Subject: [PATCH 15/18] fix: Prevent stack overflow in hostname regex on large inputs Replace nested capturing groups with non-capturing groups and use a possessive quantifier on the outer repetition. This eliminates the recursive-backtracking path Java's regex engine would otherwise follow, addressing the SonarQube finding. --- .../java/com/retailsvc/http/validate/DefaultValidator.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index 7b26620..204088d 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -69,8 +69,8 @@ private record FormatCheck(Predicate isValid, String message) {} private static final Pattern HOSTNAME = Pattern.compile( - "^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)" - + "(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"); + "^(?=.{1,253}$)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*+$"); private static final Map FORMAT_CHECKS = Map.ofEntries( From dfdc2acb746863fd8e64260aec852e4489609be7 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 11 May 2026 10:34:15 +0200 Subject: [PATCH 16/18] refactor: Replace IPv4 and IPv6 regexes with procedural parsers SonarQube flagged the IPv4 and IPv6 patterns as exceeding the allowed regex complexity (21 and 99 respectively, limit is 20). Parsing syntactically by splitting on the separator and validating each segment is both lower-complexity and easier to read. --- .../http/validate/DefaultValidator.java | 102 ++++++++++++++---- 1 file changed, 82 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index 204088d..ca949a7 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -49,24 +49,6 @@ private record FormatCheck(Predicate isValid, String message) {} private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); - private static final Pattern IPV4 = - Pattern.compile( - "^((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]\\d|\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]\\d|\\d)$"); - - private static final Pattern IPV6 = - Pattern.compile( - "^(" - + "([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}" - + "|([0-9a-fA-F]{1,4}:){1,7}:" - + "|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}" - + "|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}" - + "|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}" - + "|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}" - + "|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}" - + "|[0-9a-fA-F]{1,4}:(:[0-9a-fA-F]{1,4}){1,6}" - + "|:((:[0-9a-fA-F]{1,4}){1,7}|:)" - + ")$"); - private static final Pattern HOSTNAME = Pattern.compile( "^(?=.{1,253}$)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" @@ -86,8 +68,8 @@ private record FormatCheck(Predicate isValid, String message) {} Map.entry( "hostname", new FormatCheck(s -> HOSTNAME.matcher(s).matches(), "not a valid hostname")), - Map.entry("ipv4", new FormatCheck(s -> IPV4.matcher(s).matches(), "not a valid ipv4")), - Map.entry("ipv6", new FormatCheck(s -> IPV6.matcher(s).matches(), "not a valid ipv6")), + Map.entry("ipv4", new FormatCheck(DefaultValidator::isIpv4, "not a valid ipv4")), + Map.entry("ipv6", new FormatCheck(DefaultValidator::isIpv6, "not a valid ipv6")), Map.entry("regex", new FormatCheck(DefaultValidator::isRegex, "not a valid regex")), Map.entry("byte", new FormatCheck(DefaultValidator::isByte, "not valid base64")), Map.entry("binary", new FormatCheck(s -> true, "not valid binary")), @@ -234,6 +216,86 @@ private static boolean isUriReference(String s) { } } + private static boolean isIpv4(String s) { + String[] parts = s.split("\\.", -1); + if (parts.length != 4) { + return false; + } + for (String part : parts) { + if (!isIpv4Octet(part)) { + return false; + } + } + return true; + } + + private static boolean isIpv4Octet(String part) { + int len = part.length(); + if (len == 0 || len > 3) { + return false; + } + if (len > 1 && part.charAt(0) == '0') { + return false; + } + int n = 0; + for (int i = 0; i < len; i++) { + char c = part.charAt(i); + if (c < '0' || c > '9') { + return false; + } + n = n * 10 + (c - '0'); + } + return n <= 255; + } + + private static boolean isIpv6(String s) { + int doubleColon = s.indexOf("::"); + if (doubleColon != s.lastIndexOf("::")) { + return false; + } + boolean compressed = doubleColon >= 0; + String[] left; + String[] right; + if (compressed) { + String l = s.substring(0, doubleColon); + String r = s.substring(doubleColon + 2); + left = l.isEmpty() ? new String[0] : l.split(":", -1); + right = r.isEmpty() ? new String[0] : r.split(":", -1); + } else { + left = s.split(":", -1); + right = new String[0]; + } + int total = left.length + right.length; + if (compressed ? total > 7 : total != 8) { + return false; + } + return allHextets(left) && allHextets(right); + } + + private static boolean allHextets(String[] parts) { + for (String hextet : parts) { + if (!isHextet(hextet)) { + return false; + } + } + return true; + } + + private static boolean isHextet(String hextet) { + int len = hextet.length(); + if (len == 0 || len > 4) { + return false; + } + for (int i = 0; i < len; i++) { + char c = hextet.charAt(i); + boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) { + return false; + } + } + return true; + } + private void validateInteger(Object value, IntegerSchema s, String pointer) { long n; switch (value) { From 8688c1a4d914ad98d87d550e60a47af2981506a8 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 11 May 2026 10:38:16 +0200 Subject: [PATCH 17/18] fix: Prevent polynomial backtracking in email regex Switch to possessive quantifiers and exclude '.' from the domain first-label class so the engine cannot backtrack across the literal dot. Addresses SonarQube S5852. --- src/main/java/com/retailsvc/http/validate/DefaultValidator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index ca949a7..4698bc6 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -47,7 +47,7 @@ public final class DefaultValidator implements Validator { private record FormatCheck(Predicate isValid, String message) {} - private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); + private static final Pattern EMAIL = Pattern.compile("^[^\\s@]++@[^\\s@.]++\\.[^\\s@]++$"); private static final Pattern HOSTNAME = Pattern.compile( From 9ae41f6cc2a578c18782f97b7b88f9a55655982d Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 11 May 2026 11:17:54 +0200 Subject: [PATCH 18/18] refactor: Replace magic numbers in IPv4/IPv6 parsers with named constants --- .../http/validate/DefaultValidator.java | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index 4698bc6..ec74abc 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -216,9 +216,16 @@ private static boolean isUriReference(String s) { } } + private static final int IPV4_OCTET_COUNT = 4; + private static final int IPV4_OCTET_MAX_DIGITS = 3; + private static final int IPV4_OCTET_MAX_VALUE = 255; + private static final int DECIMAL_RADIX = 10; + private static final int IPV6_HEXTET_COUNT = 8; + private static final int IPV6_HEXTET_MAX_DIGITS = 4; + private static boolean isIpv4(String s) { String[] parts = s.split("\\.", -1); - if (parts.length != 4) { + if (parts.length != IPV4_OCTET_COUNT) { return false; } for (String part : parts) { @@ -231,7 +238,7 @@ private static boolean isIpv4(String s) { private static boolean isIpv4Octet(String part) { int len = part.length(); - if (len == 0 || len > 3) { + if (len == 0 || len > IPV4_OCTET_MAX_DIGITS) { return false; } if (len > 1 && part.charAt(0) == '0') { @@ -243,9 +250,9 @@ private static boolean isIpv4Octet(String part) { if (c < '0' || c > '9') { return false; } - n = n * 10 + (c - '0'); + n = n * DECIMAL_RADIX + (c - '0'); } - return n <= 255; + return n <= IPV4_OCTET_MAX_VALUE; } private static boolean isIpv6(String s) { @@ -266,7 +273,7 @@ private static boolean isIpv6(String s) { right = new String[0]; } int total = left.length + right.length; - if (compressed ? total > 7 : total != 8) { + if (compressed ? total > IPV6_HEXTET_COUNT - 1 : total != IPV6_HEXTET_COUNT) { return false; } return allHextets(left) && allHextets(right); @@ -283,7 +290,7 @@ private static boolean allHextets(String[] parts) { private static boolean isHextet(String hextet) { int len = hextet.length(); - if (len == 0 || len > 4) { + if (len == 0 || len > IPV6_HEXTET_MAX_DIGITS) { return false; } for (int i = 0; i < len; i++) {