Skip to content

Commit fb08573

Browse files
gpascucciclaude
andauthored
feat(validation): declarative @Valid on write DTOs (Story 29.13) (#338)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0f552f4 commit fb08573

4 files changed

Lines changed: 49 additions & 6 deletions

File tree

backend/src/main/java/ca/bc/gov/nrs/ilcr/codetable/api/CodeTableApi.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import ca.bc.gov.nrs.ilcr.codetable.dto.CodeTableEntry;
44
import ca.bc.gov.nrs.ilcr.codetable.dto.CodeTableSaveResponse;
55
import ca.bc.gov.nrs.ilcr.codetable.dto.CodeTableSummary;
6+
import jakarta.validation.Valid;
67
import java.util.List;
78
import org.springframework.http.ResponseEntity;
89
import org.springframework.security.core.Authentication;
@@ -55,6 +56,6 @@ ResponseEntity<List<CodeTableEntry>> getEntries(
5556
@PutMapping("/{tableKey}/entries")
5657
ResponseEntity<CodeTableSaveResponse> saveEntry(
5758
@PathVariable String tableKey,
58-
@RequestBody CodeTableEntry entry,
59+
@Valid @RequestBody CodeTableEntry entry,
5960
Authentication authentication);
6061
}
Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,35 @@
11
package ca.bc.gov.nrs.ilcr.codetable.dto;
22

3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.NotNull;
5+
import jakarta.validation.constraints.Size;
36
import java.time.LocalDate;
47

58
/**
69
* One row of a lookup code table (Story 24.3 / UC-CODE-001, BR-02): a code, its description, and the
710
* effective/expiry window that gates whether downstream schedules offer it for a given year (BR-07).
811
*
9-
* @param code the code value (the table's primary key); {@code null}/blank for a Contractual add
10-
* @param description the human-readable label
11-
* @param effectiveDate first day the code is offered (inclusive); {@code null} = no lower bound
12+
* <p>Declarative constraints (Story 29.13) give the {@code saveEntry} write path a uniform 400
13+
* {@code ProblemDetail} for shape violations via {@code @Valid} — the same error shape as the rest of
14+
* the API — WITHOUT replacing the authoritative service-layer checks. {@code CodeTableService.validate}
15+
* still owns the required/length/date-order rules and their verbatim legacy message keys (FLD-001..005,
16+
* BR-06); these annotations are a declarative front line, not a replacement.
17+
*
18+
* <p>The {@code @Size} caps are deliberately OUTER BOUNDS — the maximum across every table
19+
* ({@code CodeTableRegistry}: code ≤ 20, description ≤ 500) — because a record-level annotation carries
20+
* one number but the real caps are PER-TABLE ({@code table.codeMaxLength()} /
21+
* {@code descriptionMaxLength()}). Keeping the annotation at the outer bound means the exact per-table
22+
* length rejection still comes from {@code validate()} with its verbatim message; the annotation only
23+
* catches absurd input early. Do NOT tighten these to a single table's cap.
24+
*
25+
* @param code the code value (the table's primary key); required + within the table's code cap on save
26+
* @param description the human-readable label; required + within the table's description cap on save
27+
* @param effectiveDate first day the code is offered (inclusive); required on save
1228
* @param expiryDate last day the code is offered (inclusive); {@code null} = never expires
1329
*/
1430
public record CodeTableEntry(
15-
String code, String description, LocalDate effectiveDate, LocalDate expiryDate) {
31+
@NotBlank(message = "{codeRequiredErrorMsg}") @Size(max = 20) String code,
32+
@NotBlank(message = "{descriptionRequiredErrorMsg}") @Size(max = 500) String description,
33+
@NotNull(message = "{effectiveDateRequiredErrorMsg}") LocalDate effectiveDate,
34+
LocalDate expiryDate) {
1635
}

backend/src/main/java/ca/bc/gov/nrs/ilcr/reporting/api/ReportApi.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package ca.bc.gov.nrs.ilcr.reporting.api;
22

3+
import jakarta.validation.Valid;
34
import org.springframework.http.ResponseEntity;
45
import org.springframework.security.core.Authentication;
56
import org.springframework.web.bind.annotation.GetMapping;
@@ -69,10 +70,15 @@ ResponseEntity<StreamingResponseBody> getSchedule9Pdf(
6970
* @param authentication the caller (authorized for VIEW_SCHEDULE — print is read-only, BR-01)
7071
* @return 200 streaming the combined PDF ({@code application/pdf} + attachment Content-Disposition)
7172
*/
73+
// @Valid here is a forward-looking safeguard (Story 29.13): PrintRequest is today an all-Boolean
74+
// record whose compact constructor defaults every omitted flag to false, so there is nothing to
75+
// constrain and @Valid is a no-op. It is declared so that if a non-Boolean field is ever added to
76+
// PrintRequest, adding a constraint to that field is all it takes to have it enforced — the wiring
77+
// is already here. Do NOT add constraints to the Boolean flags.
7278
@PostMapping("/print")
7379
ResponseEntity<StreamingResponseBody> printSchedules(
7480
@RequestParam(required = false) String millId,
7581
@RequestParam(required = false) String year,
76-
@RequestBody PrintRequest request,
82+
@Valid @RequestBody PrintRequest request,
7783
Authentication authentication);
7884
}

backend/src/test/java/ca/bc/gov/nrs/ilcr/codetable/CodeTableIT.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,23 @@ void invalidDateRange_is400() throws Exception {
9393
.andExpect(jsonPath("$[*].code", not(hasItem("IT2"))));
9494
}
9595

96+
@Test
97+
@DisplayName("blank code is rejected 400 problem+json by @Valid (Story 29.13, uniform shape)")
98+
void blankCode_is400FromBeanValidation() throws Exception {
99+
// @NotBlank on CodeTableEntry.code fails during request-body binding — BEFORE the controller and
100+
// service run — so the uniform MethodArgumentNotValid handler answers with the same ProblemDetail
101+
// shape every other 400 uses (title "Validation Failed"). The service-layer codeRequiredErrorMsg
102+
// check remains as the authoritative belt-and-suspenders layer for the direct-service path.
103+
String body = """
104+
{"code":"","description":"Blank code","effectiveDate":"2020-01-01"}""";
105+
mockMvc.perform(put(UNIT_ENTRIES).with(groups("ILCR_ADMIN"))
106+
.contentType(MediaType.APPLICATION_JSON).content(body))
107+
.andExpect(status().isBadRequest())
108+
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
109+
.andExpect(jsonPath("$.title").value("Validation Failed"))
110+
.andExpect(jsonPath("$.detail").value("Code: Value is required."));
111+
}
112+
96113
@Test
97114
@DisplayName("ILCR_SUBMITTER is denied write — 403 (S13, admin-only action)")
98115
void submitter_isForbidden() throws Exception {

0 commit comments

Comments
 (0)