Working with OpenAPI using Rust

shuttle_dev

Shuttle

Posted on April 4, 2024

Working with OpenAPI using Rust

Hello world! In this article we’re going to talk about how you can make the most of OpenAPI with Rust, by learning all the different ways we can use OpenAPI in a Rust context. By the end of this article, you'll learn the following:

  • Adding OpenAPI to a Rust web service
  • How to generate API client libraries from OpenAPI specifications
  • Working with the OpenAPI spec directly

What is OpenAPI?

From Swagger.io:

The OpenAPI specification (OAS) defines a standard, language-agnostic interface to HTTP APIs which allows both humans and computers to discover and understand the capabilities of the service without access to source code.

No matter what language you’re using, OpenAPI allows you to easily read the specification and understand how to use an API without needing specific documentation.

Here is a simple example of what an OpenAPI specification file may look like:

swagger: "3.0"
info:
  version: "1.0"
  title: "Hello World API"
paths:
  /hello/{user}:
    get:
      description: Returns a greeting to the user!
      parameters:
        - name: user
          in: path
          type: string
          required: true
          description: The name of the user to greet.
      responses:
        200:
          description: Returns the greeting.
          schema:
            type: string
        400:
          description: Invalid characters in "user" were provided.
Enter fullscreen mode Exit fullscreen mode

There are several benefits of using OpenAPI:

  • You can improve cross-team collaboration by allowing teammates to quickly experiment with endpoints by providing a frontend
  • You can quickly get an understanding of endpoints that your teammates have made
  • It’s widely used, so you can get an understanding of official APIs that utilise it much faster
  • We can generate code from it as it’s machine parseable!

Adding OpenAPI to a Rust API

utoipa

Adding an OpenAPI specification to a Rust API can be done with the utoipa family of crates. utoipa is a crate that primarily uses macros to set up the OpenAPI specification. There is also support for frontend GUIs like Swagger UI, Redoc and Rapidoc that allow you to visualise working with your API

A simple Axum example that shows a JSON representation of your OpenAPI specification looks like this:

use std::net::SocketAddr;

use axum::{routing::get, Json};
use utoipa::OpenApi;

#[derive(OpenApi)]
#[openapi(paths(openapi))]
struct ApiDoc;

/// Return JSON version of an OpenAPI schema
#[utoipa::path(
    get,
    path = "/api-docs/openapi.json",
    responses(
        (status = 200, description = "JSON file", body = ())
    )
)]
async fn openapi() -> Json<utoipa::openapi::OpenApi> {
    Json(ApiDoc::openapi())
}

#[tokio::main]
async fn main() {
    let socket_address: SocketAddr = "127.0.0.1:8080".parse().unwrap();
    let listener = tokio::net::TcpListener::bind(socket_address).await.unwrap();

    let app = axum::Router::new().route("/api-docs/openapi.json", get(openapi));

    axum::serve(listener, app.into_make_service())
        .await
        .unwrap()
}
Enter fullscreen mode Exit fullscreen mode

Let’s break this down. We have the following:

  • An ApiDoc struct that takes the OpenApi derive macro and sets all the attributes required to serve the OpenAPI specification from your API.
  • We have an attribute macro above our function handler. This macro will document the given information about a handler endpoint and show it in the OpenAPI spec when we open it.
  • We have a “list” of responses in the macro. Note that because we have no exact type to give to OpenAPI, we leave the body as ().

Interested in checking out what attributes the utoipa::path macro can take? Have a look here.

If you run this code and visit [localhost:8080/api-docs/openapi.json](http://localhost:8080/api-docs/openapi.json) you should see a JSON response of the API specification.

This is typically good enough for just providing a basic representation. However, for internal exploration you may want to add a GUI to your OpenAPI specification. You can do this by installing the utoipa_swagger_ui crate and changing your Router to the following:

let app = Router::new().merge(
    SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())
);
Enter fullscreen mode Exit fullscreen mode

If you run your code again and go to localhost:8080/swagger-ui, you’ll get a Swagger UI menu that should look something like this:

Image description

Two small things to note here are that utoipa-service is taken from the crate name and crate is the module where our route comes from. Here because our function is taken from the top level, it says crate - but we can fix this later on if we wanted by putting the route in a module.

If we then expand this section by clicking on the route, it will allow us to then execute the endpoint! Pretty helpful, huh?

Image description

The utoipa crate is quite comprehensive, so you can be assured that it will support mostly everything you want to do. If you’d like to check out their documentation, you can do so here.

When it comes to having an API with hundreds or thousands of endpoints though, this may not be entirely ergonomic. You’ll be spending quite a lot of time writing macros which can bloat your files. To that end, you can also use the utoipauto crate which lets you automate all of the work with only one macro. However, this adds additional compilation time. Whether you’ll want to use it depends on your use case.

poem-openapi

Should you be happening to use the Poem framework (which hit 3.0.0 recently!), you can also use the poem-openapi crate to add OpenAPI functionality to your Poem service. Similarly to the utoipa crate, poem-openapi also uses macros to get OpenAPI documentation.

use poem::{listener::TcpListener, Route};
use poem_openapi::{param::Query, payload::PlainText, OpenApi, OpenApiService};

struct Api;

#[OpenApi]
impl Api {
    #[oai(path = "/hello", method = "get")]
    async fn index(&self, name: Query<Option<String>>) -> PlainText<String> {
        match name.0 {
            Some(name) => PlainText(format!("hello, {}!", name)),
            None => PlainText("hello!".to_string()),
        }
    }
}

#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
    let api_service =
        OpenApiService::new(Api, "Hello World", "1.0").server("http://localhost:3000/api");
    let ui = api_service.swagger_ui();
    let app = Route::new().nest("/api", api_service).nest("/", ui);

    poem::Server::new(TcpListener::bind("0.0.0.0:3000"))
        .run(app)
        .await
}
Enter fullscreen mode Exit fullscreen mode

Running this code will also generate a Swagger UI GUI as above, but at a different endpoint (localhost:3000/api).

Generating Rust from OpenAPI specifications

Now let’s talk about generating Rust code from OpenAPI specifications. The OpenAPI collective have made a tool to generate a server client library - Rust support included! We’ll be using npm to install the OpenAPI generator. You can install it with the following shell snippet:

npm install @openapitools/openapi-generator-cli -g
Enter fullscreen mode Exit fullscreen mode

There are also alternative ways to install the OpenAPI generator, which you can check out here.

Next, we’ll need a specification to generate a client library from. The generator takes YAML or JSON files as input. Thankfully for us, we already have a JSON filefrom the OpenAPI service we just built. If we head to localhost:8080/api-docs/openapi.json, we can select the Raw Text option, prettify it and then put it all into a JSON file for input. Our file name will be utoipa-client.json.

Next, we’ll actually generate the client code. This can be done with the following shell snippet:

npx @openapitools/openapi-generator-cli generate -i utoipa-client.json -g rust -o ./utoipa-client
Enter fullscreen mode Exit fullscreen mode

This looks like quite a long command! What’s happening here?

  • The -i flag is our input file
  • The -g flag is for the generator we should use (in this case, the rust one). Generators are not necessarily one-per-language, hence the flag convention.
  • The -o flag is for the output directory. If the directory doesn’t exist, the generator will attempt to create it.

Once done, you should see a new Rust crate in the automatically generated utoipa-client folder. At this point in time, the generator is mostly correct. However, your generated code can have syntactical errors if your OpenAPI specification input is malformed. You can find out more about this here.

Interested in further customization? You can check out more OpenAPI generator configuration options here.

Working with OpenAPI specifications directly in Rust

Interested in building tools that use the OpenAPI specification? There’s a crate for that! Using the openapiv3 crate, you can also deserialize (and serialize) to and from the OpenAPI spec format. Below, we deserialize it from a JSON string - you would additionally need serde_json installed:

use serde_json;
use openapiv3::OpenAPI;

fn main() {
    let data = include_str!("openapi.json");
    let openapi: OpenAPI = serde_json::from_str(data)
        .expect("Could not deserialize input");
    println!("{:?}", openapi);
}
Enter fullscreen mode Exit fullscreen mode

There are several crates for handling this; notably, the openapiv3 crate only supports the V3 specification. For V3.1 you want to use the oas3 crate, which can take both YAML and JSON:

fn main() {
    match oas3::from_path("path/to/openapi.yaml") {
      Ok(spec) => println!("spec: {:?}", spec),
      Err(err) => println!("error: {}", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Finishing up

Thanks for reading! With this article, you should be able to tackle OpenAPI with Rust no problem.

Read more:

💖 💪 🙅 🚩
shuttle_dev
Shuttle

Posted on April 4, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related