Apina is a tool for creating client-side TypeScript code automatically from your Spring MVC controllers and Jackson classes. It analyzes normal Spring / Jackson annotations, builds a model of your API and emits TypeScript code targeting either Angular or Fetch API.

Introduction

Consider the following Spring controller:[1]

package hello

@RestController
class HelloController {

    @GetMapping(
    fun greet(@RequestParam name: String) =
        GreetingResponse(greeting = "Hello, $name!", mood = Mood.HAPPY)

    enum class Mood { HAPPY, SAD }

    class GreetingResponse(val greeting: String, val mood: Mood)

}

To generate TypeScript code from the controller, apply Apina’s Gradle plugin: [2]

plugins {
    id("fi.evident.apina") version "0.22.0"
}

tasks.apina {
    // Output file that Apina generates
    target.set(project(":frontend").layout.projectDirectory.file("gen/apina-api.ts"))

    // Generate code for Angular
    platform.set(Platform.ANGULAR)

    // Regex matching our controller classes
    endpoints.set(listOf("""hello\..+"""))
}

Next, use provideApina and provideHttpClient in your Angular setup:

import { provideHttpClient } from "@angular/common/http";
import { provideApina } from '../gen/apina-api';

bootstrapApplication(AppComponent, {
    providers: [
        provideHttpClient(),
        provideApina()
    ]
});

Finally, inject the service generated by Apina and use it your own code:

import { Component } from "@angular/core";
import { HelloEndpoint, GreetingResponse } from '../gen/apina-api';

@Component({
    selector: 'my-greeting',
    template: `<span *ngIf="response$ | async as response">{{response.greeting}}</span>`
})
class MyGreetingComponent {

    readonly response$: Observable<GreetingResponse>;

    constructor(helloEndpoint: HelloEndpoint) {
        this.response$ = helloEndpoint.greet("world");
    }
}

That’s it! Based on Jackson’s object mapping rules, Apina generates model types and metadata for handling JSON serialization on the TypeScript side. Similarly, based on Spring’s annotations, Apina generates endpoint classes that execute HTTP requests and marshals the data.

Support for Spring

As long as you don’t do anything too exotic, @RestController -annotated Spring controllers should just work. Consider the following example:

package hello

@RestController
@RequestMapping("/topic")
class TopicController {

    @PostMapping("/{topic:[a-zA-Z]+}")
    fun postMessage(@PathVariable topic: String,
                    @RequestBody message: Message,
                    @RequestParam name: String?,
                    response: HttpServletResponse): PostResponse {
        ...
    }
}

This creates a TypeScript class with following interface:

class TopicEndpoint {

    postMessage(topic: string, message: Message, name: string | null): Observable<PostResponse> {
        ...
    }
}

The @PathVariable, @RequestBody and @RequestParam parameters are all present in the generated method, but the response parameter is not, since it makes no sense for the caller to pass that. Also note that Kotlin’s nullable type turns into nullable type in TypeScript.

If you are writing Java instead of Kotlin, you can use nullability annotations (@Nullable, @CheckForNull etc.) from most well-known libraries to signal nullability (eg. org.jetbrains:annotations or FindBugs).

Support for Jackson

Jackson is very customizable, but Apina supports only a small statically analyzable subset of Jackson. If you have customized Jackson mappings heavily in the global configuration, it’s unlikely that Apina will pick up your customizations. Same is true if you have used some of Jackson’s more exotic annotations.

Mostly the translation is pretty unsurprising: Apina traverses fields and properties the same way that Jackson would do and creates matching TypeScript interfaces. However, there are a few things to know:

Enums

On the wire, enumerations are always represented by their literal name, but there are three different ways that they can be represented in TypeScript. These can be controlled by setting the enumMode property of the Gradle plugin.

So, for following enum:

enum class Toggle {
    ON, OFF
}

the possible translations would be:

Table 1. Enum translations
Enum-mode Generated code

DEFAULT

enum Toggle { ON = "ON", OFF = "OFF" }

INT_ENUM

enum Toggle { ON, OFF }

STRING_UNION

type Toggle = "ON" | "OFF"

Generally you should use the DEFAULT mode if you don’t have a really good reason to do otherwise.

Flattening

Apina will look for properties in superclasses, but will flatten the result into a single TypeScript interface. So:

open class Event {
    val timestamp = Instant.now()
}

class LoginEvent(val username: String) : Event()

will turn into:

interface LoginEvent {
    timestamp: Instant
    username: String
}

First of all, TypeScript is structurally typed so supertypes are not really necessary. And second, keeping the supertype name available is useful when using discriminated unions:

Discriminated unions

Apina supports mapping Jackson’s sub types into TypeScript’s discriminated unions as long as you use @JsonTypeInfo with use=NAME and specify property name for discriminator. Moreover, you must list the sub-types and define their discriminator values explicitly:

@JsonTypeInfo(use=NAME, property="type")
@JsonSubTypes(
    Type(value = Bar::class, name = "my_bar"),
    Type(value = Baz::class, name = "my_baz")
)
abstract class Foo { ... }

class Bar : Foo() { ... }

class Baz : Foo() { ... }

This creates interfaces normally for Bar and Baz and then adds the following types:

export interface Foo_Bar extends Bar {
    type: "my_bar";
}

export interface Foo_Baz extends Baz {
    type: "my_baz";
}

export type Foo = Foo_Bar | Foo_Baz;

This way you can easily pass heterogeneous data back and forth between client and server.

Kotlin sealed classes

With Java, you must use @JsonSubTypes to find the subtypes of a class, but when using Kotlin and sealed classes, you don’t need it. However, you need to use @JsonTypeName to specify names for the types:

@JsonTypeInfo(use=NAME, property="type")
sealed class Foo { ... }

@JsonTypeName("my_bar")
class Bar : Foo() { ... }

@JsonTypeName("my_baz")
class Baz : Foo() { ... }

Supported annotations

That said, straightforward Jackson mappings should generally work without hassle. Most of the time you don’t need any annotations. When you need to control the mappings, following Jackson annotations are recognized by Apina as well:

Table 2. Supported Jackson annotations:
Annotation Description

@JsonIgnore

Property is ignored

@JsonValue

Properties of the owning class will be ignored and the return type of the annotated method will be used as its representation.

@JsonUnwrapped

Properties of the target class will be inlined into the referencing class.

@JsonTypeInfo, @JsonSubTypes, @JsonTypeName

Class will be translated into a discriminated union.

Finally, java.beans.Transient can be used as to ignore a property as well.

Other annotations are not supported at the moment.

Customization of type mappings

While most of the time Apina does the right thing without additional configuration, every now and then you need to customize either the build time translation or runtime execution.

Normally when Apina sees a referenced class, it will analyze its properties and fields decide how it should be serialized: just like Jackson does. However, for some types this makes no sense (that is, when Jackson mapping itself is customized).

As an example, perhaps you don’t want java.time.Instant to be serialized as { "seconds": 1550778425, "nanos": 398000000" } but would prefer it to be represented as "2019-02-21T19:47:05.398Z" on the wire. And instead of handling it as a string in TypeScript, you’d probably want it to be converted to Date or perhaps js-joda's Instant. Apina can do both of these.

Configuring translation

First we’ll configure the Gradle task to import Instant from another file instead of trying to translate it:

tasks.apina {
    ...
    imports.set(mapOf("./my-apina-types" to listOf("Instant")))
}

Now whenever Apina sees the type Instant (Apina ignores package names) it won’t try to generate code for it, but assume that the user has provided it. Furthermore it adds the code import { Instant } from "./my-apina-types"; on top of the generated file.

Next, we’ll write my-apina-types.ts. For our example, we’ll use JavaScript’s Date for representation of Instant, so we’ll simply specify a type alias:

export type Instant = Date;

Configuring runtime serialization

Next we’ll need to instruct Apina to customize the serialization format used on JavaScript. (Of course you also need to configure Jackson similarly, but that’s out of scope for this document.) Apina generates an ApinaConfig class whose default constructor will configure all serializers that Apina can deduce itself. Therefore we can just construct the default instance and then customize it by registering our own serializer for instants:

export function createApinaConfig(): ApinaConfig {
    const config = new ApinaConfig();

    config.registerSerializer("Instant", {
        serialize(o) { return formatISO8601(o); },
        deserialize(o) { return parseISO8601(o); }
    });

    return config;
}

Finally we’ll register createApinaConfig as a factory for ApinaConfig in our Angular module:

@NgModule({
    ...
    providers: [
        { provide: ApinaConfig, useFactory: createApinaConfig }
    ]
})
export class MyModule { }
When generating code for ES6, you won’t register the provider for Angular, but you’ll simply pass configuration as a constructor parameter when instantiating endpoint-classes.

Customizing HTTP requests

Override request handling

Sometimes you need more control over the HTTP requests Apina makes. Perhaps you are behind a proxy and need to mangle the URL somehow or perhaps you’ll want to add some headers to requests.

You can implement these by implementing replacing Apina’s ApinaEndpointContext with your own. When Apina generates code for endpoints, it actually just builds a structure representing the details of the request and passes it to endpoint context for actual execution:

findOrders(customerId: number): Observable<Order> {
    return this.context.request({
        'uriTemplate': '/api/customer/{customerId}/orders',
        'method': 'GET',
        'pathVariables': {
            'customerId': this.context.serialize(customerId, 'number')
        },
        'responseType': 'Order'
    });
}

Therefore, you can easily implement your own endpoint context that does something different than the default one:

@Injectable()
export class MyApinaEndpointContext extends ApinaEndpointContext {

    constructor(config: ApinaConfig) {
        super(config);
    }

    request(data: RequestData): Observable<any> {
        ... do something completely different ...
    }
}

Finally, register it so that Apina uses it:

@NgModule({
    ...
    providers: [
        { provide: ApinaEndpointContext, useClass: MyApinaEndpointContext }
    ]
})
export class MyModule { }
When targeting ES6, you can just instantiate your own context normally and pass it to constructor of your endpoint class.
When targeting Angular, Apina’s DefaultApinaEndpointContext uses Angular’s HttpClient to execute the requests. Therefore things like authorization headers are probably best implemented as interceptors for HttpClient instead of writing an Apina-specific implementation. Custom endpoint context can still be useful for some cases which need higher-level knowledge of the requests.

Build URLs without requests

It’s also possible to configure Apina to build methods that simply return the request URL without making a request. This could be used for a strongly typed download link or performing really exotic requests while still getting help from Apina.

Suppose we have following controller:

package example

@RestController
class DownloadController {

    @GetMapping("/download/{name}")
    fun download(@PathVariable name: String, @RequestParam code: Int): Document {
        ...
    }
}

We can now configure a regex matching to the fully qualified name of the method to tell Apina that we want URL-generation for this method:

tasks.apina {
    endpointUrlMethods.set(listOf("""hello\.DownloadController\.download"""))
}

Now Apina creates two methods instead of one. First you can use to call the endpoint as normally, whereas you can use one with Url-suffix to create the URL:

interface DownloadEndpoint {
    download(name: String, code: Int): Observable<Document>;
    downloadUrl(name: String, code: Int): string;
}

Methods with @RequestBody parameter are supported, but they are omitted from the URL-method, since it’s impossible to create a URL with request body. In that case, you need to pass the body manually when you end up calling the URL.

Gradle task reference

plugins {
    id("fi.evident.apina") version "0.22.0"
}

tasks.apina {
    // Set the name of the created TypeScript file. Default is "build/apina/apina.ts".
    target.set(project(":frontend").layout.projectDirectory.file("app/apina-api.ts"))

    // Specify types that should not be generated, but are implemented manually
    // and should be imported to the generated code. Keys are module paths, values
    // are list of types imported from the module.
    imports.set(mapOf(
      "./my-time-module" to listOf("Instant", "LocalDate"),
      "./other-module" to listOf("Foo", "Bar")
    ))

    // How Java enums are translated to TypeScript enums? (Default mode is 'DEFAULT'.)
    //  - 'DEFAULT'      => enum MyEnum { FOO = "FOO", BAR = "BAR", BAZ = "BAZ" }
    //  - 'INT_ENUM'     => enum MyEnum { FOO, BAR, BAZ }
    //  - 'STRING_UNION' => type MyEnum = "FOO" | "BAR" | "BAZ"
    enumMode.set(EnumMode.DEFAULT)

    // How nullables are translated to TypeScript interfaces? (Default mode is 'NULL'.)
    //  - 'NULL'      => name: Type | null
    //  - 'UNDEFINED' => name?: Type
    optionalTypeMode.set(OptionalTypeMode.NULL)

    // Which controllers to include when generating API? Defaults to everything.
    // Given regexes may safely match other things that are not controllers, but it pays to
    // limit the scope because otherwise Apina needs to parse bytecode of all the classes
    // in your classpath to find the controllers.
    endpoints.set(listOf("""my\.package\.foo\..+"""))

    // Which methods to include when generating URL methods. This is a subset of normal
    // endpoint methods. Pattern to match is 'package.name.ClassName.methodName'. By
    // default this set is empty and no URL-methods are generated.
    endpointUrlMethods.set(listOf(""".+\.download.*"""))

    // If generated URLs would start with given prefix, removes it. Useful when configuring Apina
    // to work behind reverse proxies. Defaults to empty string (URL is not modified).
    removedUrlPrefix.set("/foo")

    // Code generation target (Default is 'ANGULAR')
    // - 'ANGULAR' => Generate Angular module that uses Angular's HttpClient
    // - 'ES6' => Generate code that uses Fetch API and has no dependencies apart from ES6
    platform.set(Platform.ANGULAR)
}

Translation details

The following sections provide a bird’s eye view of the translation process. It’s not necessary to understand this in order to use Apina, but it’s probably a good idea anyway.

Building the model

Apina starts by building its model, the API Definition. API Definition contains information about HTTP endpoints and JSON documents that need translation. The model itself is agnostic to any specific technology, so in theory you could have a parser that builds a model out of a .NET or Rust program. However, only Spring MVC and Jackson are supported at the moment.

The process to build a model from Spring MVC application is straightforward:

  1. Find all controllers annotated with @RestController.[3] Add these as endpoint groups.

  2. Scan their @RequestMapping-annotated methods (you can use meta such as @GetMapping etc). Add these as endpoints to endpoint groups.

  3. Perform Jackson-scanning for @RequestBody parameters and return type of the methods:

    1. Build a Class Definition, Enum Definition or Discriminated Union Definition based on the Java type and its Jackson annotations.

    2. Go through all properties that Jackson would consider and add them to the definition.

    3. Perform the scanning process recursively on the types of the properties.

Generating output

When the model has been built, Apina creates output:

  1. For each type definition in the model, create corresponding TypeScript elements:

    1. An interface describing the structure of the data

    2. Configuration metadata for client-side object-mapper

  2. For each endpoint group (Spring MVC controller), create an endpoint class.

  3. Write some generic runtime code that is independent of translated classes (e.g. implementation of object mapper and some support code for making requests)

  4. Write target specific code

    1. Create a default EndpointContext-implementation for Angular or ES6

    2. If using Angular, create Angular module that wires all generated endpoint classes.

Since the model of the API is completely abstract from the implementation technology, in theory we could proceed to generate code for any target, e.g. Swift or Befunge. However, at the moment only TypeScript is supported, albeit with two different flavors: we can generate code that takes advantage of Angular’s offerings or we can generate code for standalone ES6, using the Fetch API for requests.


1. Throughout the documentation I’ve used Kotlin for brevity, but Apina works perfectly with Java as well.
2. Again this is Kotlin, but using Groovy is similar enough.
3. As far as Spring is concerned, @RestController is just a combination of @Controller and @ResponseBody annotations. Apina ignores controllers annotated with @Controller for a reason: this way it’s easy to make controllers that Apina ignores.