Generate Client API
Basic Concepts
If using Jimmer to build backend systems, developers have three ways to solve the
problem.Description | Where object shapes are restricted | Client experience | |
---|---|---|---|
Solution 1 | Use Jimmer to build GraphQL services | Client side | Good |
Solution 2 | Use Jimmer to build REST services, shapes specified by client | Client side | Bad |
Solution 3 | Use Jimmer to build REST services, shapes enumerated by server | Server side | Good |
Let's discuss the three cases separately:
Solution 1 (GraphQL)
Use Jimmer to build GraphQL services
Server: Develop GraphQL Backend with Jimmer
Client: Many client frameworks to choose from. Using my graphql-ts-client can get the ultimate TypeScript experience
Jimmer itself supports recursive query of self-associated properties, but so far, the GraphQL protocol does not support this feature, which means the feature will be lost.
Solution 2 (Not Recommended)
Use Jimmer to build REST services, shapes specified by client
This approach actually gives REST the capabilities of GraphQL. The client passes a string, and the server parses it into a Fetcher via org.babyfish.jimmer.sql.fetcher.compiler.FetcherCompiler
- Java
- Kotlin
@GetMapping("/book/{id}")
public Book findBookById(
@PathVariable long id,
@RequestParam(required = false) String fetcherCode
) {
Fetcher<Book> fetcher = FetcherCompiler.compile(fetcherCode, Book.class);
return bookRepository.findNullable(id, fetcher);
}
@GetMapping("/book/{id}")
fun findBookById(
@PathVariable id: Long,
@RequestParam(required = false) fetcherCode: String?
): Book? =
bookRepository.findNullable(
id,
FetcherCompiler.compile(fetcherCode, Book::class.java)
)
When calling, specify the fetcherCode parameter, for example
{
id
name
authors {
id
firstName
lastName
}
}
or
{ id, name, authors { id, firstName, lastName }}
To learn how to express more complex object fetcher in string code, refer to the toString()
method of object fetcher.
Although this approach can give REST capabilities similar to GraphQL, which is very flexible, it only facilitates server-side writing. The client will get a messy type system, so it is not recommended.
If due to some historical constraints, the client uses JavaScript, not TypeScript, and there is no foreseeable hope of any improvement, then consider this usage.
Solution 3 (Topic of This Article)
Use Jimmer to build REST services, shapes enumerated by server
This usage is more in line with the original intention of the REST protocol, and it is also easier to manage permissions (although the previous two usages are flexible, permission control is very difficult), and it is also the topic to be discussed in this article.
The case discussed here is when the server API returns dynamic entities (recommended), not DTOs.
Let's look at a contradiction first
-
Jimmer uses dynamic entities. As long as the root entity types are the same, any data structure shape can be expressed with the same Java/Kotlin type.
-
The client needs to see rich data types. The shapes returned by the same entity type in different HTTP interfaces are different.
This contradiction is essentially the difference in awareness of
between the server side and the client side.-
Server perspective is producer perspective.
From the producer's point of view, DTO explosion is a very annoying problem, which means that their own development costs will also increase dramatically.
Therefore, Jimmer uses dynamic entities combined with object fetchers to eliminate DTO explosions on the server side.
-
Client perspective is consumer perspective.
From the consumer's point of view, DTO explosion is beneficial, each business scenario has an accurate return type, which is very nice to use. (Difficult to implement? What does that have to do with me?)
Therefore, Jimmer server can automatically generate client code to (such as TypeScript) restore the DTO explosion eliminated on the server side on the client side.
The server eliminates DTO explosions, and the client restores DTO explosions. This is the fundamental value of Jimmer's automatic sever-client integration, and it is also the essential difference between Jimmer and any other technology that automatically generates client code.
Only by letting the server and client use completely different programming models can both sides get the ultimate development experience at the same time.
Usage
Declare @FetchBy
Previously we discussed that using Jimmer to build REST services with shapes enumerated by the server is the topic to be discussed in this article.
To use this development approach, you need to use the @org.babyfish.jimmer.client.FetchBy
annotation to decorate dynamic entity types in REST API return types, to mark specific shapes of dynamic objects for the client.
@FetchBy
does not simply decorate the return value of the REST API, but is used to decorate type references. Its declaration code is as follows
package org.babyfish.jimmer.client;
import java.lang.annotation.*;
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE_USE)
public @interface FetchBy {
...omitted...
}
Therefore, the return type of the REST API is very flexible. You can use it to decorate Jimmer entity types anywhere (including generic parameters), for example
@FetchBy("...") Book
List<@FetchBy("...") Book>
Page<@FetchBy("...") Book>
Tuple2<@FetchBy("...") BookStore, @FetchBy("...") Author>
Map<String, Map<String, @FetchBy("...") Book>>
- Java
- Kotlin
@GetMapping("/books")
public Page<
@FetchBy("SIMPLE_FETCHER") Book ❶
> findBookById(
@RequestParam(defaultValue = "0") int pageIndex,
@RequestParam(defaultValue = "5") int pageSize,
@RequestParam(defaultValue = "name asc, edition desc") String sortCode
) {
return bookRepository.findBooks(
PageRequest.of(pageIndex, pageSize, SortUtils.toSort(sortCode)),
SIMPLE_FETCHER ❷
);
}
@GetMapping("book/{id}")
@Nullable
public
@FetchBy("COMPLEX_FETCHER") Book ❸
findComplexBook(
@PathVariable("id") long id
) {
return bookRepository.findNullable(
id,
COMPLEX_FETCHER ❹
);
}
private static final Fetcher<Book> SIMPLE_FETCHER = ❺
Fetchers.BOOK_FETCHER
.allScalarFields()
.tenant(false);
private static final Fetcher<Book> COMPLEX_FETCHER = ❻
Fetchers.BOOK_FETCHER
.allScalarFields()
.tenant(false)
.store(
Fetchers.BOOK_STORE_FETCHER
.allScalarFields()
.avgPrice()
)
.authors(
Fetchers.AUTHOR_FETCHER
.allScalarFields()
);
@GetMapping("/books")
fun findBooks(
@RequestParam(defaultValue = "0") pageIndex: Int,
@RequestParam(defaultValue = "5") pageSize: Int,
@RequestParam(defaultValue = "name asc, edition desc") sortCode: String
): Page<
@FetchBy("SIMPLE_FETCHER") Book ❶
> =
bookRepository.findBooks(
PageRequest.of(pageIndex, pageSize, SortUtils.toSort(sortCode)),
name,
storeName,
authorName,
SIMPLE_FETCHER ❷
)
@GetMapping("/book/{id}")
fun findBookById(
@PathVariable id: Long,
): @FetchBy("COMPLEX_FETCHER") Book? = ❸
bookRepository.findNullable(
id,
COMPLEX_FETCHER ❹
)
companion object {
private val SIMPLE_FETCHER = ❺
newFetcher(Book::class).by {
allScalarFields()
tenant(false)
}
private val COMPLEX_FETCHER = ❻
newFetcher(Book::class).by {
allScalarFields()
tenant(false)
store {
allScalarFields()
avgPrice()
}
authors {
allScalarFields()
}
}
}
-
❶ Promise externally that the shape of each
Book
object in the paged object returned byGET /books
is expressed by the static constantSIMPLE_FETCHER
-
❷ Internal implementation,
GET /books
uses static constantSIMPLE_FETCHER
internally to query datacaution❶ as external commitment and ❷ as internal implementation must be consistent :::
-
❸ Promise externally that if
GET /book/{id}
returns non-null, its shape is expressed by the static constantCOMPLEX_FETCHER
-
❹ Internal implementation,
GET /book/{id}
uses static constantCOMPLEX_FETCHER
internally to query datacaution❸ as external commitment and ❹ as internal implementation must be consistent
-
❺ and ❻, declare object shapes as static constants.
With the decoration of @FetchBy, Jimmer understands the specific shape of each object's externally returned data, so it can generate code for the client, including TypeScript.
In this example, the class annotated with @FetchBy
and the class declaring object shapes with static constants are the same class.
If not, you can use the ownerType
parameter of @FetchBy
, for example
@FetchBy(value = "COMPLEX_FETCHER", ownerType = FetcherConstants.class)
Generate Client Code
The following configuration can be declared in application.yml
or application.properties
to download related client code
jimmer:
...other configurations omitted...
client:
ts:
path: /ts.zip ❶
java-feign:
path: /java-feign.zip ❷
Currently, Jimmer supports generating two types of client code, TypeScript and Java Feign Client code required by Spring Cloud
-
❶ Can download Web client required TypeScript code through http://localhost:8080/ts.zip
-
❷ Can download Spring Cloud required Java Feign Client code through http://localhost:8080/java-feign.zip
Next we discuss the TypeScript code.
Start the service, download http://localhost:8080/ts.zip, decompress it. Let the root directory after decompression be ${ts_root}
:
Let's take a look at ${ts_root}/model/dto/BookDto.ts
first
export type BookDto = {
'BookService/SIMPLE_FETCHER': {
readonly id: number,
readonly name: string,
readonly edition: number,
readonly price: number,
},
'BookService/COMPLEX_FETCHER': {
readonly id: number,
readonly name: string,
readonly edition: number,
readonly price: number,
readonly store?: {
readonly id: number,
readonly name: string,
readonly website?: string,
readonly avgPrice: number
},
readonly authors: ReadonlyArray<{
readonly id: number,
readonly firstName: string,
readonly lastName: string,
readonly gender: Gender
}>
}
}
It is obvious that the DTO explosion eliminated on the server side is restored on the client side.
Let's also take a look at ${ts_root}/services/BookService.ts
import type { BookDto } from '../model/dto';
import type { Page } from '../model/static';
export class BookService {
async findBooks(
options: BookServiceOptions['findBooks']
): Promise<
Page<
BookDto['BookService/SIMPLE_FETCHER']
>
> {
...code omitted...
}
async findBookById(
options: BookServiceOptions['findBookById']
): Promise<
BookDto['BookService/COMPLEX_FETCHER'] |
undefined
> {
...code omitted...
}
...other code omitted...
}
export type BookServiceOptions = {
'findBooks': {
readonly pageIndex: number,
readonly pageSize: number,
readonly sortCode: string
},
'findBookById': {
readonly id: number
}
}
It is obvious that the return type of each business scenario is accurately defined.
Develop Web Client Project
-
Create project
First create a typescript based react project
yarn create react-app my-web-app --template typescript
-
Automatically generate client code
Obviously, it is impossible to require client developers to manually download server code, decompress it, and replace local code every time changes occur on the server side.
So we need to write a small script to automatically complete the download, decompression and replacement of the latest TypeScript code.
Add folder
scripts
under project root, and add filegenerate-api.js
under it. This file is executed by nodejs and is tooling code for development, not the code of the client itself.scripts/generate-api.jsconst http = require('http');
const fs = require('fs');
const fse = require('fs-extra');
const uuid = require('uuid');
const tempDir = require('temp-dir');
const AdmZip = require('adm-zip');
const sourceUrl = "http://localhost:8080/ts.zip";
const tmpFilePath = tempDir + "/" + uuid.v4() + ".zip";
const generatePath = "src/__generated";
console.log("Downloading " + sourceUrl + "...");
const tmpFile = fs.createWriteStream(tmpFilePath);
const request = http.get(sourceUrl, (response) => {
response.pipe(tmpFile);
tmpFile.on("finish", () => {
tmpFile.close();
console.log("File save success: ", tmpFilePath);
// Remove generatePath if it exists
if (fs.existsSync(generatePath)) {
console.log("Removing existing generatePath...");
fse.removeSync(generatePath);
console.log("Existing generatePath removed.");
}
// Unzip the file using adm-zip
console.log("Unzipping the file...");
const zip = new AdmZip(tmpFilePath);
zip.extractAllTo(generatePath, true);
console.log("File unzipped successfully.");
// Remove the temporary file
console.log("Removing temporary file...");
fs.unlink(tmpFilePath, (err) => {
if (err) {
console.error("Error while removing temporary file:", err);
} else {
console.log("Temporary file removed.");
}
});
});
});Here,
adm-zip
needs to be installed separatelyyarn add adm-zip --dev
Modify project's
package.json
and add the following code under its "scripts" field{
...other code omitted...
"scripts": {
...other code omitted...
"api": "node scripts/generate-api.js"
}
...other code omitted...
}So every time the server team notifies that REST API changes have occurred, just execute
yarn api
simply to refresh the local TypeScript client codecautionThis method is only suitable when the web team is very small in size. If there are many web developers, it is more recommended to implement secondary development on the CI environment to achieve the following:
Each time the server code on a specific branch is committed, the CI environment compiles and starts the service, then downloads the ts code, decompresses it, and commits it to git. Finally, web engineers can simply pull the latest code from git.
-
Create Global API Object
The generated TypeScript code has a
__generated/Api.ts
file that needs to be instantiated into a global variable and configured properly.Under
src
, createApiInstance.ts
and export the global variableapi
src/ApiInstance.tsimport { Api } from "../__generated";
const BASE_URL = "http://localhost:8080";
// Export global variable `api`
export const api =
new Api(async({uri, method, body}) => {
const response = await fetch(`${BASE_URL}${uri}`, {
body: body !== undefined ? JSON.stringify(body) : undefined,
headers: {
'content-type': 'application/json;charset=UTF-8',
...other important HTTP headers, e.g. Authorization...
}
});
if (response.status !== 200) {
throw response.json();
}
const text = await response.text();
if (text.length === 0) {
return null;
}
return JSON.parse(text);
}); -
Call REST APIs
Now we can call REST APIs based on global variable
api
.infoThe following examples are based on use-immer and TanStack/Query.
Being proficient in or quickly mastering various remote request libraries is the basic literacy of web front-end engineers, so the details are not elaborated here.
-
Experience
api.bookService.findBooks
const [options, setOptions] = useImmer<
// RequestOf is a TypeScript helper class generated by Jimmer,
// used to extract parameter types of any REST API
RequestOf<
typeof api.bookService.findBooks
>
>(() => {
return {
pageIndex: 0,
pageSize: 10,
sortCode: "name asc"
};
});
const {
isLoading,
// If `data` is not `undefined`, its type must be
// `Page<BookDto['BookService/SIMPLE_FETCHER']>`
data,
error,
refetch
} = useQuery({
queryKey: ["Books", options],
// The type of `data` is determined here
queryFn: () => api.bookService.findBooks(options)
});If request succeeds, the type of
data
isPage<BookDto['BookService/SIMPLE_FETCHER']>
.Where
BookDto['BookService/SIMPLE_FETCHER']
is defined as{
readonly id: number,
readonly name: string,
readonly edition: number,
readonly price: number,
} -
Experience
api.bookService.findBookById
In the following code, assume
id
is the current React primary key parameterconst {
// If `data` is not `undefined`, its type must be
// `BookDto['BookService/COMPLEX_FETCHER']`
data,
isLoading,
error
} = useQuery({
queryKey: ["book/detail", id],
queryFn: () => api.bookService.findBookById({id: id!}),
enabled: id !== undefined
});If request succeeds and
data
is not null, its type isBookDto['BookService/COMPLEX_FETCHER']
. This type is defined as:{
readonly id: number,
readonly name: string,
readonly edition: number,
readonly price: number,
readonly store?: {
readonly id: number,
readonly name: string,
readonly website?: string,
readonly avgPrice: number
},
readonly authors: ReadonlyArray<{
readonly id: number,
readonly firstName: string,
readonly lastName: string,
readonly gender: Gender
}>
}
tipIt can be seen that any REST API call will return strict data type definitions, and these strict type definitions will also affect the React UI template code in
tsx
files.This will give full play to the advantages of TypeScript, providing good IDE intelligence, and ensuring all problems are discovered at compile time, thus having a good development experience.
-
Integrate with Custom Data
Although @FetchBy
combined with Jimmer's dynamic entities can restore DTO type definitions in client code, one situation still needs to be carefully considered: the returned data type differs greatly from the underlying entity model. For example:
- Java
- Kotlin
@lombok.Data
public class ActiveAuthorInfo {
private Author raw;
private List<BookStore> stores;
}
data class ActiveAuthorInfo(
val raw: Author,
val stores: List<BookStore>
)
In this example, ActiveAuthorInfo
represents a very active author, containing the original information of the author raw
, and the collection of all bookstores selling his/her books.
The corresponding HTTP service interface is:
- Java
- Kotlin
@GetMapping("/authors/mostActive")
public List<ActiveAuthorInfo> findMostActiveAuthorInfos(
@RequestParam(defaultValue = "10") int limit
) {
...omitted...
}
@GetMapping("/authors/mostActive")
fun findMostActiveAuthorInfos(
@RequestParam(defaultValue = "10") limit: Int
): List<ActiveAuthorInfo> {
...omitted...
}
Obviously, this data structure differs greatly from the underlying entity model. In the entity model, there is an association between BookStore
and Book
, and there is also an association between Book
and Author
, but there is no association between BookStore
and Author
.
In some cases, the data required by the client may contain multiple entity objects that have no direct ORM association, but only a certain business-level correlation.
If such business-level correlations are not generic at all, then defining complex calculated properties for entities is not a good choice either.
At this point, we can break the stereotypical thinking of the entity object graph and use custom data to represent the return results, just like ActiveAuthorInfo
here.
However, ActiveAuthorInfo
is not a purely custom user data type, it mixes the use of Jimmer entities internally. Let's call it a hybrid type.
The @FetchBy
annotation can be used to decorate the fields of this hybrid type, for example:
- Java
- Kotlin
@lombok.Data
public class ActiveAuthorInfo {
private @FetchBy("AUTHOR_FETCHER") Author raw;
private List<@FetchBy("STORE_FETCHER") BookStore> stores;
private static final Fetcher<Author> AUTHOR_FETCHER =
Fetchers.AUTHOR_FETCHER
.firstName()
.lastName();
private static final Fetcher<BookStore> STORE_FETCHER =
Fetchers.AUTHOR_FETCHER.name();
}
data class ActiveAuthorInfo(
val raw: @FetchBy("AUTHOR_FETCHER") Author,
val stores: List<@FetchBy("STORE_FETCHER") BookStore>
) {
companion object {
private val AUTHOR_FETCHER =
newFetcher(Author::class) {
firstName()
lastName()
}
private val STORE_FETCHER =
new Fetcher(BookStore::class) {
name()
}
}
}
Finally, the TypeScript related types generated for ActiveAuthorInfo
are as follows (for convenience, the code of multiple TypeScript files is mixed here):
export interface ActiveAuthorInfo {
readonly raw: AuthorDto['ActiveAuthorInfo/AUTHOR_FETCHER'];
readonly stores: ReadonlyArray<
BookStoreDto['ActiveAuthorInfo/STORE_FETCHER']
>;
}
export type AuthorDto {
'ActiveAuthorInfo/AUTHOR_FETCHER': {
readonly id: number,
readonly firstName: string,
readonly lastName: string
},
...other DTO types omitted...
}
export type BookStoreDto {
'ActiveAuthorInfo/STORE_FETCHER': {
readonly id: number,
readonly name: string
},
...other DTO types omitted...
}