Skip to content

Add @FormPrefix for nested form bean binding - #811

Open
ah3nan wants to merge 7 commits into
avaje:masterfrom
ah3nan:feature/form-prefix
Open

ah3nan wants to merge 7 commits into
avaje:masterfrom
ah3nan:feature/form-prefix

Conversation

@ah3nan

@ah3nan ah3nan commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Problem

Form beans (@Form/@BeanParam) only supported flat form parameters. Nested objects had to be flattened manually, and RESTEasy-style prefixed binding (invoice.street, shipping.street) had no support.

Feature

Adds a new @FormPrefix(String value) annotation (FIELD target) for a field/component on a @Form/@BeanParam bean. The nested object is populated from form parameters prefixed with the given value:

public class Person {
  @FormParam("name")     public String name;
  @FormPrefix("invoice") public Address invoice;
  @FormPrefix("shipping") public Address shipping;
}
public class Address {
  @FormParam("street") public String street;
  @FormParam("zip")    private String postalCode;  // setter-based
  @FormPrefix("contact") public ContactForm contact; // deep nesting
}

submitted as name=bill&invoice.street=Main+St&invoice.zip=12345&invoice.contact.phone=555....

Records are supported too — nested @FormPrefix records are built via their canonical constructor and passed into the parent constructor:

public record Customer(String firstName, List<String> lastName, @FormPrefix("invoice") InvoiceForm invoice) {}

Changes

  • avaje-http-api: new io.avaje.http.api.FormPrefix annotation.
  • http-generator-core:
    • ParamType: new FORMNESTED.
    • ElementReader: detect @FormPrefix, apply the dotted prefix to form lookups (formParamName()), recursive imports for nested types, form-bean var/constructor wiring.
    • BeanParamReader: recursive nested population — constructor-based for records / single-constructor types, no-arg + setter/field for POJOs; unique local variable names so repeated field names under different prefixes don't collide; nested constructor params are built before the parent.
  • All platforms share the core logic; nested reads use each platform's existing form-param access (Helidon, Javalin, Vert.x, Jex, Sigma).

Generated code (Helidon, record example)

var nested = new Customer.InvoiceForm(
    formParams.contains("invoice.street") ? formParams.get("invoice.street") : null,
    formParams.contains("invoice.city") ? formParams.get("invoice.city") : null);

var customer = new Customer(
    formParams.contains("firstName") ? formParams.get("firstName") : null,
    list(Object::toString, formParams.all("lastName", () -> java.util.List.of())),
    nested);

Verification

  • New integration tests (exact-body assertions: full binding, partial submit → nulls, repeated-field-name collision, records) added to all 5 test modules: test-nima (Helidon), test-javalin, test-jex, test-vertx (each with FormPrefixControllerTest + RecordFormControllerTest), plus explicit assertions in test-sigma's SigmaProcessorTest.
  • tests/test-vertx/src/test/resources/expectedOpenApi.json updated for the new /formprefix and /recordform routes.
  • mvn clean package -Ptest → BUILD SUCCESS across all modules.

Notes / future work

  • Nested beans must be constructible from their form fields (no-arg + setters/fields for POJOs, or a matching constructor/record).
  • Lists/maps of beans (telephoneNumbers[0].countryCode, address[INVOICE].street) are a follow-up (needs runtime key enumeration via Parameters.names()).

@SentryMan
SentryMan requested a review from rbygrave September 11, 2026 15:19
@SentryMan SentryMan added the enhancement New feature or request label Sep 11, 2026
@SentryMan SentryMan added this to the 3.11 milestone Sep 11, 2026

@SentryMan SentryMan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rbygrave seems most of the changes are just tests, so it's not that bad to review

ah3nan and others added 6 commits September 13, 2026 09:09
# Conflicts:
#	http-generator-core/src/main/java/io/avaje/http/generator/core/ElementReader.java
… invalid Java identifier

This produces a compilation error on the generated code:

```
    var shippingCustomer-info = new ContactForm();
    shippingCustomer-info.phone = formParams.contains("shipping.customer-info.phone") ? formParams.get("shipping.customer-info.phone") : null;
    shippingCustomer-info.email = formParams.contains("shipping.customer-info.email") ? formParams.get("shipping.customer-info.email") : null;
```
…valid Java identifiers

Uses new BeanParamReader.toJavaIdentifier() to convert required chars to underscore

This now produces the generated code (with valid java identifiers):
```
    var shippingCustomer_info = new ContactForm();
    shippingCustomer_info.phone = formParams.contains("shipping.customer-info.phone") ? formParams.get("shipping.customer-info.phone") : null;
    shippingCustomer_info.email = formParams.contains("shipping.customer-info.email") ? formParams.get("shipping.customer-info.email") : null;

    shipping.contact = shippingCustomer_info;
```
@rbygrave

Copy link
Copy Markdown
Contributor

Hi @ah3nan @SentryMan - I have added some commits with a failing test case for @FormPrefix("customer-info"), and associated fix (to ensure valid java identifier is used).

The change to ensure valid java identifier converts unsupported chars like hyphen - to underscore _. This could technically produce duplicate variable names but I think thats super unlikely and I prefer to keep BeanParamReader.toJavaIdentifier() simple.

Please review those last commits and post back if you are happy with those.

Thanks, Rob.

@rbygrave
rbygrave requested a review from SentryMan September 14, 2026 22:02
@rbygrave

Copy link
Copy Markdown
Contributor

FYI: The failed build there was because this PR lost the avoidTypeShadowing() change (probably due to ordering of the PRs) and I added a test case to exercise the avoidTypeShadowing() - so that was the failing test, and hence that commit to restore the avoidTypeShadowing() change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants