TypeScript Clients
commons-rest-openapi turns your Spring controllers into a complete, typed TypeScript
client: axios clients, react-query hooks (v3/v4/v5), model types and Zod
schemas — generated from the springdoc OpenAPI document plus a handful of annotations.
No hand-written fetch code, no drift between backend and frontend.
Installation
Section titled “Installation”<dependency> <groupId>io.rocketbase.commons</groupId> <artifactId>commons-rest-openapi</artifactId> <version>4.0.0-M4</version></dependency>implementation("io.rocketbase.commons:commons-rest-openapi:4.0.0-M4")The generator annotations themselves live in commons-rest-api — your api module doesn’t
need the generator on its classpath.
Annotating endpoints
Section titled “Annotating endpoints”Put the annotations on your Api interface (they are
@Inherited) next to the Spring mappings:
@Tag(name = "activity")@RequestMapping(path = "/activity", produces = APPLICATION_JSON_VALUE)public interface ActivityApi {
@InfiniteHook(value = "findAll", cacheKeys = "activity,list") @GetMapping ResponseEntity<PageableResult<Activity>> loadActivities( @ParameterObject Pageable pageable, @RequestParam(value = "query", required = false) Optional<String> query);
@QueryHook(value = "findById", cacheKeys = "activity,detail,${id}") @GetMapping(path = "/{id}") ResponseEntity<Activity> findById(@PathVariable("id") String id);}public interface PermissionApi {
@MutationHook(invalidateKeys = {"element,detail,${body.objectId}", "activities,${body.objectId}"}) @PutMapping(value = "/element/set-permission", consumes = APPLICATION_JSON_VALUE) ResponseEntity<Void> setPermission(@Valid @NotNull @RequestBody PermissionCmd cmd);}The annotations
Section titled “The annotations”| Annotation | Target | Generates | Key attributes |
|---|---|---|---|
@QueryHook |
method | useQuery hook + query options |
value (method name), cacheKeys, staleTime (seconds, 0 disables caching) |
@InfiniteHook |
method | useInfiniteQuery hook |
value, cacheKeys (required), staleTime |
@MutationHook |
method | useMutation hook |
value, invalidateKeys |
@ClientModule |
type | groups methods under a module name | value, disable |
@ZodSchema |
type / field | controls Zod schema generation | INCLUDE / IGNORE / ANY |
Cache-key & invalidation syntax
Section titled “Cache-key & invalidation syntax”Both cacheKeys and invalidateKeys use the same little layout language. A key is one
string; commas split it into the elements of the react-query key array:
"tile,detail,${id}" → queryKey: ['tile', 'detail', id]Each element is either a literal (tile, detail) or a placeholder:
| Placeholder | Resolves against | Available in |
|---|---|---|
${name} |
the request input — path variables and request params by their mapping name (not the Java variable name); a @RequestBody is always named body, so nested access is ${body.objectId} |
cacheKeys + invalidateKeys |
@{name} |
the mutation response — e.g. @{id} for the id the server returned after a create |
invalidateKeys only |
In the generated code ${...} becomes a template literal over the hook’s filter argument
(queries) or the mutation variables, and @{...} over the mutation result data:
@MutationHook(invalidateKeys = {"element,detail,${body.objectId}", "element,list", "activity,@{id}"})// generated onSuccess (v5):onSuccess: async (data, variables, ...) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ['element', 'detail', `${variables.body.objectId}`] }), queryClient.invalidateQueries({ queryKey: ['element', 'list'] }), queryClient.invalidateQueries({ queryKey: ['activity', `${data.id}`] }), ]); ...}invalidateKeys takes an array — every entry is one invalidateQueries call. Since
react-query matches keys by prefix, you control the blast radius via key length:
"tile"— nukes everything undertile: all lists and all details"tile,list"— all list variations (every filter/query combination), details stay cached"tile,detail,${body.id}"— exactly one detail entry
That also means a good cacheKeys layout goes from coarse to fine (entity, scope, id) —
it is what makes cheap, targeted invalidation possible later.
If you pass your own onSuccess to a generated mutation hook it runs (and, when async, is
awaited) before the invalidation — so optimistic updates or toasts settle before the
refetch storm starts.
Zod schemas are discovered automatically from every @MutationHook request body and its
reachable object graph. Fine-tune with @ZodSchema:
public class ZodAnnotatedCmd { @ZodSchema(ZodSchema.Mode.ANY) // keep the field, validate as z.any() private Map<String, Object> rawPayload;
@ZodSchema(ZodSchema.Mode.IGNORE) // drop from the schema entirely private String internalNote;}Running the generation
Section titled “Running the generation”Two entry points produce the same output:
Fetch the OpenAPI doc once, then generate to the file system — no running app needed afterwards:
curl http://localhost:8080/v3/api-docs > target/openapi.jsonmvn compilemvn exec:java \ -Dexec.mainClass=io.rocketbase.commons.openapi.StandaloneClientGenerator \ -Dexec.args="target/openapi.json target/typescript-client v5 /api ModuleApi"Arguments: openapi file, output directory, react-query version (v3/v4/v5), base
url, group name.
With the module on the classpath every running app exposes a (swagger-hidden) generator controller:
curl -o client.zip http://localhost:8080/generator/client/v5/client.zipGET /generator/{version} additionally returns the extracted controller metadata as
JSON.
The generated package is ready to build:
src/├── clients/ # one axios client per controller├── hooks/ # react-query hooks (queryOptions, useQuery, useInfiniteQuery, useMutation)├── model/ # types.ts, index.ts, request.ts, zod-schemas.ts├── util.ts└── index.tspackage.jsoncd target/typescript-client && npm install && npm run buildWiring it into your app
Section titled “Wiring it into your app”The generated folder is a complete npm package. In a monorepo the simplest setup is a
workspace package (packages/api-client) that the generator writes into — apps depend on it
like any other workspace dependency:
// packages/api-client/package.json (generated){ "name": "openapi-module", "dependencies": { "@rocketbase/commons-rest-client": "^1.3.0", "@tanstack/react-query": "^5.0.0", "axios": "^1.0.0", "zod": "^3.0.0" }, "peerDependencies": { "react": ">=18" }}The only runtime dependency is the small
@rocketbase/commons-rest-client
package. It ships the PageableResult<T> / PageableResultWithMeta<T, M> / ErrorResponse
types matching the server-side DTOs, the axios-backed buildRequestorFactory
(abort-signal support included) and the auth context described below.
Generated hooks don’t take an axios instance or url — they resolve both from React context
via an internal useApi() hook. So the whole wiring is two providers around your tree:
import { AuthProvider, type TokenService } from '@rocketbase/commons-rest-client';import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
const tokenService: TokenService = { getToken: () => localStorage.getItem('token'), // sync or async — your choice};
export function ApiProvider({ children }: { children: ReactNode }) { return ( <AuthProvider baseUrl="https://api.example.com" tokenService={tokenService}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> </AuthProvider> );}AuthProvider creates one shared axios instance with a Bearer interceptor wired to your
tokenService — every generated client and hook below it uses that instance automatically.
Querying — the test api as example
Section titled “Querying — the test api as example”The module’s own test sources contain a small REST api (TileApi
and friends) that exercises the whole feature set:
@Tag(name = "tile")@RequestMapping(path = "/tile", produces = APPLICATION_JSON_VALUE)public interface TileApi {
@InfiniteHook(value = "findAll", cacheKeys = "tile,list") @GetMapping ResponseEntity<PageableResult<Tile>> loadTiles(@ParameterObject Pageable pageable, @RequestParam(value = "query", required = false) Optional<String> query, ...);
@QueryHook(value = "findOne", cacheKeys = "tile,detail,${id}") @GetMapping(path = "/tile/{id}") Tile get(@PathVariable("id") TSID id);}Everything a component needs is a one-liner per endpoint — url building, typing, cache keys, staleTime and request cancellation are already baked in:
import { useQueryTileFindOne, useInfiniteTileFindAll, useMutationPermissionSetPermission,} from 'openapi-module';import { infiniteItems, infiniteTotalElements } from '@rocketbase/commons-rest-client';
function TileDetail({ id }: { id: string }) { // GET /tile/{id} — cacheKey ['tile', 'detail', id], TSID binds as plain string const { data: tile, isLoading } = useQueryTileFindOne({ id }); ...}
function TileList({ query }: { query?: string }) { // GET /tile?size=25&query=... — page param managed by react-query const { data, fetchNextPage, hasNextPage } = useInfiniteTileFindAll({ size: 25, query });
const tiles = infiniteItems(data); // flattened across loaded pages const total = infiniteTotalElements(data); // totalElements from the backend ...}
function PermissionForm() { const mutation = useMutationPermissionSetPermission(); // on success the keys from @MutationHook(invalidateKeys = ...) are invalidated — // affected lists and details refetch without any manual cache code return <button onClick={() => mutation.mutate({ body: { objectId, permission } })} />;}The cache choreography is defined once, in Java, next to the endpoint — the frontend can’t drift from it.
Besides the hooks, each endpoint also exports its queryOptions factory — handy for
router loaders and prefetching:
import { queryOptionTileFindOne, useApi } from 'openapi-module';
// e.g. a TanStack Router loaderloader: ({ params, context }) => context.queryClient.ensureQueryData(queryOptionTileFindOne(context.moduleApi, { id: params.id })),And outside React (scripts, tests, node) the plain axios clients work standalone:
import { createModuleApi } from 'openapi-module';import axios from 'axios';
const api = createModuleApi(axios.create(), { baseURL: 'https://api.example.com' });const tile = await api.tile.findOne({ id: '0gwvcnq9wm7st' });const page = await api.tile.findAll({ size: 25 });Pluggable auth layers
Section titled “Pluggable auth layers”The generated code never talks to your auth system directly — it only sees the axios
instance from AuthProvider. Which auth layer sits between is entirely up to the app, via
two seams:
1. TokenService — the minimal contract the Bearer interceptor calls before every
request. getToken may be sync or async and should handle refresh internally;
onUnauthorized is the reactive backstop for responses that still come back 401:
// Keycloakconst tokenService: TokenService = { getToken: () => keycloak.token ?? null };
// Better-Auth / session-basedconst tokenService: TokenService = { getToken: async () => (await getSession())?.accessToken ?? null,};
// custom refresh flow: proactively refresh short-lived JWTs, force one refresh on 401const tokenService: TokenService = { getToken: () => authClient.ensureFreshToken(), onUnauthorized: async () => { await authClient.refresh(); },};Because getToken is awaited per request, patterns like “in-memory access token +
refresh-cookie round-trip after a page reload” just work — the first query waits for a real
token instead of firing with an expired one.
2. axiosConfigure — full access to the shared axios instance for anything beyond
Bearer tokens: api keys, cookie-based sessions (withCredentials), tracing headers or
completely custom interceptors:
<AuthProvider baseUrl="https://api.example.com" tokenService={tokenService} axiosConfigure={(instance) => { instance.defaults.withCredentials = true; instance.interceptors.request.use((config) => { config.headers['X-Tenant'] = currentTenant(); return config; }); }}>baseUrl also accepts a map when one frontend talks to several backends — generated
clients resolve their url by key via useAuth().baseUrl(key):
<AuthProvider baseUrl={{ main: 'https://api.example.com', auth: 'https://auth.example.com' }} ... >Configuration
Section titled “Configuration”Prefix commons.openapi.generator:
| Property | Default | Explanation |
|---|---|---|
base-url |
/api |
prefix for all generated urls |
group-name |
ModuleApi |
name of the generated method group |
package-name |
openapi-module |
name in the generated package.json |
client-folder |
clients |
folder for axios clients |
hook-folder |
hooks |
folder for react-query hooks |
model-folder |
model |
folder for types & schemas |
model-imports |
(unset) | extra lines added to model/index.ts |
default-stale-time |
2 |
default staleTime (used when annotation says -1) |
enable-file-system-generation |
true |
write directly to output-directory |
output-directory |
typescript-client |
file-system output target |
class-patterns |
(empty) | restrict scanned types, e.g. io.rocketbase.**.dto.** |
custom-type-mappings |
(empty) | Java FQN → TypeScript type overrides |
required-annotations |
(empty) | annotation FQNs marking fields as required; replaces the default list |
zod-infer-type-export |
false |
emit export type X = z.infer<typeof XSchema> next to each schema |
zod-infer-type-suffix |
(empty) | suffix for the inferred type name (e.g. Zod → UserCreateCmdZod) |
Fields render optional by default — only fields carrying one of the required-annotations
become required (in types.ts) and non-nullish (in the Zod schemas). Out of the box that
list covers the not-null implying jakarta.validation constraints (@NotNull, @NotBlank,
@NotEmpty) plus the common non-null markers when on the classpath:
org.springframework.lang.NonNull, jakarta.annotation.Nonnull and
org.jspecify.annotations.NonNull. Set required-annotations to replace the list — e.g. to
tighten back down to bean-validation-only during a migration.