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.
- GitHub
- Gradle plugin repository
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.24.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:
Enum-mode | Generated code |
---|---|
|
|
|
|
|
|
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:
Annotation | Description |
---|---|
|
Property is ignored |
|
Properties of the owning class will be ignored and the return type of the annotated method will be used as its representation. |
|
Properties of the target class will be inlined into the referencing class. |
|
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.24.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:
-
Find all controllers annotated with
@RestController
.[3] Add these as endpoint groups. -
Scan their
@RequestMapping
-annotated methods (you can use meta such as@GetMapping
etc). Add these as endpoints to endpoint groups. -
Perform Jackson-scanning for
@RequestBody
parameters and return type of the methods:-
Build a Class Definition, Enum Definition or Discriminated Union Definition based on the Java type and its Jackson annotations.
-
Go through all properties that Jackson would consider and add them to the definition.
-
Perform the scanning process recursively on the types of the properties.
-
Generating output
When the model has been built, Apina creates output:
-
For each type definition in the model, create corresponding TypeScript elements:
-
An interface describing the structure of the data
-
Configuration metadata for client-side object-mapper
-
-
For each endpoint group (Spring MVC controller), create an endpoint class.
-
Write some generic runtime code that is independent of translated classes (e.g. implementation of object mapper and some support code for making requests)
-
Write target specific code
-
Create a default
EndpointContext
-implementation for Angular or ES6 -
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.