Skip to content

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.

<dependency>
<groupId>io.rocketbase.commons</groupId>
<artifactId>commons-rest-openapi</artifactId>
<version>4.0.0-M4</version>
</dependency>

The generator annotations themselves live in commons-rest-api — your api module doesn’t need the generator on its classpath.

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);
}
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

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 under tile: 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;
}

Two entry points produce the same output:

Fetch the OpenAPI doc once, then generate to the file system — no running app needed afterwards:

Terminal window
curl http://localhost:8080/v3/api-docs > target/openapi.json
mvn compile
mvn 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.

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.ts
package.json
Terminal window
cd target/typescript-client && npm install && npm run build

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.

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 loader
loader: ({ 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 });

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:

// Keycloak
const tokenService: TokenService = { getToken: () => keycloak.token ?? null };
// Better-Auth / session-based
const tokenService: TokenService = {
getToken: async () => (await getSession())?.accessToken ?? null,
};
// custom refresh flow: proactively refresh short-lived JWTs, force one refresh on 401
const 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' }} ... >

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. ZodUserCreateCmdZod)

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.