Over the last couple of years, I have developed two small web services in Rust: one for EndBASIC and one for this blog. Those two web services contained significant copy/pasted helper code, which always bothered me because small bug fixes in one rarely propagated to the other. But because this only impacted two inconsequential side projects, the hinderance wasn’t a big deal.
Until now. I now face the need to write two more web services (details TBA), and duplicating those foundations twice more felt just wrong. So I spent the last couple of weeks pulling the common code out of the existing services into a… you guessed it… framework, which I have called III-IV ("three four" if you read it out loud) and am ready to announce.
Find the code in https://github.com/jmmv/iii-iv.
This is weird
But before getting into the details… what’s up with this weird name? You know, picking a name has been extremely difficult: all of the “obvious” ideas that came to mind were either already in use by other crates or by other projects. After many drafts, I started zoning into 3 and 4, which represent the number of layers you have to implement to develop a service with this framework. And 3 and 4 also represent the third and fourth services that precipitated the creation of this framework. So these numbers seemed fitting. And as you cannot start identifiers with a digit, roman numerals fixed that. Yes, it’s a very obscure name, but that’s fine because…
Who asked for yet another framework? Aren’t there plenty of them out there already? Well akshually, if you search the web for “Rust web framework”, you will find things like Rocket and Axum, which are great for implementing the HTTP request handling of the service… but they stop there. In my services, I need consistent features that span all parts of the app, from the HTTP router to the persistence layer, passing by support features like outbound email or OAuth flows—all while supporting lightweight unit testing at every layer. Glueing all existing pieces together requires a non-trivial amount code, so that’s where this comes in.
What is III-IV?
III-IV is a rudimentary and very opinionated framework with which to write web services in Rust. The framework is really just a thin layer over existing and well-established Rust libraries: all III-IV does is facilitate putting things together and removing boilerplate glue. The goal is to keep each service implementation focused on its business needs in order to more-easily reason about its functionality.
Keep in mind that this framework exists to satisfy my needs for my web services. It won’t be big and professional like Django (wink, wink), and I do not expect it to be useful to anyone but me. That said, if you do find any of this useful, by all means go ahead and use whichever parts you find interesting. Documentation is minimal at this point though. I’ll be happy to entertain contributions as well—but let’s discuss any major changes first if you have any.
So, what does III-IV offer and what makes it opinionated? This framework assumes that your web service will:
Adhere to a 3-layer architecture with the specific names:
db, and provide a fourth cross-cutting layer named
modelto offer data types. This is where the III-IV name comes from.
Use PostgreSQL for serving. SQLite could work too though but not in the context of serverless apps like the ones I’ve been deploying.
Use SQLite for unit/integration testing. A major design decision has been to allow super-fast non-flaky testing with zero configuration. I do not want to have to spin complex dev environments up just to work on my code whenever I have some spare time: all I want is for
cargo testto do the right thing and to do it milliseconds. And I have achieved this.
sqlxcrate for database access.
tokiocrate as the async runtime.
lettrecrate for SMTP communication.
Be configured via environment variables from
main.rs, some of which have predefined names.
Take minimal dependencies on cloud services, although the provided template favors Azure Functions.
Example and template
To illustrate how to use III-IV and how it keeps the code of a service free from boilerplate, I have written a tiny sample key/value store. You can find the source code for the example under jmmv/iii-iv/example.
Here are some of the highlights to look for:
modelmodule provides high-level data types to represent the concepts in the problem domain. Abusing the newtype idiom and named structures is a critical aspect of this framework to ensure data correctness throughout (particularly after data values have been interned by the REST layer).
dbmodule provides a
Txtype. This type offers “one shot” operations on the data types provided by the
model, and supplies implementations for both PostgreSQL and SQLite. The PostgreSQL variant is used when instantiating the app for serving in
lib.rsand the SQLite variant is used for unit testing with an in-memory database. Both implementations are validated by the exact same test collateral.
drivermodule provides a parameterized
Drivertype that holds the state of the application and provides the business logic for the operations that the REST layer will need. This is where the various backend services (in this case, database transactions only) are coordinated.
restmodule provides the HTTP API entry points and the HTTP router. This layer’s responsibility is to parse HTTP requests and write out HTTP responses. No application logic lives here. Of special interest in this layer is the use of the
OneShotBuilderutility to test every HTTP API end-to-end.
top-levelmodule instantiates the various layers for the production service.
mainentry point extracts application configuration from environment variables (the variable for the port name assumes deployment to Azure Functions) and runs the app.
The main purpose of this example is to serve as a template for new services. As a result, its code is overly verbose, in the sense that it includes many more source files and tests than it really deserves. The verbosity is necessary, however, to enforce structure and to allow simpler copy/pasting of the code into new services.
Note that the sample service does not use all features of the framework so it may be hard to see how to leverage some of them. This might change in the future, either via this example or via additional examples. I’m finding the way
axum showcases functionality to be very amenable to learning.
The process to create III-IV has been incredibly painful: I spent way too long figuring out how to remove superfluous
Arcs used for polymorphism in favor of static dispatch. But, once I was able conquer this hill, new (to me) Rust concepts “clicked” and I’m pretty happy with the results. I’m a believer in keeping generic logic separate from domain-specific logic (even within a single project), so creating III-IV and using it to remove code duplication from multiple projects actually feels great.
So, what’s next? For starters, there is still a bunch of logic in my existing services that would benefit from generalization to keep their clutter to a minimum. Shuffling this functionality into III-IV requires “actual” redesign work though, and so far I’ve only been moving code almost-verbatim. I will only tackle these features whenever I come back to work on these services.
And then, well, whichever thing I happen to need for the new web services I’m working on. If a piece of functionality looks generic enough and potentially reusable in other services, it will go into III-IV. Two things that I immediately need are: support for OIDC user authentication via Auth0 and seamless integration with BootstrapVue for the frontend components.
With that, head to https://github.com/jmmv/iii-iv for instructions on how to depend on this code from your
Cargo.toml files. Have fun!