Publish your backend API typings as an NPM package
It’s quite common in a microservice style architecture to provide a type-safe client library that other services can use to communicate with your service. This can be package with a Retrofit client published to nexus by the maintainer of the service. Some projects might also generate that code from a OpenAPI spec or a gRPC proto file.
However, when we expose some of these APIs to the frontend, we lose the types. In this post I suggest a way to publish your backend API types as an NPM package. The frontend can then depend on these typings. Using typescript you now have compile time type safety and code completion. To see a sneak peak, scroll to the end :).
The next steps provide 'a way' to publish typings of a simple Kotlin service (also works for java) to the NPM registry. Then we will use a simple frontend to depend on these types.
A complete working example is available at github.com/toefel18/backend-api-as-npm-typings-blog
Creating a simple service
The following code sample uses Javalin to create a simple REST API that publishes a movie:
enum class Genre {
ACTION,
COMEDY,
THRILLER,
HORROR,
DRAMA
}
data class ActorDto(
val firstName: String,
val lastName: String,
val dateOfBirth: LocalDate?
)
data class MovieDto(
val name: String,
val producerName: String,
val releaseDate: LocalDateTime,
val genre: Genre,
val actors: List<ActorDto>
)
class Router(val port: Int) {
private val logger: Logger = LoggerFactory.getLogger(Router::class.java)
val app = Javalin.create { cfg -> cfg.requestLogger(::logRequest).enableCorsForAllOrigins() }
.get("/movies", ::listMovies)
private fun logRequest(ctx: Context, executionTimeMs: Float) =
logger.info("${ctx.method()} ${ctx.fullUrl()} status=${ctx.status()} durationMs=$executionTimeMs")
fun start(): Router {
app.start(port)
return this
}
fun listMovies(ctx: Context) {
val bradPitt = ActorDto(
firstName = "Johnny",
lastName = "Depp",
dateOfBirth = LocalDate.parse("1989-07-17")
)
val johnnyDepp = ActorDto(
firstName = "Brad",
lastName = "Pitt",
dateOfBirth = LocalDate.parse("1990-11-03")
)
val lionKing = MovieDto(
name = "Lion King",
producerName = "Disney",
releaseDate = LocalDateTime.parse("2019-07-17T15:30:00"),
genre = Genre.DRAMA,
actors = listOf(bradPitt, johnnyDepp)
)
ctx.json(listOf(lionKing))
}
}
When started, you can visit localhost:8080/movies
and it will return:
[
{
"name": "Lion King",
"producerName": "Disney",
"releaseDate": "2019-07-17T15:30:00",
"genre": "DRAMA",
"actors": [
{
"firstName": "Johnny",
"lastName": "Depp",
"dateOfBirth": "1989-07-17"
},
{
"firstName": "Brad",
"lastName": "Pitt",
"dateOfBirth": "1990-11-03"
}
]
}
]
Generating Typings
Now we have some DTO’s and a working REST API, it’s time to generate typings. There are multiple ways of doing this. One option is to use a gradle/maven plugin like github.com/vojtechhabarta/typescript-generator
However for this example I chose to use github.com/ntrrgc/ts-generator and write some Kotlin code to generate the typings. Using code to generate typings allows me rename or move types safely in my IDE. It also gives me more flexibility as I can add my own code after the generation if I so desire.
The code:
val typingsContent = TypeScriptGenerator(
rootClasses = setOf(
MovieDto::class
),
mappings = mapOf(
LocalDate::class to "Date",
LocalDateTime::class to "Date"
)
).definitionsText
The output:
interface ActorDto {
dateOfBirth: Date | null;
firstName: string;
lastName: string;
}
type Genre = "ACTION" | "COMEDY" | "THRILLER" | "HORROR" | "DRAMA";
interface MovieDto {
actors: ActorDto[];
genre: Genre;
name: string;
producerName: string;
releaseDate: Date;
}
Publishing an NPM package
Now we have typings, all we need to do next is create an NPM package and publish it. Normally expect you to have a local registry for this or a company scope in NPM. For now I will just publish them as a public NPM package with the name backend-api-as-npm-typings-blog
.
All we need is a separate directory with a package.json
and the typings generated for the previous step.
{
"name": "backend-api-as-npm-typings-blog",
"version": "0.0.12",
"description": "typings for backend-api-npm-typings java app",
"repository": {
"type": "git",
"url": "git+https://github.com/toefel18/backend-api-as-npm-typings-blog.git"
},
"author": "toefel18@gmail.com",
"license": "ISC",
"homepage": "https://github.com/toefel18/backend-api-as-npm-typings-blog#readme"
}
The version should be kept in-sync with the project version of your backend API. Gradle or maven knows this version. I again used Kotlin code to inject the version in a package.json and gradle to execute this code while injecting the required variables.
This is the complete Kotlin code that generates the typings.d.ts
and package.json
object TypingsGenerator {
@JvmStatic
fun main(args: Array<String>) {
val typingsDestination = "${args[0]}/src/typings.d.ts"
val packageJsonDestination = "${args[0]}/package.json"
val projectVersion = args[1]
val typingsContent = TypeScriptGenerator(
rootClasses = setOf(
MovieDto::class
),
mappings = mapOf(
LocalDate::class to "Date",
LocalDateTime::class to "Date"
)
).definitionsText
File(typingsDestination).writeText(typingsContent)
val packageJsonContent = packageJson.replace("PROJECT_VERSION", projectVersion)
File(packageJsonDestination).writeText(packageJsonContent)
}
val packageJson = """{
"name": "backend-api-as-npm-typings-blog",
"version": "PROJECT_VERSION",
"description": "typings for backend-api-npm-typings java app",
"repository": {
"type": "git",
"url": "git+https://github.com/toefel18/backend-api-as-npm-typings-blog.git"
},
"author": "toefel18@gmail.com",
"license": "ISC",
"homepage": "https://github.com/toefel18/backend-api-as-npm-typings-blog#readme"
}
"""
}
Inside build.gradle
I added a custom task to execute this code:
task(generateTypings, dependsOn: 'classes', type: JavaExec) {
main = 'nl.toefel.blog.backendtypings.dto.typings.TypingsGenerator'
classpath = sourceSets.main.runtimeClasspath
args = ["backend-api-typings", "${project.version}"]
}
Now running ./gradlew generateTypings
will create a package.json
and src/typings.d.ts
inside the directory backend-api-typings
. The version of the npm package equals the project.version
variable inside build.gradle. There are multiple ways to determine a project version, I used this simple one for the sake of the example.
To publish this package, we simply need to run npm publish --access public
inside the directory backend-api-typings
.
--access public
is necessary in my case because I do not use a scope or custom registry.
Make sure you have an account on npmjs.org and first run npm login
if you try this yourself (also change the package name to avoid conflicts). If you integrate this in a build, use ~/.npmrc
to provide the credentials.
To integrate publication inside your gradle build:
task(publishTypings, type: Exec) {
workingDir 'backend-api-typings'
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
commandLine "npm.cmd", "publish", "--access", "public"
} else {
commandLine "npm", "publish", "--access", "public"
}
}
This allows you to run ./gradlew publishTypings
.
You can view the published package here: www.npmjs.com/package/backend-api-as-npm-typings-blog.
Using the typings in your Typescript App
I created a simple react app inside the frontend
directory using the command npx create-react-app my-app --typescript
.
Install the dependency with the typings using npm install --save backend-api-as-npm-typings-blog
.
Your Typescript project should include tsconfig.json
. If you used create-react-app with the typescript option, it’s already there. Inside that file you find typescript configurations. One of the settings is include
. You should add the generated typings there so that Typescript and our IDE pick it up.
// tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve"
},
"include": [
"src",
"../backend-api-typings/src/typings.d.ts"
]
}
Final Results
Now I can fetch a movie from our backend API and interpret the response as a MovieDto. You now have type-safety throughout your app, and your IDE provides code completion on your backend types:
I hope you enjoyed this post, and any comments or better alternatives are welcome.