Skip to content

Commit 07a9150

Browse files
dmealingclaude
andcommitted
fix(fr-036): Java TPH per-subtype CREATE validates present values — review finding #6
The TPH per-subtype create bound the annotation-free union <Base>Dto and called repository.createWithType with NO validation, so an over-@maxlength (or missing @required) value on a TPH POST was accepted 201 — while TS/C#/Python validate TPH create. Fix: the generated per-subtype create now validates each body value against the subtype's ANNOTATED <Sub>Dto via validator.validateValue(<Sub>Dto.class, field, dto.<field>()) → 400 {"error":"validation"} before persisting (validateValue applies @NotNull to null, so an absent @required column 400s too — matching the vanilla create's whole-bean validate, scoped to the subtype). The bound body stays the union (the shape createWithType consumes); the validated set is the sibling <Sub>Patch's settable fields (SpringDtoGenerator.settableFields made public static for that SSOT). New shared gate fixtures/api-contract-conformance/tph/scenarios/ tph-create-constraint-violation.yaml (over-@maxlength reference on a per-subtype POST → 400; valid → 201). TS/C#/Python already pass it; Kotlin follows. Verified: codegen-spring 165/165; TPH generated lane 7/7 (incl. the new scenario); vanilla generated lane 25/25. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent 361b3e1 commit 07a9150

3 files changed

Lines changed: 80 additions & 4 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: tph-create-constraint-violation
2+
description: >
3+
FR-036 — the TPH per-subtype CREATE must enforce the subtype's present-value field
4+
constraints, exactly as the vanilla POST and the TPH per-subtype PATCH already do.
5+
The discriminator base's union DTO is annotation-free (a row of any other subtype
6+
stores NULL in a subtype column, so the union carries no @required/@maxLength), so a
7+
per-subtype POST must validate the body against the subtype's ANNOTATED <Sub>Dto
8+
before persisting — an over-@maxLength column is a 400 {error:"validation"}, not a
9+
silent 201. TS/C#/Python already validate TPH create; this scenario gates it cross-port.
10+
11+
`reference` is a @required @maxLength:80 base column shared by every subtype. A create
12+
whose `reference` exceeds 80 chars must be rejected on BOTH subtype segments (the
13+
discriminator is injected from the URL, never the body). A well-formed create still
14+
succeeds (201) — the guard rejects only violations, never valid bodies.
15+
requests:
16+
# r1 — a valid PriorAuth create still succeeds (positive control: the constraint
17+
# guard must not over-reject a well-formed body).
18+
- id: r1
19+
method: POST
20+
path: /api/auths/priorauth
21+
body:
22+
reference: "REF-OK"
23+
approver: "Dr. Smith"
24+
expect:
25+
status: 201
26+
body:
27+
row: { type: "PriorAuth", reference: "REF-OK", approver: "Dr. Smith" }
28+
# r2 — an over-@maxLength(80) `reference` on the PriorAuth segment → 400 validation.
29+
- id: r2
30+
method: POST
31+
path: /api/auths/priorauth
32+
body:
33+
reference: "RXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
34+
approver: "Dr. Smith"
35+
expect:
36+
status: 400
37+
body:
38+
error: "validation"
39+
# r3 — the same over-length `reference` on the Bridge segment → 400 validation
40+
# (cross-subtype: every per-subtype create validates against its own <Sub>Dto).
41+
# `quantity` is supplied valid so the rejection is unambiguously the length constraint.
42+
- id: r3
43+
method: POST
44+
path: /api/auths/bridge
45+
body:
46+
reference: "RXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
47+
quantity: 3
48+
expect:
49+
status: 400
50+
body:
51+
error: "validation"

server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,8 @@ protected void emit(MetaObject entity, Path outRoot) {
377377
* <li>the polymorphic collection — {@code GET /} (union list) + {@code GET /{id}} (any subtype);</li>
378378
* <li>per subtype {@code <seg>} (the {@code @discriminatorValue} lowercased): {@code GET /<seg>}
379379
* (subtype-scoped list), {@code GET /<seg>/{id}} (404 cross-subtype),
380-
* {@code POST /<seg>} (discriminator injected from the URL, never the body),
380+
* {@code POST /<seg>} (discriminator injected from the URL, never the body; body values
381+
* validated against the subtype {@code <Sub>Dto} constraints &rarr; 400 {@code {"error":"validation"}}),
381382
* {@code PATCH|PUT /<seg>/{id}} (404 cross-subtype; discriminator immutable),
382383
* {@code DELETE /<seg>/{id}} (404 cross-subtype).</li>
383384
* </ul>
@@ -547,10 +548,28 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) {
547548
src.append(" .orElseGet(this::notFound);\n");
548549
src.append(" }\n\n");
549550

550-
// per-subtype create (discriminator injected from URL)
551+
// per-subtype create (discriminator injected from URL) — FR-036: validate each body
552+
// value against the subtype's ANNOTATED <Sub>Dto constraints BEFORE persisting, emitting
553+
// the cross-port {"error":"validation"} envelope on any violation (so an over-@maxLength
554+
// or missing-@required column is a 400, not a silent 201). The bound body stays the union
555+
// <Base>Dto (the shape repository.createWithType consumes), but the union is annotation-
556+
// free — so validation runs PER FIELD against the subtype DTO (the annotated shape), the
557+
// exact type the per-subtype PATCH validates against. The validated set is the sibling
558+
// <Sub>Patch's settable fields (effective scalars MINUS PK MINUS discriminator): the PK
559+
// is auto-generated and the discriminator is injected from the URL, so neither is checked.
560+
// validateValue applies @NotNull to a null value, so an absent @required column 400s too —
561+
// matching the vanilla create's whole-bean validate, scoped to this subtype.
562+
List<MetaField> createValidated =
563+
SpringDtoGenerator.settableFields(st.entity(), TphPlan.discriminatorFieldOf(st.entity()));
551564
src.append(" @PostMapping(\"/").append(seg).append("\")\n");
552-
src.append(" public ResponseEntity<").append(dtoName).append("> create").append(suffix)
565+
src.append(" public ResponseEntity<?> create").append(suffix)
553566
.append("(@RequestBody ").append(dtoName).append(" dto) {\n");
567+
for (MetaField vf : createValidated) {
568+
src.append(" if (!validator.validateValue(").append(subDto).append(".class, \"")
569+
.append(vf.getName()).append("\", dto.").append(vf.getName()).append("()).isEmpty()) {\n");
570+
src.append(" return ResponseEntity.badRequest().body(Map.of(\"error\", \"validation\"));\n");
571+
src.append(" }\n");
572+
}
554573
src.append(" ").append(dtoName).append(" saved = repository.createWithType(\"").append(disc).append("\", dto);\n");
555574
src.append(" return ResponseEntity.status(HttpStatus.CREATED).body(saved);\n");
556575
src.append(" }\n\n");

server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,8 +281,14 @@ private static List<MetaField> settableFields(MetaObject entity) {
281281
* As {@link #settableFields(MetaObject)}, additionally excluding the field named
282282
* {@code alsoExclude} (or nothing when {@code null}) — the FR-036 TPH per-subtype patch passes
283283
* the discriminator field name so the immutable discriminator is never a settable member.
284+
*
285+
* <p>Public so {@link SpringControllerGenerator#emitTph} can validate a TPH per-subtype CREATE
286+
* body against exactly the field set the sibling {@code <Sub>Patch} covers (effective scalars
287+
* MINUS the primary key MINUS the discriminator), keeping create + patch validation on one
288+
* SSOT: the PK is auto-generated and the discriminator is injected from the URL, so neither is
289+
* validated from the body.</p>
284290
*/
285-
private static List<MetaField> settableFields(MetaObject entity, String alsoExclude) {
291+
public static List<MetaField> settableFields(MetaObject entity, String alsoExclude) {
286292
List<String> pkFields = entity.getIdentities(true).stream()
287293
.filter(MetaIdentity::isPrimary)
288294
.findFirst()

0 commit comments

Comments
 (0)