Anterofit: wrap REST APIs with Rust traits


#1

Github Repo

Crates.io Entry

Discuss on Reddit

Anterofit Release Announcement

Anterofit has been a long time coming. I’ve been conceptualizing it ever since I started tinkering with Rust,
something like over three years ago. For the longest time, I thought it wasn’t possible to have it do everything I wanted without compiler plugins, which dampened my enthusiasm somewhat as they are perpetually unstable and break occasionally without much warning. I discovered this while working on a previous attempt at the same concept, which I had all but completely forgotten about before starting from scratch on Anterofit. As it turns out, I was partially right, as the macro-based implementation has one big, ugly limitation, but the future is looking bright.

Inspiration

I basically straight-up copied the concept behind Retrofit, a Java library I have been using for many years. It has great utility in Android development, where there is often a want for an app to talk to a server backend to store user data and things of that nature.

The basic idea is that you write a Java interface describing a REST API (which can, and should, be split across multiple interfaces), using annotations to add the necessary information to construct a request, and then Retrofit constructs an instance of that interface such that calling methods on it issues requests and returns their responses. Serialization is supported in both directions, so you can set any serializable object as the request body and have the response be deserialized to some POD object.

For a useful example, the interface and usage to fetch a post from the JSONPlaceholder API,
which has been immensely useful in developing and testing Anterofit, would look something like this:

public class Post {
    public long userId;
    public long id;
    public String title;
    public String body;
}

// Calling it a "service" comes from Retrofit's only example on interface declaration
// I've used the same convention ever since
public interface PostService {
    @POST("posts")
    Call<Post> createPost(@Field("userid") long userId, @Field("title") String title, 
                          @Field("body") String Body);
    
    @GET("posts/{id}")
    Call<Post> getPost(@Path("id") long id);
}

And then to use it, you construct an instance of the Retrofit class and have it create an object from the interface class and call the declared methods on it:

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://jsonplaceholder.typicode.com/")
    // You actually need to supply a converter to parse JSON responses into objects
    // That would be done before calling `.build()`
    .build();

PostService service = retrofit.create(PostService.class);

Call<Post> newPost = service.createPost(42, "Hello, world!", "Lorem ipsum dolor sit amet");

// The JSONPlaceholder API doesn't save anything for obvious reasons;
// this will return a Post instance with some filler text instead.
Call<Post> post = service.getPost(0);

One shortcoming of Retrofit’s documentation is that it doesn’t show how to execute the Call instance to access the response. In the version of Retrofit that I’m used to using (v1.x), you either add an extra parameter for a callback instance and declare the return type void, or use the return type directly (which makes the request synchronous). This makes things a little more clear, but if you want an asynchronous and a synchronous call to the same endpoint, you need to declare two different methods, which is kind of ugly.

Anterofit functions similarly to how Retrofit does now, but hopefully the documentation is sufficiently detailed to avoid confusion.

Introducing Anterofit

Anterofit is a collection of macros that makes it easy to wrap REST APIs using Rust traits. Superficially, it functions similarly to Retrofit, but instead of constructing trait instances at runtime (which would be hacky and full of overhead), Anterofit simply rewrites the trait declarations and injects all the necessary details for making a request and parsing the response. This makes the implementation much less magical, more approachable and more maintainable. Also, much easier to document.

Additionally, Anterofit is futures-aware (Call<T> implements Future<Item = T>), so that you can poll for the status of requests from an event loop. However, for standalone use, it also provides inherent methods equivalent to Future::poll() and Future::wait() without requiring use of the futures crate. I have great plans for futures integration; see the Looking to the Future / Tokio section for details.

The name didn’t really sound “cool” when I first came up with it, but it’s grown on me. Retrofit is an appropriate name for a framework which does all the setup at runtime, so I figured that since Anterofit does almost all the setup at compile time instead (in true Rust fashion), I should try to find (or construct) the antonym of “retrofit”. I would like to say I coined “anterofit” out of my own brilliance, by realizing the opposite of “retro-” could be considered to be “antero-”, but I didn’t. I stole the word from this StackExchange answer instead. Technically, doesn’t the word exist now that there’s a semi-legitimate usage for it?

How It Works

I have written a User’s Guide going into much more detail, but here’s the basics:

  • Use the service!{} macro to write a trait interface describing a REST API endpoint (for example, the equivalent
    of the above example for Retrofit):
#[macro_use] extern crate anterofit;
// Serde is also supported
extern crate rustc_serialize;

#[derive(Debug, RustcDecodable)]
struct Post {
    pub userid: Option<u64>,
    pub id: u64,
    pub title: String,
    pub body: String
}

service! {
    pub trait PostService {
        /// Get a Post by id.
        fn get_post(&self, id: u64) -> Post {
            // Using normal Rust expressions was easier to implement than trying to emulate 
            // Retrofit's annotation approach with attributes;
            // Plus, it's more elegant, IMO.
            GET("/posts/{}", id)
        }

        /// Create a new Post under the given user ID with the given title and body.
        fn create_post(&self, userid: u64, title: &str, body: &str) -> Post {
            POST("/posts/");
            // Provide HTTP form fields as key-value pairs
            fields! {
                "userId" => userid,
                // Shorthand for when the identifier is the same as the key
                title, 
                body
            }
        }
    }
}
  • Construct an adapter with the base URL and serializers (unlike Retrofit, JSON serialization is provided out-of-the-box):
use anterofit::{Adapter, Url};

let url = Url::parse("https://jsonplaceholder.typicode.com").unwrap();

let adapter = Adapter::builder()
    .base_url(url)
    // Shorthand for setting both JSON serializer and deserializer
    .serialize_json()
    .build();
  • Then call trait methods on the adapter, or coerce it to a trait object:
// Instead of having the adapter create opaque implementations of service traits like Retrofit,
// all service traits are implemented for `anterofit::Adapter` by default;
// using generics like this helps keep service methods namespaced.
fn create_post<T: PostService>(post_service: &T) {
    let post = post_service.new_post(42, "Hello, world!", "Lorem ipsum dolor sit amet")
        // Requests in Anterofit aren't sent until executed:
        // by default, the request will be executed via blocking I/O on a background thread.
        // This returns `Call<Post>` which can be polled on for the result.
        .exec()
        // Equivalent to `Future::wait()` without using types from `futures`
        .block()
        .unwrap();

    println!("{:?}", post);
}

// `anterofit::Adapter` is object-safe!
fn fetch_posts(post_service: &PostService) {
    let posts = post_service.get_posts()
        // Shorthand for .exec().block(), but executes the request on the current thread.
        .exec_here()
        .unwrap();

    for post in posts.into_iter().take(3) {
        println!("{:?}", post);
    }
}

create_post(&adapter);
fetch_posts(&adapter);

That’s it. No magic, no overly complicated metaprogramming, less work being done both at compile time and at runtime. On top of that, Anterofit is an order of magnitude more type-safe than Retrofit is.

The service!{} macro is a bit of a mess mostly because of the limitations of macro_rules! macros, but I have plans to replace this using procedural macros. See the Looking to the Future / Procedural Macros section for more details.

Comparison to Retrofit

This is kind of apples-and-oranges given that Java and Rust are two very different platforms, but I figured the “order of magnitude more type-safe” claim in the previous section needed substantiating.

  • In Retrofit, if you pass the wrong class to Retrofit.create(), you get a runtime exception.

Meanwhile in Anterofit, if you try to coerce anterofit::Adapter to a type that isn’t a service trait, you get a compiler error pointing right to the problem, and suggestions for types that would work.

  • Serialization in Retrofit relies on a lot of magic (i.e. class metadata at runtime). Serialization for classes doesn’t have to be explicitly implemented (at least if using the GSON converter), so if you use the wrong type you might get surprising results.

In Anterofit, serialization for a given type has to be explicitly implemented; you’ll get a compiler error otherwise.

  • In Retrofit, missing fields in deserialized objects are initialized to their defaults as per the Java language spec, so null for reference types and 0 or false for primitives.

In Anterofit, missing fields that are not Option are a serialization error. Though, to be honest,
this depends on how deserialization is implemented.

Easily Construct API Wrappers

The design of Anterofit doesn’t just take end-user applications into account.

If you’re using Anterofit to create an API wrapper for some popular REST API, say, Reddit’s or Github’s, exposing the particular implementation details of your wrapper is probably undesirable.

For example, you might want to use the type system to restrict access to some APIs in certain states, such as statically preventing errors in trying to access the user’s private data before they’ve logged in. And you probably want to make sure that only, e.g., JSON serialization is used for both request and response, to avoid confusing errors with data formats.

In these and similar cases, you’ll probably want to still expose your service trait definitions as part of your API because you’d have to repeat most of their definitions elsewhere, but you probably don’t want them to be used with any old anterofit::Adapter instance, especially if you want to prevent people who are using multiple wrappers at once from shooting themselves in the foot by using an adapter set to the wrong endpoint for some service trait.

For this case, Anterofit provides a way to suppress the default implementation for anterofit::Adapter and provide one or more alternate implementations, which I’ve chosen to call “delegates”. Delegates are more like shells really, as they’re expected to provide an accessor for an instance of anterofit::Adapter, but this can be entirely opaque; you can totally use a private field or method, as long as it obeys Rust’s visibility rules.

For example, a delegate implementation for PostService:

pub struct MyDelegate {
    adapter: ::anterofit::Adapter,
}

service! {    
    pub trait PostService {
        // Methods omitted for brevity
    }
    
    // The expression inside the braces is expected to be `FnOnce(&Self) -> &Adapter<...>`
    impl for MyDelegate { |this| &this.adapter }
}

let delegate: MyDelegate = ...;

create_post(&delegate);
fetch_posts(&delegate);

Now, only MyDelegate implements PostService, making abstraction possible while keeping noise to a minimum.

Unanswered Design Questions

I’ve put a lot of thought into the design of Anterofit, and I’ve tried to anticipate the most common and the most interesting use-cases, but there’s some parts I’m still not quite sure about and I’m interested in feedback. I’ve listed some of my doubts on the first Github issue on the repo, please feel free to share your opinions there, or suggest other areas which might be improved.

Discussion on the announcement post is also welcome. I will add any non-negligible concerns to the Github issue for archiving and further discussion.

Looking to the Future

Tokio

Once the dust around Hyper’s transition to async I/O with Tokio settles, I plan on branching multipart to support futures, and then rebuilding Anterofit on top of it. The public API won’t change much, save for a couple new executors which are aware of Tokio event loops; one to execute all requests on a background thread’s event loop, and one to execute requests on the event loop running in the current thread, making Anterofit truly asynchronous.

Procedural Macros

The current implementation of the service!{} macro is mostly satisfactory, but it’s rather noisy due to having to support different combinations of syntax features, mainly generics and where clauses, and even then it requires using [] as delimiters for generics than <> because angle brackets aren’t supported as token-tree delimiters.

In reality, none of this is relevant to the actual implementation of service trait generation; other than separating ethod signatures from their bodies and rewriting the return type, the service!{} macro doesn’t really care about the specifics.

With the introduction of procedural macros beyond custom derive, the solution was clear: implement service!{} as an attribute. I was so excited, I wanted to immediately try prototyping it. The going was pretty easy, and I had a mostly finished prototype, but when I went to test it, I discovered a core problem with my prototype: attribute procedural macros weren’t implemented in the compiler yet! That wouldn’t do, so I decided I’d go ahead and get that out of the way (with @jseyfried as an excellent mentor).

Once attribute procedural macros were working and I had my prototype up to speed, I found that the implementation was far more elegant, extensible, easier to follow, and almost shorter than the implementation in macros was! And the best part: it didn’t have any of the syntactic limitations as the macro version. I was immensely excited.

With the #[service] attribute, the declaration of PostService would look like this:

#[service]
pub trait PostService {
    fn get_post(&self, id: u64) -> Post {
        // Since this is already valid Rust syntax, it doesn't break the parser
        GET("/posts/{}", id)
    }
    
    fn create_post(&self, userid: u64, title: &str, body: &str) -> Post {
        POST("/posts/");
        fields! {
            "userId" => userid,
            title, body
        }
    }
}

This doesn’t look too much different, but the main point is that when implemented this way, generics and where clauses aren’t a problem. They can simply be passed on after parsing and re-emitted. Superficially,
it also saves a level of indent which helps limit right-drift. The main feature is the much simpler implementation, which also has, IMO, better diagnostics for unsupported features (like associated types and unsafe traits, which can be implemented but don’t seem relevant currently).

Unfortunately, it looks like the remaining procedural macro features won’t be fast-tracked to stabilization like
custom derive was. I’m ambivalent about the reasoning; ideally, TokenStream should have a lot more flexible
API and support things like hygiene before we start encouraging people to use procedural macros en masse.
However, since my implementation works as-is and, with the possible exception of hygiene,
it seems like these things can be implemented backwards-compatibly, I don’t think there’s much reason to keep procedural macros themselves unstable for very long.

In-Vivo Test

Of course, no great project is complete without a sister project built with it as an in-vivo test and an
endless well of design feedback; Rust has Servo, LLVM has Clang… okay, I’ve run out of examples, but you get my point.

As counterpart to Anterofit, I plan on reviving my Reddit API Wrapper for Rust project, which I initially
started building Ferrite for. The eventual goal is to have RAWR testing as many features of Anterofit as possible; interceptors, delegation, procedural macros, and maybe an auxilliary Imgur API for uploading images, to test file uploads. I may wait for this until I get Anterofit ported to Hyper-Tokio, however.

Conclusion

I don’t really have a big conclusion statement put together, it just seemed weird to end this announcement without saying something. I could have kept polishing this perpetually in private but I figured that wouldn’t do much good for me, the library, or anyone else. So please try it and let me know what you think. I’d also love to hear about any potential use cases that aren’t covered by the current API.