Code generation REST client
View MarkdownStatus: Stable API reference: javadoc.io
Overview
Section titled “Overview”vertique-codegen-rest-client is an annotation processor that eliminates per-call reflection in the REST client hot path. The bottleneck is RestClientRequestFactory.extractFieldValue, which runs on every call that uses @BeanParam: it iterates getRecordComponents() or walks the class hierarchy via getDeclaredField(), calls setAccessible(true), and invokes Method.invoke() / Field.get() — per call, no caching.
The processor generates two artifact types per compilation unit:
{Client}_RestClientProxy— apublic finalclass implementing the@RestClientinterface. It holdsClientMethodMetareferences infinalfields (resolved once at construction time from the pre-builtMap<Method, ClientMethodMeta>) and calls accessor methods directly, with no per-call reflection.{Bean}_BeanParamAccessor— apublic finalclass implementingBeanParamAccessor<T>. It dispatches over field names via aswitchexpression calling record accessors, public getters, or direct field access — whichever is applicable — with nosetAccessibleor reflective dispatch on the hot path.
Runtime selection is transparent: RestClientBuilder.build() and RestClientFactory.builder() both try Class.forName(clientInterface.getName() + "_RestClientProxy") before falling back to the existing JDK proxy. The application processor boundary described below is the only Maven setup required.
In addition to the performance win, the processor lifts structural validation to compile time: @Path placeholder / @PathParam mismatches, missing HTTP verb annotations, and non-Future<T> return types all surface as build errors.
Adoption
Section titled “Adoption”Applications inheriting vertique-app-parent declare vertique-rest-client as a runtime
dependency and receive the complete processor facade automatically. Custom-parent applications
import vertique-bom and configure only the versionless Dagger and vertique-codegen-all
processor paths. See docs/packaging.md. The runtime selects generated proxies when present and
retains the JDK proxy as its fallback.
Key Classes
Section titled “Key Classes”Processor Options
Section titled “Processor Options”RestClientProcessor is registered automatically via META-INF/services/javax.annotation.processing.Processor and recognizes @RestClient-annotated interfaces. It supports two options:
@SupportedOptions({ "vertique.codegen.package", "vertique.codegen.restclient.warnExternalBeans" // default: true})Note:
vertique.codegen.packageis currently not end-to-end for REST client artifacts — the override is honored when generating the bean-param accessor and by the scanner’s same-package access check, but the proxy generator does not add the required import for the@RestClientinterface, and the runtimeBeanParamAccessorRegistryderives the accessor FQN from the bean’s binary name (not the override package). Setting this option for@RestClientinterfaces will produce a generated proxy that fails to compile and/or accessors that the runtime cannot discover. Leave the option unset for projects using@RestClientuntil the override path is completed.
Validators
Section titled “Validators”Each validator emits compile-time diagnostics via a shared Diagnostics helper (from vertique-codegen-core):
| Validator | What it checks | Diagnostic |
|---|---|---|
ReturnTypeValidator |
Method must return Future<T> |
ERROR |
PathPlaceholderValidator |
{name} in @Path must have a matching @PathParam("name") and vice versa |
ERROR |
HttpVerbValidator |
Non-default methods must have one of @GET/@POST/@PUT/@DELETE/@PATCH/@HEAD; @OPTIONS intentionally rejected to match the runtime scanner’s supported set |
ERROR |
Generated {Client}_RestClientProxy
Section titled “Generated {Client}_RestClientProxy”Generates {ClientSimpleName}_RestClientProxy in the origin interface’s package. Key properties of the generated class:
- Static
Methodconstants resolved once in the class initializer via{Interface}.class.getDeclaredMethod(...). Signature-precise and overload-safe — matches theMap<Method, ClientMethodMeta>shape the runtime scanner already produces. - Constructor:
(RestClientDispatcher dispatcher, BeanParamAccessorRegistry registry, Map<Method, ClientMethodMeta> methodMetas). Per-methodClientMethodMetareferences are stored infinalfields for O(1) access on the hot path. - Each method body calls
dispatcher.newRequest(meta), then for every PATH/QUERY/HEADER/COOKIE parameter calls the matchingdispatcher.applyPathParam/applyQueryParam/applyHeaderParam/applyCookieParam(req, meta, name, value, defaultValue)— passing the raw typed value, not a.toString()-serialized one — and finally callsdispatcher.send(req, meta). Outbound serialization runs through the dispatcher’s param-conversion resolver, so aUUID,java.timetype, enum, or app-registered converter serializes consistently with the inbound jaxrs side.@BeanParamfields route through the same fourapply*Paramcalls, keyed by each field’s JAX-RS wire name. - A
nullrequired@PathParam(top-level or a@BeanParamfield) with no@DefaultValuethrowsRestClientExceptionfrom the generated proxy before the dispatcher call — the reflective fallback (RestClientRequestFactory.collectParamsWithMeta) fails identically, so both proxy flavors reject the call at the same point.
Example for a two-parameter method:
@Generated("dev.vertique.codegen.rest.client.processor.RestClientProcessor")public final class UserClient_RestClientProxy implements UserClient {
private static final Method M_GET_USER; static { try { M_GET_USER = UserClient.class.getDeclaredMethod("getUser", String.class, PageRequest.class); } catch (NoSuchMethodException e) { throw new ExceptionInInitializerError(e); } }
private final RestClientDispatcher dispatcher; private final BeanParamAccessor<PageRequest> pageRequestAccessor; private final ClientMethodMeta metaGetUser;
public UserClient_RestClientProxy( RestClientDispatcher dispatcher, BeanParamAccessorRegistry registry, Map<Method, ClientMethodMeta> methodMetas) { this.dispatcher = dispatcher; this.pageRequestAccessor = registry.resolve(PageRequest.class); this.metaGetUser = methodMetas.get(M_GET_USER); }
@Override public Future<User> getUser(String id, PageRequest paging) { RestRequestBuilder req = dispatcher.newRequest(metaGetUser); if (id == null) { throw new RestClientException("path param 'id' was null and has no @DefaultValue"); } req = dispatcher.applyPathParam(req, metaGetUser, "id", id, null); req = dispatcher.applyQueryParam(req, metaGetUser, "page", pageRequestAccessor.extract(paging, "page"), null); req = dispatcher.applyQueryParam(req, metaGetUser, "size", pageRequestAccessor.extract(paging, "size"), null); req = dispatcher.applyHeaderParam(req, metaGetUser, "X-Sort", pageRequestAccessor.extract(paging, "sort"), null); return dispatcher.send(req, metaGetUser); }}Generated {Bean}_BeanParamAccessor
Section titled “Generated {Bean}_BeanParamAccessor”Generates {BeanSimpleName}_BeanParamAccessor in the bean type’s package. Uses a switch expression over field names for both record and class bean types, so the JIT can apply tableswitch or indy-string dispatch. The generated class has a public no-arg constructor, which is required by BeanParamAccessorRegistry.resolve().
Field deduplication is subclass-wins: when a subclass field shadows a superclass field of the same name, only the subclass field is emitted, keyed by field name. This prevents duplicate case labels in the generated switch expression (which would fail to compile in the consumer’s build) and matches Java field-hiding semantics.
Example for a record bean:
@Generated("dev.vertique.codegen.rest.client.processor.RestClientProcessor")public final class PageRequest_BeanParamAccessor implements BeanParamAccessor<PageRequest> {
public PageRequest_BeanParamAccessor() {}
@Override public Object extract(PageRequest bean, String fieldName) { return switch (fieldName) { case "page" -> bean.page(); case "size" -> bean.size(); case "sort" -> bean.sort(); default -> throw new IllegalArgumentException("Unknown field: " + fieldName); }; }
@Override public List<String> fieldNames() { return List.of("page", "size", "sort"); }}Generated Output
Section titled “Generated Output”Per-field Access Path Resolution
Section titled “Per-field Access Path Resolution”For each field in a @BeanParam type, the emitter selects the first applicable path (in order):
- Record component — call
bean.{name}()(the generated accessor method). - Public or protected getter — call
bean.get{Name}()orbean.is{Name}(). - Same-package field — emit direct field access
bean.{name}(the generated class is in the same package as the bean, so package-private and public fields are accessible withoutsetAccessible). - None of the above — abort emission for the entire bean (see “Bean-level fallback” below).
Bean-level Fallback
Section titled “Bean-level Fallback”If any field on a bean has no accessible path (step 4 above), the processor does NOT emit an accessor for that bean. Instead, a NOTE-level diagnostic is emitted:
{BeanType} has fields not accessible without setAccessible; runtime reflective fallback appliesAt runtime, BeanParamAccessorRegistry.resolve(beanType) gets a ClassNotFoundException for the missing _BeanParamAccessor class and returns the ReflectiveBeanParamAccessor fallback, which handles the entire bean as today.
There are no partial accessors. Partial generation would silently drop fields that the runtime currently reads via setAccessible-driven private-field reflection.
Deduplication
Section titled “Deduplication”When multiple @RestClient interfaces in the same compilation unit reference the same @BeanParam type, exactly ONE {Bean}_BeanParamAccessor is emitted (keyed by bean type FQN). Both client proxies resolve the same accessor at runtime.
Runtime Integration
Section titled “Runtime Integration”Class.forName Discovery Contract
Section titled “Class.forName Discovery Contract”Both discovery lookups follow the same pattern: one reflective call per type at first resolution, then cached forever in a ConcurrentHashMap.
Proxy lookup (in RestClientBuilder.build):
String generatedFqn = clientInterface.getName() + "_RestClientProxy";try { Class<?> generated = Class.forName(generatedFqn, true, clientInterface.getClassLoader()); Constructor<?> ctor = generated.getDeclaredConstructor( RestClientDispatcher.class, BeanParamAccessorRegistry.class, Map.class); return clientInterface.cast(ctor.newInstance(dispatcher, registry, methodMetas));} catch (ClassNotFoundException e) { return buildReflectiveProxy(clientInterface); // existing JDK proxy path} catch (ReflectiveOperationException e) { throw new RestClientConfigurationException( "Generated proxy %s present but failed to instantiate".formatted(generatedFqn), e);}Accessor lookup (in BeanParamAccessorRegistry.resolve):
// Backed by ClassValue — resolution runs at most once per (type, classloader) pair.String generatedFqn = beanType.getName() + "_BeanParamAccessor";try { Class<?> generatedClass = Class.forName(generatedFqn, true, beanType.getClassLoader()); return (BeanParamAccessor<?>) generatedClass.getDeclaredConstructor().newInstance();} catch (ClassNotFoundException e) { return fallback; // ReflectiveBeanParamAccessor handles the whole bean} catch (ReflectiveOperationException e) { throw new RestClientConfigurationException( "Generated bean accessor %s present but failed to instantiate".formatted(generatedFqn), e);}BeanParamAccessorRegistry Ownership
Section titled “BeanParamAccessorRegistry Ownership”The registry is process-wide. BeanParamAccessorRegistry.shared() returns a static singleton backed by ReflectiveBeanParamAccessor as the fallback. RestClientBuilder defaults to this shared instance so standalone construction (new RestClientBuilder(vertx), RestClientBuilder.create(vertx)) and Dagger-injected construction (RestClientFactory) use the same lookup cache. RestClientModule provides a BeanParamAccessorRegistry binding that returns BeanParamAccessorRegistry.shared(). RestClientBuilder.beanParamAccessorRegistry(...) allows override (e.g. in tests).
The registry’s internal lookup table is backed by ClassValue<BeanParamAccessor<?>> rather than a static final ConcurrentHashMap. ClassValue is classloader-scoped: each classloader gets its own entry, so the registry does not retain bean types across classloader boundaries in OSGi or multi-classloader containers. The observable behavior is one resolved accessor per type, for the lifetime of the classloader.
Nested-Class FQN Translation
Section titled “Nested-Class FQN Translation”Java uses $ as the separator for nested classes in binary names, but . in source names. The generated class name follows binary name conventions. When a @BeanParam type is a nested class (e.g. com.example.Outer$Inner), the generated accessor is com.example.Outer$Inner_BeanParamAccessor. Class.forName receives the binary name, so discovery works correctly.
@Url Validation at Compile Time
Section titled “@Url Validation at Compile Time”The processor enforces the same @Url constraints as the runtime scanner:
- A
@Urlparameter type must bejava.net.URI. @Urland@PathParam/@QueryParam/@HeaderParam/@CookieParam/@BeanParamare mutually exclusive on the same parameter.@DefaultValueis not allowed on a@Urlparameter.@Pathon the interface or method is not allowed when@Urlis present on any parameter of that method.
These are compile errors (ERROR-level diagnostics), not warnings.
ReflectiveBeanParamAccessor Internal Cache
Section titled “ReflectiveBeanParamAccessor Internal Cache”ReflectiveBeanParamAccessor maintains a two-level cache keyed by (Class, fieldName):
- Level 1:
ClassValue<ConcurrentHashMap<String, Resolved>>— one map per bean class, scoped to the classloader. - Level 2: the inner map caches the resolved accessor by field name using a sealed
Resolvedcarrier:RecordHit(Method)for record components,ClassHit(Field)for direct field access, andMissas a negative sentinel.
setAccessible(true) is called once at resolve time and never again. The Miss sentinel prevents repeated superclass walks on unknown field names, so even error paths are bounded — the reflective fallback pays no per-call hierarchy walk or repeated setAccessible cost on its hot path.
PATH null Without @DefaultValue: Aligned Fail-Fast
Section titled “PATH null Without @DefaultValue: Aligned Fail-Fast”Both the generated proxy and the reflective fallback (RestClientRequestFactory.collectParams) throw RestClientException when a @PathParam — whether a top-level method parameter or a @BeanParam field — is null and no @DefaultValue is set. The message is:
path param '<name>' was null and has no @DefaultValueBoth paths fail fast with this descriptive exception. Applications that rely on a null path segment being silently omitted must add @DefaultValue or guard against null before calling the method.
Pitfalls
Section titled “Pitfalls”-
External
@BeanParamtypes skip proxy emission for the interface. If any@BeanParamtype on an interface is not in the current compilation unit, the processor cannot scan its fields, so the entire{Client}_RestClientProxyis not emitted. A compile-timeWARNINGis emitted for the external type. The interface falls back to the JDK proxy at runtime, exactly as without the processor. -
Lombok remains explicit. The public application parent does not activate Lombok. A project that uses it appends the Lombok processor path as the explicit opt-in documented in
docs/packaging.md. -
Bean-level fallback for un-accessible fields. A bean with private fields and no getters (and no record components) will not receive a generated accessor. The processor emits a
NOTErather than aWARNINGbecause the runtime handles such beans correctly viaReflectiveBeanParamAccessor. If maximum performance is required, add public getters or convert to a record. -
PATH
nullwithout@DefaultValuefails fast on both paths. Both the generated proxy and the reflective fallback throwRestClientExceptionwhen a@PathParamisnullwith no@DefaultValue. See “Runtime Integration” above.
Module Dagger Bindings
Section titled “Module Dagger Bindings”None. The processor emits no Dagger binding modules. Generated proxies and accessors are discovered at runtime via Class.forName and do not require any Dagger graph participation.
Dependencies
Section titled “Dependencies”dev.vertique:vertique-codegen-core—CodegenContext,TypeResolver,AnnotationMirrors,Diagnostics,Identifierscom.squareup:javapoet— source generation (compile-only; not on runtime classpath)javax.annotation.processingAPIs — part of the JDK; not a separate Maven dependency