Skip to content

Latest commit

 

History

History

tutorial

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 

Tuturial

hsr is a code-generator. It takes an OpenAPIv3 spec as an input and generates Rust as it's output. It can massively reduce the boilerplate and general annoyance of writing an HTTP API, either for an internet-facing web service or an internal micro (or macro!) service. And becuase it is just a thin layer on top of actix-web, performance is excellent.

OpenAPIv3, aka swagger, is a popular format for specifying an HTTP API. If you aren't familiar with OpenAPI, I recommend having at least a quick skim of the the docs before proceeding.

The generated Rust code presents an interface (i.e. a trait) which the user should implement. Once that trait is implemented, the server is ready to go. hsr takes care of bootstrapping the server, url routing, serializing the inputs and outputs and other boilerplate.

Rust's powerful type system makes this a particularly nice workflow because 'if it compiles, it works'. Suppose you need to modify or add an endpoint, you simply modify you API spec (usually a .yaml file) and recompile. rustc will most likely throw a bunch of type errors, you fix them, and you're done.

Right, enough talk, lets get started. Make a new project.

cargo new tutorial
cd tutorial

We'll use the handy cargo-edit to add our dependencies.

cargo add -B hsr-codegen        # build dependency
cargo add hsr actix-rt serde    # runtime dependencies

Hello HSR!

First, we'll define an api. Create a spec.yaml file containing the following:

# spec.yaml
openapi: "3.0.0"
info:
  version: 0.0.1
  title: hsr-tutorial
servers:
  - url: http://localhost:8000

paths:
  /hello:
    get:
      operationId: hello
      responses:
        '200':
          description: Yes, we get it, hello

This is just about the simplest possible API. It exposes a single route, GET /hello, to which it responds with a 200 OK. That's it. Yes, I know it's boring, we'll make it WACKY later - for now we're just going to build it.

Create a build.rs file in your project root:

// build.rs

use hsr_codegen;
use std::io::Write;

fn main() {
    let code = hsr_codegen::generate_from_yaml_file("spec.yaml").expect("Generation failure");

    let out_dir = std::env::var("OUT_DIR").unwrap();
    let dest_path = std::path::Path::new(&out_dir).join("api.rs");
    let mut f = std::fs::File::create(&dest_path).unwrap();

    write!(f, "{}", code).unwrap();
    // If we alter the spec.yaml, we should rebuild the api
    println!("cargo:rerun-if-changed=spec.yaml");
}

Now if we run cargo build, it does... something. Specifically, we just told Rust that at build-time it should:

  • Open the spec file
  • Generate our interface code from the spec
  • Find the magic OUT_DIR and write the code to $OUT_DIR/api.rs

Nice! But not very useful as-is. To actually use this code, we need to get it into our project source.

Create a file src/lib.rs containing the following:

pub mod api {
    include!(concat!(env!("OUT_DIR"), "/api.rs"));
}

Now we can compile! Go! Or, actually, wait a sec. We are going to codegen an interface. Really, we want to be able to view our API. But viewing the raw code isn't very enlightening (and Rust doesn't make it easy), instead we view it in rustdoc.

$ cargo doc --open

Ok, this time it did something useful. Inside the api module we can see a promising-sounding things like client and server modules and an HsrTutorialApi trait.

The HsrTutorialApi trait has a rather intimidating definition, something like:

trait HsrTutorialApi {
    fn hello<'life0, 'async_trait>(&'life0 self) -> Pin<Box<dyn Future<Output = Hello> + 'async_trait>>
    where
      'life0: 'async_trait,
      Self: 'async_trait;
}

This is not as complicated as it looks. Basically this trait should be read as:

trait HsrTutorialapi {
    async fn hello(&self) -> Hello;
}

where Hello is defined elsewhere. Which we can see closely matches the definition in spec.yaml.

Why does the definition... not look like that? Well, unfortunately "async-in-traits" is not yet supported (issue) so for now we work around it with the amazing async-trait, which however gives us these slightly inscrutable api definitions.

Lets gloss over this for now and implement the trait. Continuing in src/lib.rs:

// src/lib.rs

/* .. previous code .. */

struct Api;

#[hsr::async_trait::async_trait(?Send)]
impl api::HsrTutorialApi for Api {
    async fn hello(&self) -> api::Hello {
        api::Hello::Ok
    }
}

Notice that we've used the async_trait macro (conveniently re-exported from hsr) to allow us to implement the trait as we would 'like' it to be written, rather than as it is defined according to the docs.

The last step is to serve our api. We can see from the api docs that there is a function in tutorial::api::server with the signature

pub async fn serve<A: HsrTutorialApi>(api: A, cfg: Config) -> std::io::Result<()>

so let's use that.

In src/main.rs:

use tutorial::{api, Api};

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    let config = hsr::Config::with_host("http://localhost:8000".parse().unwrap());
    api::server::serve(Api, config).await
}

That's it, the webserver is ready. We're going to test it with httpie, which can be installed with pip3 install httpie --user.

// terminal 1
cargo run

// terminal 2
➜  ~ http --print hH :8000/hello
GET /hello HTTP/1.1
# ...

HTTP/1.1 200 OK

It lives!

That's not fun, make it fun

Fine, very nice. What else can this thing do? Let's flex our muscles.

Add the following to you spec.yaml:

# spec.yaml

  # ... previous code ...

  /greet/{name}:
    get:
      operationId: greet
      parameters:
        - name: name
          in: path
          required: true
          schema:
            type: string
        - name: obsequiousness
          in: query
          required: false
          schema:
            type: integer
      responses:
        '200':
          description: If you can't say something nice...
          content:
            application/json:
              schema:
                type: object
                required:
                  - greeting
                properties:
                  greeting:
                    type: string
                  lay_it_on_thick:
                    $ref: '#/components/schemas/LayItOnThick'

components:
  schemas:
    LayItOnThick:
      type: object
      required:
        - is_wonderful_person
        - is_kind_to_animals
        - would_take_to_meet_family
      properties:
        is_wonderful_person:
          type: boolean
        is_kind_to_animals:
          type: boolean
        would_take_to_meet_family:
          type: boolean

Now if we re-run cargo doc and refresh our browser, we have some new goodies. In the trait definition:

trait HsrTutorialApi {
    // .. previous definition

    fn greet<'life0, 'async_trait>(&'life0 self, name: String, obsequiousness_level: Option<i64>)
        -> Pin<Box<dyn Future<Output = Greet> + 'async_trait>>
    where
        'life0: 'async_trait,
        Self: 'async_trait;
}

... which we have learned should be read as

trait HsrTutorialapi {
    // ...

    async fn greet(&self, name: String, obsequiosness_level: Option<i64>) -> Greet;
}

We implement it like so:

#[hsr::async_trait::async_trait(?Send)]
impl api::HsrTutorialApi for Api {
    // ... previous

    async fn greet(&self, name: String, obsequiousness_level: Option<i64>) -> api::Greet {
        let obs_lvl = obsequiousness_level.unwrap_or(0);
        let lay_it_on_thick = if obs_lvl <= 0 {
            None
        } else {
            Some(api::LayItOnThick {
                is_wonderful_person: obs_lvl >= 1,
                is_kind_to_animals: obs_lvl >= 2,
                would_take_to_meet_family: obs_lvl >= 3,
            })
        };
        api::Greet::Ok(api::Greet200 {
            greeting: format!("Greetings {}, pleased to meet you", name),
            lay_it_on_thick,
        })
    }
}

That's our new endpoint implemented. Let's try it out:

~ http ":8000/greet/Alex"
HTTP/1.1 200 OK

{
    "greeting": "Greetings Alex, pleased to meet you",
    "lay_it_on_thick": null
}

➜  ~ http ":8000/greet/Alex?obsequiousness=1"
HTTP/1.1 200 OK

{
    "greeting": "Greetings Alex, pleased to meet you",
    "lay_it_on_thick": {
        "is_kind_to_animals": false,
        "is_wonderful_person": true,
        "would_take_to_meet_family": false
    }
}

➜  ~ http ":8000/greet/Alex?obsequiousness=50"
HTTP/1.1 200 OK

{
    "greeting": "Greetings Alex, pleased to meet you",
    "lay_it_on_thick": {
        "is_kind_to_animals": true,
        "is_wonderful_person": true,
        "would_take_to_meet_family": true
    }
}

Beautiful! This API really knows how make you feel good about yourself.

That's it for now. Take a look at the petstore example for a more complex spec that implements a somewhat-realistic looking API.