Skip to content

Bind controller arguments from request params; parse urlencoded form bodies - #54

Merged
ghosthack merged 6 commits into
masterfrom
claude/vigilant-allen-k2k12x
Sep 27, 2026
Merged

ghosthack merged 6 commits into
masterfrom
claude/vigilant-allen-k2k12x

Conversation

@ghosthack

@ghosthack ghosthack commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to #53, for 5.0.0 (not yet tagged).

Summary

Controller argument binding (d90b4d6, 0213ee8, dec0f0c, 8f72170)

  • Annotated route methods can take arguments. The argument name is enough; @Param is optional:
    @GET("/items/:id")
    void item(int id, String q) { ... }                  // binds "id" and "q"
    
    @GET("/cart")                                        // ?sku=A1&sku=B2&qty=1&qty=3
    void cart(String[] sku, List<Integer> qty, BigDecimal discount, Boolean gift) { ... }
  • Each argument is looked up like param() (path, then query, then form fields).
  • Types:
    • String, primitives and their wrappers (boolean/Boolean accept true/false in any case), BigInteger, BigDecimal, enums (by constant name) and UUID.
    • A Context argument receives the request context and an InputStream argument the request body.
  • Arrays and collections collect every value of a repeated parameter, in order. An absent parameter gives an empty one.
    • Supported forms: an array of any of those types, or a List, Collection, Iterable or Set of one (List<? extends X> uses X).
    • A Set is a LinkedHashSet (request order, duplicates dropped); the others are an ArrayList. Each request gets a fresh, mutable collection.
    • Rejected at registration: nested or unsupported arrays, and collections without a concrete element type (raw List, List<?>, List<Object>, List<? super X>, type variables, nested collections), as well as concrete collection classes.
  • Big numbers are capped at 1000 characters, and a BigDecimal exponent at ±1000, because parsing or printing far larger values grows much faster than the input. A 20-character d=1e999999999 would otherwise print as a billion digits. Over the cap gets 400.
  • A value that can't be converted, a bad array or collection element, or a missing value for a primitive gets 400 Bad Request. A missing value for any other type is passed as null.
  • Names: without @Param, the Java parameter name is read from the class file:
    • from -parameters metadata when the class was compiled with it;
    • otherwise from the LocalVariableTable written by debug info (-g), the default in Maven, Gradle and IDE builds.
    • Only classes compiled with neither need @Param; controller() rejects them at registration with a message saying so.
    • ParameterNames is a small, dependency-free class-file reader, and anything unexpected falls back to that error.

Repeated parameters (dec0f0c)

  • New Turismo.paramValues(name), queryValues(name) and formValues(name) return every value of a repeated name. paramValues looks in the same places as param().
  • Context gains queryValues(name), with a default that returns query(name) so custom contexts keep compiling.
  • Behavior change: param()/query() now return the first value of a repeated query parameter. 4.x returned the last. This matches form fields and servlet getParameter, and is listed in the README's "Upgrading to 5.0".

Form bodies (3e66afc)

  • application/x-www-form-urlencoded bodies are parsed on first use:
    • form(name), formFields() and formValues(name) read the fields.
    • param(name) falls back to form fields after path and query parameters.
  • The charset comes from Content-Type and defaults to UTF-8.
  • After the form has been read, body() and context().body() replay the raw bytes.
  • App.setMaxFormSize(bytes) sets the size limit (default 2 MB). Oversized bodies get 413; a malformed body gets 400.

Review fixes (0940d51)

  • Error replaces partial output: a 400 or 413 raised mid-handler now replaces whatever the handler had already written, headers included (new default Context.reset()).
  • context().body() now goes through the same form-aware body as Turismo.body().
  • Raw body read first: asking for the form fields of a form request after taking its raw body stream now throws a clear IllegalStateException instead of returning no fields.
  • Rename: forms() is now formFields().

Testing

  • mvn verify passes, with 243 tests, and javadoc builds.
  • Each of the six commits was also built and tested on its own, so every step of a rebase-merge onto master is green.
  • ArgumentTypesTest covers:
    • big numbers, including the length and exponent caps
    • strict booleans
    • arrays and every collection type, including empty ones, Set ordering and de-duplication, mutability, and bad elements
    • bounded wildcards, and every rejected element-type shape
    • a path parameter bound to an array, and form checkboxes bound to arrays and sets
    • paramValues lookup order
  • ServerTest covers repeated query parameters through the real HttpContext.
  • ParameterNamesTest compiles a controller at test time with -g, -g:none and -parameters.

Branch history

The branch was rebuilt as these six commits directly on top of master (6438897), dropping the stale pre-squash copies of #52 and #53 so that "Rebase and merge" works. The final tree is byte-identical to the previous tip (ec6075f).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Leqha458EwWbRT5xWiVuyg

Annotated route methods can now take arguments instead of calling
param(): each is looked up by @PARAM name (or the Java parameter name
when compiled with -parameters) from path then query parameters, and
converted to String, primitives/wrappers, enums or UUID. Context and
InputStream arguments get the request context and body. Unconvertible
values, and missing values for primitives, answer 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Leqha458EwWbRT5xWiVuyg
Fields of an application/x-www-form-urlencoded body are read lazily with
form()/forms(), and param() falls back to them after path and query
parameters, so controller method arguments bind form fields too. The
body is decoded with the request charset (UTF-8 by default) and stays
readable through body(). Bodies over App.setMaxFormSize (2 MB default)
get 413, malformed ones 400, via a RequestException that App.handle
turns into a response; controller argument errors use it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Leqha458EwWbRT5xWiVuyg
- A 400/413 raised mid-handler (bad form body, unconvertible controller
  argument) now replaces the partial response instead of being appended
  to it. Context gains a default reset() hook that HttpContext
  implements; App.handle calls it before writing the error.
- context().body() now goes through the same form-aware body as
  Turismo.body(), so both replay the body after the form was parsed.
- Reading form fields after the handler took the raw body stream now
  throws IllegalStateException with a clear message, instead of quietly
  returning no fields.
- Rename forms() to formFields() (unreleased API).
- @PARAM javadoc: lookup includes form fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Leqha458EwWbRT5xWiVuyg
When a controller class is compiled without -parameters, read the
parameter names from the class file's LocalVariableTable instead. That
table is written with debug information (-g), the default for Maven,
Gradle and IDE builds, so plain `void item(int id)` binds "id" without
@PARAM in the common case. Only classes compiled with neither -g nor
-parameters still need @PARAM, and the registration error now says so.

ParameterNames is a small, dependency-free class-file reader that only
walks far enough to reach the method's Code/LocalVariableTable; any
surprise yields null and the existing error path.

Tests compile a controller at runtime with -g, -g:none and -parameters
to cover each case, including long/double slot widths and static
methods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Leqha458EwWbRT5xWiVuyg
Controller arguments:
- BigInteger and BigDecimal, capped at 1000 characters and (for
  BigDecimal) a scale magnitude of 1000; beyond that parsing or printing
  grows far faster than the input, so a short request like
  d=1e999999999 could otherwise tie up the server. Over the cap is 400.
- Arrays of any supported type (String[], int[], Boolean[],
  BigDecimal[], enum arrays, ...) collect every value of a repeated
  parameter in order; an absent parameter gives an empty array, a bad
  element 400. Nested or unsupported arrays fail at registration.

Repeated parameters:
- Context.queryValues(name), with a default so custom contexts still
  compile; HttpContext keeps all values and query() now returns the
  first (was the last), matching form fields and servlet getParameter.
- Form keeps all values of a repeated field.
- New Turismo.paramValues/queryValues/formValues. paramValues looks in
  the same places as param() and returns all values from the first
  place that has the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Leqha458EwWbRT5xWiVuyg
A List<T>, Collection<T>, Iterable<T> or Set<T> argument, where T is any
type an array argument accepts, collects every value of a repeated
parameter. The element type comes from the declared type argument
(List<? extends X> uses X). Set gives a LinkedHashSet, so request order
is kept and duplicates dropped; the others an ArrayList. Each request
gets a fresh, mutable collection, empty if the parameter is absent. A
bad element is 400.

A raw type, List<?>, List<Object>, a lower-bounded wildcard, a type
variable or a nested collection has no usable element type and is
rejected at registration, as are concrete collection classes.

testControllerRejectsUnsupportedParameterType used List<String> as its
unsupported example; it now uses Map<String, String>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Leqha458EwWbRT5xWiVuyg
@ghosthack
ghosthack force-pushed the claude/vigilant-allen-k2k12x branch from ec6075f to 8f72170 Compare September 27, 2026 13:31
@ghosthack
ghosthack merged commit 7f13db9 into master Sep 27, 2026
6 checks passed
@ghosthack
ghosthack deleted the claude/vigilant-allen-k2k12x branch September 27, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants