Auto-generated client libraries from OpenAPI specs
This document proposes to automatically generate and publish client libraries for Ebury services that expose a REST API. Our Continuous Integration and Continuous Deployment (CI/CD) pipelines shall automate these tasks based on the OpenAPI specification of the service.
Problem Description
Services expose the functionality they implement by means of a remote API. The team building a given service is currently in charge of developing (at least) one client library to facilitate the consumption of the offered capabilities in other services or applications. Instead of wasting development resources in creating, updating, and publishing client libraries, this document encourages to automate this process.
In particular, we describe how to automatically generate client libraries for REST APIs using OpenAPI Generator. As long as teams maintain an OpenAPI specification for their services, libraries for most programming languages can be created and published to the appropriate registry automatically.
Background
One of the main benefits of REST APIs is that they can be easily consumed by third parties. However, the developer experience improves significantly if the API has a well-maintained client library in the programming language of the client application. Otherwise, each client application would have to solve the same kinds of problems; e.g. the network and serialization libraries to use, or the exception hierarchy and corresponding error handling, among others.
While this applies to both public-facing and private APIs, the impact on our internal workflow might be significant as we transition from monolithic applications to micro-services1. Splitting the monoliths helps avoid development bottlenecks, and therefore it allows technical teams to scale better. However, in such a new scenario, multiple services must collaborate among themselves in order to implement a business case. If we have N Python services using the API of another one, we want them to use the corresponding client library instead of duplicating the work N times. Hence, at this moment teams build client libraries for services as they are required.
But the number of libraries required per service could start to grow rapidly. As we move into a polyglot environment with applications and services written in different languages (e.g. Javascript) or flavors (e.g. synchronous, threaded, or asynchronous Python), the overhead of creating and maintaining these libraries threatens to (partly) jeopardize the scalability gains achieved by adopting micro-services.
In order to put this overhead in perspective, note that a team member may require about two weeks (one Scrum iteration) to create a single client library for a small service. But the main problem is that every future change in the service may require the team to update the library accordingly and to publish the new version in the appropriate registry. Since this is a manual step at this moment, it's error-prone and easy to forget. Moreover, creating, updating and publishing libraries are not value-added tasks, just technical requirements.
In order to let our developers focus on tasks that provide our clients with real value and avoid unnecessary overheads, we encourage all teams to automatically generate client libraries for the micro-services they build.
The solution depicted in this document is intended for services exposing a REST API. In the remainder of this section we briefly review the building blocks required for our proposal: OpenAPI specs and the OpenAPI Generator tool2.
OpenAPI
The OpenAPI Specification defines a standard document format to describe RESTful APIs. OpenAPI specs are JSON objects that can be represented in either JSON or (the more human-friendly) YAML formats.
The benefits of maintaining OpenAPI specs are multiple. They document the capabilities offered by the service; they serve as a contract between provider and consumers; and we can take advantage of the ecosystem of tools around them (documentation formatters; API clients; automatic generation of client libraries, server stubs, and mock servers; and many more). Nonetheless, having OpenAPI specs is a recommended practice at this moment.
There are multiple ways to maintain the OpenAPI spec of a service. Some common ways to do this (in increasing order of preference):
- Manually. Have the spec file under revision control in the service's git repository.
- Generated from code annotations. Use framework plugins that let you insert OpenAPI definitions as part of your docstrings or using some kind of annotation. The spec is generated automatically from such annotations.
- Generated from code. Use a framework able to extract OpenAPI specs from your code. E.g. FastAPI leverages Python 3's type hints to automatically generate the OpenAPI spec.
OpenAPI Generator
Once we have the OpenAPI spec for the service, we can leverage OpenAPI Generator to create our client libraries.
OpenAPI Generator is an open source Java application that generates clients, servers, and documentation from OpenAPI specs. The project maintains public Docker images to run the application.
It comes with built-in generators for more than 50 programming languages (including Javascript, Typescript, Java, Go, or Python). The auto-generated code should be enough for most purposes. But in case it's needed, we could fine-tune the output by means of writing our own templates (easier) or creating a custom generator (more involved and less likely to be required).
The client libraries created via the default Python generator have these nice features:
- Support of Python 2/3.
- Client-side validation. I.e. if you pass the wrong params to an API call, the library will raise an error instead of sending the request over the network. This includes some type validations like regex matching for string params. It can be disabled via client code if needed.
- Support of synchronous and threaded clients.
- Support of asynchronous clients. We can generate additional client libraries
with
asynciosupport.
Solution
Description
Following our current best practices, each service exposing a REST API must have a corresponding OpenAPI spec. We propose that client libraries must be automatically generated from such spec using OpenAPI Generator.
The CI/CD pipeline must ensure that the client libraries can be correctly generated in every build. Otherwise, the build shall fail.
The CI/CD pipeline must publish the new version of the client libraries each time the service is deployed successfully. I.e. the pipeline must be in charge of uploading the libraries to the corresponding registries (e.g. Ebury's PyPI in the case of a Python library).
Benefits
-
Lower development overhead. Client libraries are automatically generated instead of manually developed.
-
Lower management overhead. We don't need additional git repositories to host the code of client libraries any more. Furthermore, when a new version of a service is deployed, the corresponding client libraries are automatically generated and published.
-
As a consequence of the latter, this solution is less error prone because it eliminates human intervention in the process. Overall, the whole system gets more consistent as every service in production has its corresponding client libraries ready to be used.
Implementation
The solution above has been already implemented to generate the account-details-client Python library for the Account Details Service.
In the remainder of this section, we describe the approach we followed and how it can be further developed.
Docker images for OpenAPI Generator
Custom Docker images based on OpenAPI Generator's official image can be created as required. The granularity for these images may range from a single image to generate all Ebury client libraries, to as many images as client languages and flavors are used.
So far, we have one Docker image that can be used to generate client libraries in Python with simultaneous support of versions 2 and 3, and both synchronous and multi-threaded clients:
- Image:
public.ecr.aws/ebury/eburypartners/openapi-generator-python - Git repository: docker-openapi-generator-python
- Example usage via Docker Compose:
# openapi_json=<path-or-url-to-openapi-spec>
# project=<project-name>
# version=<lib-version>
# output=<output-path>
docker-compose run --rm openapi_generator \
-i ${openapi_json} \
-p ${project} \
-v ${version} \
-o ${output}
# docker-compose.yml (snippet)
services:
openapi_generator:
image: public.ecr.aws/ebury/eburypartners/openapi-generator-python:1.1.0
entrypoint: ["/bin/generate-lib.sh"]
dns:
- 10.5.0.3
networks:
- ed2k
user: ${USER_ID}:${GROUP_ID}
volumes:
- "../:/local"
depends_on:
- application
REST services
In order to benefit from this proposal, services must take three simple steps: expose the OpenAPI spec, provide a Makefile target, and add a var in their Jenkinsfile.
Expose spec
Each service that implements a REST API must expose the corresponding OpenAPI
spec in a well-known URL (e.g. /openapi.json). An alternative implementation
could maintain the spec as a file under revision control. However, we recommend
to automatically generate the spec from our code if possible.
Makefile target
The service must declare a generate-lib target in the project's Makefile. The
CI/CD pipeline will run this target to create the library. The build will fail
if the client library cannot be generated for any reason.
.PHONY: generate-lib
generate-lib: ##@development Generate client library from OpenAPI spec.
generate-lib: openapi_json?= http://account-details.localhost/openapi.json
generate-lib: project?= account-details-client
generate-lib: version?= local
generate-lib: output?= api-client
generate-lib:
${DC} run --rm openapi_generator -i ${openapi_json} -p ${project} -v ${version} -o /local/${output}
Note that the value of default arguments refer to the local development environment. In order to generate the client library for the production service, we must provide the corresponding URL for the OpenAPI spec as shown next.
Jenkinsfile arguments
When a service wants to generate and publish a library automatically, the
corresponding Jenkinsfile just has to include the openapi_json argument with
the URL of the production spec.
Note that the former guarantees that we release the library for the service version currently running in the production environment.
@Library('jenkins-devops@7.39') _
// It calls to https://github.com/Ebury/jenkins-devops/blob/master/vars/eburyPipelinePythonService.groovy
String projectId = 'account-details'
def daemons = [:]
def kwargs = [
'system_tests' : false,
'openapi_json': 'https://account-details.eburypartners.com/openapi.json'
]
def slackChannels = ['#releases-account-details-service']
eburyPipelinePythonService(projectId, daemons, kwargs, slackChannels)
Jenkins pipelines
For each kind of service, the corresponding Jenkins pipeline must support client
library generation and publishing in a conditional way. I.e. to maintain
compatibility with services not exposing an OpenAPI spec, the generation and
publishing stages only run if the argument openapi_json has been given.
The remainder of this section shows how this has been done for Python services:
- Git repository: eburyPipelinePythonService
Generate library in every build
- Define new stage.
- Enter stage only if argument
openapi_jsonhas been given. - Use Makefile targets
serverandgenerate-libto generate the library. Note that the former is required because the service is exposing the OpenAPI spec that the generator is consuming.
stage('Generate Client library') {
when {
beforeAgent true
expression {
kwargs.containsKey('openapi_json')
}
}
steps {
sh 'make server generate-lib'
}
post {
always {
sh 'make clean'
}
}
}
At the moment, the objective of this stage is to make sure that we don't break the auto-generation of the library.
Publish library after deployment
- Define new stage.
- Enter stage only if argument
openapi_jsonhas been given and a new release has been deployed. - Use Makefile target
generate-libto generate the library from the spec exposed in the production environment. - Upload library to the appropriate registry. In the case of a Python library,
use the
devpimodule.
Note that the current implementation (see below) uploads the library to two different registries: One of them is used by BOS and it should be deprecated soon.
stage('Upload Client library') {
when {
buildingTag()
beforeAgent true
expression {
kwargs.containsKey('openapi_json')
}
}
steps {
sh "make generate-lib openapi_json=${kwargs['openapi_json']} version=${env.TAG_NAME} output=${env.PROJECT}-api-client"
dir("${env.PROJECT}-api-client") {
devpiRun {
script {
devpi.use("${env.DEVPI_URL}")
devpi.login('jenkins')
devpi.use('jenkins/dev')
devpi.upload()
devpi.pushLocalRepo("${env.LIBRARY_NAME}", "${env.TAG_NAME}", 'jenkins/staging')
devpi.pushRemoteRepo("${env.LIBRARY_NAME}", "${env.TAG_NAME}")
}
}
}
}
}
Alternatives
Keep doing this manually
As already explained throughout this document, this option wastes development resources and the overhead will just keep growing as we develop more services and adopt more programming languages.
Automate using gRPC tooling
Services using gRPC can define their API using protocol buffers as the IDL. Specific tools can then be used to automatically generate client libraries following the same workflow described in this document.
However, for services exposing a REST API, the solution described here is the one that applies.
Automate using other OpenAPI generators
OpenAPI Generator has been chosen because it is the SDK generator that supports more languages and it is backed by a large community. However, other generators are available and might be used without impacting the overall solution described here.
Caveats
The Python code that OpenAPI Generator issues by default has some shortcomings. In decreasing order of importance:
-
Client-side validation of server responses cannot be disabled. This might be a problem in some cases. E.g. say we have a service whose response includes an enumerate field. In version
1the allowed values areAandB. Version2addsCas an allowed value. As soon as version2is released, clients using the library for version1will break if they receive a response that includesCas the value of such field. -
Some client-side validations cannot be disabled. In particular, trying to pass a request parameter that does not exist in the current library version is not allowed.
-
Error responses from the API trigger a generic
ApiException. While the exception includes all the information about the particular error, it would be nicer if they triggered specific exceptions per error type.
Note that all of these drawbacks can be fixed by customizing the generator.
Generators for other languages may have similar shortcomings - and they could also be fixed by means of fine-tuning the generated code.
Operation
N/A
Security Impact
N/A
Performance Impact
No performance impact on services.
CI/CD builds include two conditional extra steps. When they apply, the build takes a bit longer but it is negligible with respect to the overall build time.
Developer Impact
Overall, this solution has a positive impact on development because it removes the need of manually creating, updating, and publishing client libraries.
Services using the described solution must implement the three simple steps referred above. As long as the service is using a supported pipeline (Python service) and supported client language (Python library), the impact is very low.
In order to support additional service types, developers must coordinate with the DevOps team to build the pipeline. The impact in this case is also very low.
In order to support client libraries in additional languages, developers must create a new Docker image based on the official one from OpenAPI Generator and coordinate with the DevOps team. Again, this has low impact.
When the need for customizing the auto-generated code arises, developers must coordinate with other teams also using that generator. Alternatively, the team might use a dedicated OpenAPI Generator image if they want to include changes that might break other services' workflow (and coordinate with the DevOps team if required).
Data Consumer Impact
N/A
Deployment
This solution is already deployed for the described use case.
Dependencies
N/A
References
N/A
1. In this document, the term micro-service doesn't refer to the size of the service. In general, the scope of the service should account for the high cohesion and loose coupling principles of software systems design.
2. Note that OpenAPI Generator is a switchable component of the described solution. Other SDK generators could work too.