On my current project for the Ministry of Justice we've been using OpenAPI to keep our typescript types up to date with our API
How it works
- We have a specification of the endpoints and how to use them written to the OpenAPI (FKA Swagger) standard that lives in the API repo
- We use a Gradle library, OpenAPIGenerate, to generate models, controllers and interfaces that the API uses.
- This means that the API has to comply to the specification
- When a change is made to the spec (via a PR merged into
main
) a Github action runs in the API repo which triggers another Github action in the UI repo which generates the Typescript (TS) types from the spec - The test script we use in CI in the UI repo automatically downloads the latest types and checks against them
The good things
- The UI is always in sync with not only the spec but the API too meaning we don’t expect to recieve anything from requests that doesn’t exist or send anything to the API it doesn’t expect
- The majority of our TS types are autogenerated so we don’t have to write them
- Since using this approach we’ve eliminated a class of bugs
The bad things
- The two parts of the application feel tightly coupled, when the API updates we have to update the UI too. This is a risk for future development but currently it hasn’t hurt us. Also the nature of the application means to the two components were very tightly coupled anyway due to the organisations preferred architecture.
- Importing the latest types sometimes breaks things in the UI but normally the blast radius is quite limited. 90% it just means updating some of the factory functions we use to create test data.
Why did we implement it
- As we have been developing this project the API and the UI have been developing different features at different times. Some parts of the application that include a lot of UI work don’t involve much API work and visa versa.
- This, along with some organisational factors, mean we have to mock a lot of the interaction between the UI and the API.
- When mocking we were copying what the spec said we would be getting.
- This led to two kinds of errors:
- Human error, mistyping, misreading etc
- The spec could change without us updating the stubs. Relying on manually updating one repo when the other changes means that things can slip out of date quite easily. The API developer would have to notify the UI developer any time a change was made because the API developer doesn’t know intimately how the data is being used and whether the changes would cause breakages in the UI
- This meant that sometimes the UI would request something but it was different from what it thought it was getting. Other times the UI would pass info to the API that it wasn’t expecting - which caused an error.
- So we took inspiration from another team in our department and decided that we should have Cypress tests that run post-merge on a PR in the UI repo. These tests just hit the happy path to confirm everything was working as expected and there were no crashes or major errors. This didn’t really work that us. The issues we had with it were:
- The app is going to be quite big so these test runs are going to take a while in dev, even just focussing on happy path.
- The errors were indirect. A crash during a test probably meant that the API didn’t pass us some data it was expecting but it could be almost anything
- This meant we had to dig in to what was happening through various methods. UI logs, API logs, logs from Cypress and do some detective work.
- Normally my preferred method of investigation would be running the app fully locally and stepping through what was going on but this was hard because of the amount of dependencies. It’s difficult for our machines to run the stack locally because there are so many dependencies that can’t be mocked.
- The tests ran post merge because this is when we had pushed the Docker image to the repository which we needed to run the tests. Premerge tests are great because you get to prevent breakages before they happen whereas postmerge tests should really be avoided as you only find out you’ve broken something post-hoc.
- It made sense to run the tests in the UI repo because we already have Cypress configured there but a postmerge test failure wouldn’t necessarily mean there was an issue with the PR that has just been merged: it could have been because the API changed under the UI’s feet.
- The problems I’ve mentioned I’ve mentioned aren’t insurmountable, you can:
- become a better detective
- build and push the Docker image premerge and use that in the tests
- get a more powerful machine or optimise the dependencies to be less resource intensive
- But we looked into alternative arrangements we went looking for contract testing tools. A couple that caught our attention were pact and prism.
- I’ve used Pact at a previous job and it was good but you have to do some work to set it up, it needs its own config and it felt a bit heavy duty for what we were after.
- Prism looked promising as it uses the OpenAPI spec that we already had been producing but it looked a bit to early stage to be used in prod.
- So we didn’t have a good solution and as the app was getting bigger the contract tests were taking us longer and longer to maintain. We didn’t realise but the solution was staring us in the face all this time.
- We were already using an OpenAPI spec to define the API specification
- We use a Gradle library called OpenAPIGenerate to generates some models, controllers and interfaces for the API. This means that the API has to comply to the spec
- In the frontend we use types that we were copying by hand from the OpenAPI spec. So our idea was - instead of writing the types by hand why not generate these types from the spec in the same way we generate the API from it