Skip to main content

Generate Client API

Basic Concepts

If using Jimmer to build backend systems, developers have three ways to solve the

problem.

DescriptionWhere object shapes are restrictedClient experience
Solution 1Use Jimmer to build GraphQL servicesClient sideGood
Solution 2Use Jimmer to build REST services, shapes specified by clientClient sideBad
Solution 3Use Jimmer to build REST services, shapes enumerated by serverServer sideGood

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

caution

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.

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

BookController.java
@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);
}

When calling, specify the fetcherCode parameter, for example

{
id
name
authors {
id
firstName
lastName
}
}

or

{ id, name, authors { id, firstName, lastName }}
info

To learn how to express more complex object fetcher in string code, refer to the toString() method of object fetcher.

caution

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.

info

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.

tip

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.

tip

@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>>
BookController.java
@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()
);
  • ❶ Promise externally that the shape of each Book object in the paged object returned by GET /books is expressed by the static constant SIMPLE_FETCHER

  • ❷ Internal implementation, GET /books uses static constant SIMPLE_FETCHER internally to query data

    caution

    ❶ 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 constant COMPLEX_FETCHER

  • ❹ Internal implementation, GET /book/{id} uses static constant COMPLEX_FETCHER internally to query data

    caution

    ❸ 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.

info

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

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

BookDto.ts
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
}>
}
}
info

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
}
}
info

It is obvious that the return type of each business scenario is accurately defined.

Develop Web Client Project

  1. Create project

    First create a typescript based react project

    yarn create react-app my-web-app --template typescript 
  2. 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 file generate-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.js
    const 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 separately

    yarn 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 code

    caution

    This 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.

  3. 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, create ApiInstance.ts and export the global variable api

    src/ApiInstance.ts
    import { 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);
    });
  4. Call REST APIs

    Now we can call REST APIs based on global variable api.

    info

    The 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 is Page<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 parameter

      const {
      // 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 is BookDto['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
      }>
      }
    tip

    It 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:

ActiveAuthorInfo.java
@lombok.Data
public class ActiveAuthorInfo {

private Author raw;

private List<BookStore> stores;
}

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:

AuthorController.java
@GetMapping("/authors/mostActive")
public List<ActiveAuthorInfo> findMostActiveAuthorInfos(
@RequestParam(defaultValue = "10") int limit
) {
...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.

tip

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:

ActiveAuthorInfo.java
@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();
}

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...
}