DEV Community

kanarus
kanarus

Posted on

Macro-Less, Highly Integrated OpenAPI Document Generation in Rust with Ohkami

This is a cross post from Medium.

In Rust web dev, utoipa is the most popular crate for generating OpenAPI document from server code. While it’s a great tool, it can be frustrating due to excessive macro use.

A new web framework Ohkami offers a macro-less, highly integrated way to generate OpenAPI document with its openapi feature.

GitHub logo ohkami-rs / ohkami

Ohkami - intuitive and declarative web framework for Rust

Ohkami

Ohkami - [狼] wolf in Japanese - is intuitive and declarative web framework

  • macro-less and type-safe APIs for intuitive and declarative code
  • various runtimes are supported:tokio, async-std, smol, nio, glommio and worker (Cloudflare Workers), lambda (AWS Lambda)
  • extremely fast, no-network testing, well-structured middlewares, Server-Sent Events, WebSocket, highly integrated OpenAPI document generation, ...
License build check status of ohkami crates.io

Quick Start

  1. Add to dependencies :
[dependencies]
ohkami = { version = "0.23", features = ["rt_tokio"] }
tokio  = { version = "1",    features = ["full"] }
Enter fullscreen mode Exit fullscreen mode
  1. Write your first code with Ohkami : examples/quick_start
use ohkami::prelude::*;
use ohkami::typed::status;
async fn health_check() -> status::NoContent {
    status::NoContent
}

async fn hello(name: &str) -> String {
    format
Enter fullscreen mode Exit fullscreen mode

Example

Let’s take following code as an example. It’s the same sample from the “openapi” section of the README, but with openapi-related parts removed:

use ohkami::prelude::*;
use ohkami::typed::status;

#[derive(Deserialize)]
struct CreateUser<'req> {
    name: &'req str,
}

#[derive(Serialize)]
struct User {
    id: usize,
    name: String,
}

async fn create_user(
    JSON(CreateUser { name }): JSON<CreateUser<'_>>
) -> status::Created<JSON<User>> {
    status::Created(JSON(User {
        id: 42,
        name: name.to_string()
    }))
}

async fn list_users() -> JSON<Vec<User>> {
    JSON(vec![])
}

#[tokio::main]
async fn main() {
    let o = Ohkami::new((
        "/users"
            .GET(list_users)
            .POST(create_user),
    ));

    o.howl("localhost:5000").await;
}
Enter fullscreen mode Exit fullscreen mode

While this compiles and works as a pseudo user management server, activating openapi feature causes a compile error, telling that User and CreateUser don’t implement ohkami::openapi::Schema.

As indicated by this, Ohkami with openapi feature effectively handles type information and intelligently collects its endpoints’ metadata. It allows code like:

use ohkami::openapi;

...

let o = Ohkami::new((
    "/users"
        .GET(list_users)
        .POST(create_user),
));

o.generate(openapi::OpenAPI {
    title: "Users Server",
    version: "0.1.0",
    servers: &[openapi::Server::at("localhost:5000")],
});
Enter fullscreen mode Exit fullscreen mode

to assemble metadata into an OpenAPI document and output it to a file without opaque macros.

Then, how we implement Schema? Actually we can easily impl Schema by hand, or just #[derive(Schema)] is available! In this case, derive is enough:

#[derive(Deserialize, openapi::Schema)] // <--
struct CreateUser<'req> {
   name: &'req str,
}

#[derive(Serialize, openapi::Schema)] // <--
struct User {
   id: usize,
   name: String,
}
Enter fullscreen mode Exit fullscreen mode

That’s it! Just adding these derives allows Ohkami::generate to output following file:

{
  "openapi": "3.1.0",
  "info": {
    "title": "Users Server",
    "version": "0.1.0"
  },
  "servers": [
    {
      "url": "localhost:5000"
    }
  ],
  "paths": {
    "/users": {
      "get": {
        "operationId": "list_users",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "id": {
                        "type": "integer"
                      },
                      "name": {
                        "type": "string"
                      }
                    },
                    "required": [
                      "id",
                      "name"
                    ]
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "create_user",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "integer"
                  },
                  "name": {
                    "type": "string"
                  }
                },
                "required": [
                  "id",
                  "name"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id",
                    "name"
                  ]
                }
              }
            }
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Additionally, it’s easy to define the User schema as a component instead of duplicating inline schemas.
In derive, just add #[openapi(component)] helper attribute:

#[derive(Serialize, openapi::Schema)]
#[openapi(component)] // <--
struct User {
   id: usize,
   name: String,
}
Enter fullscreen mode Exit fullscreen mode

Now the output is:

{
  "openapi": "3.1.0",
  "info": {
    "title": "Users Server",
    "version": "0.1.0"
  },
  "servers": [
    {
      "url": "localhost:5000"
    }
  ],
  "paths": {
    "/users": {
      "get": {
        "operationId": "list_users",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/User"
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "create_user",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "integer"
                  },
                  "name": {
                    "type": "string"
                  }
                },
                "required": [
                  "id",
                  "name"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "User": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          }
        },
        "required": [
          "id",
          "name"
        ]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And optionally #[operation] attribute is available to set summary, description, and override operationId and each response’s description:

#[openapi::operation({
    summary: "...",
    200: "List of all users",
})]
/// This doc comment is used for the
/// `description` field of OpenAPI document
async fn list_users() -> JSON<Vec<User>> {
    JSON(vec![])
}
Enter fullscreen mode Exit fullscreen mode
{
  ...

  "paths": {
    "/users": {
      "get": {
        "operationId": "list_users",
        "summary": "...",
        "description": "This doc comment is used for the\n`description` field of OpenAPI document",
        "responses": {
          "200": {
            "description": "List of all users",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/User"

  ...
Enter fullscreen mode Exit fullscreen mode

How it works?

Let’s take a look at how this document generation works!

1. Schema

First, the #[derive(Schema)]s are expanded as following:

  • for CreateUser
impl<'req> ::ohkami::openapi::Schema for CreateUser<'req> {
    fn schema() -> impl 
Into<::ohkami::openapi::schema::SchemaRef> {
        {
            let mut schema = ::ohkami::openapi::object();
            schema = schema
                .property(
                    "name",
                    ::ohkami::openapi::schema::Schema::<
                        ::ohkami::openapi::schema::Type::any,
                    >::from(
                        <&'req str as ::ohkami::openapi::Schema>::schema()
                            .into()
                            .into_inline()
                            .unwrap(),
                    ),
                );
            schema
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

equivalent to

impl openapi::Schema for CreateUser<'_> {
    fn schema() -> impl Into<openapi::schema::SchemaRef> {
        openapi::object()
            .property("name", openapi::string())
    }
}
Enter fullscreen mode Exit fullscreen mode
  • for User
impl ::ohkami::openapi::Schema for User {
    fn schema() -> impl Into<::ohkami::openapi::schema::SchemaRef> {
        ::ohkami::openapi::component(
            "User",
            {
                let mut schema = ::ohkami::openapi::object();
                schema = schema
                    .property(
                        "id",
                        ::ohkami::openapi::schema::Schema::<
                            ::ohkami::openapi::schema::Type::any,
                        >::from(
                            <usize as ::ohkami::openapi::Schema>::schema()
                                .into()
                                .into_inline()
                                .unwrap(),
                        ),
                    );
                schema = schema
                    .property(
                        "name",
                        ::ohkami::openapi::schema::Schema::<
                            ::ohkami::openapi::schema::Type::any,
                        >::from(
                            <String as ::ohkami::openapi::Schema>::schema()
                                .into()
                                .into_inline()
                                .unwrap(),
                        ),
                    );
                schema
            },
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

equivalent to

impl openapi::Schema for User {
    fn schema() -> impl Into<openapi::schema::SchemaRef> {
        openapi::component(
            "User",
            openapi::object()
                .property("id", openapi::integer())
                .property("name", openapi::string())
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

The organized DSL enables to easily impl manually.

Schema trait links the struct to an item of type called SchemaRef.

2. openapi_* hooks of FromParam, FromRequest, IntoResponse

They're Ohkami’s core traits appeared in the handler bound:

async fn({FromParam tuple}?, {FromRequest item}*) -> {IntoResponse item}
Enter fullscreen mode Exit fullscreen mode

When openapi feature is activated, they additionally have following methods:

fn openapi_param() -> openapi::Parameter

fn openapi_inbound() -> openapi::Inbound

fn openapi_responses() -> openapi::Responses
Enter fullscreen mode Exit fullscreen mode

Ohkami leverages these methods in IntoHandler to generate consistent openapi::Operation, reflecting the actual handler signature like this.

Moreover, Ohkami properly propagates schema information in common cases like this, allowing users to focus only on the types and schemas of their app.

3. routes metadata of Router

In Ohkami, what’s called router::base::Router has routes property that stores all the routes belonging to an Ohkami instance. This is returned alongside router::final::Router from finalize step, and is used to assemble metadata of all endpoints.

4. generate

What Ohkami::generate itself does is just to serialize an item of type openapi::document::Document and write it to a file.

The openapi::document::Document item is created by gen_openapi_doc of router::final::Router, summarized as follows:

let mut doc = Document::new(/* ... */);

for route in routes {
    let (openapi_path, openapi_path_param_names) = {
        // "/api/users/:id"
        // ↓
        // ("/api/users/{id}", ["id"])
    };

    let mut operations = Operations::new();
    for (openapi_method, router) in [
        ("get",    &self.GET),
        ("put",    &self.PUT),
        ("post",   &self.POST),
        ("patch",  &self.PATCH),
        ("delete", &self.DELETE),
    ] {
        // if an operation is registerred in a Node
        // at `route` of `router`,
        // perform a preprocess for it and
        // append it to `operations`
    }

    doc = doc.path(openapi_path, operations);
}

doc
Enter fullscreen mode Exit fullscreen mode

That’s how Ohkami generates OpenAPI document!

Appendix: Cloudflare Workers

There is, however, a problem in rt_worker, Cloudflare Workers: where Ohkami is loaded to Miniflare or Cloudflare Workers as WASM, so it can only generate OpenAPI document as data and cannot write it to the user’s local file system.

To work around this, Ohkami provides a CLI tool scripts/workers_openapi.js. This is, for example, used in package.json of Cloudflare Workers + OpenAPI template:

{
    ...
    "scripts": {
     "deploy": "export OHKAMI_WORKER_DEV='' && wrangler deploy",
     "dev": "export OHKAMI_WORKER_DEV=1 && wrangler dev",
     "openapi": "node -e \"$(curl -s https://raw.githubusercontent.com/ohkami-rs/ohkami/refs/heads/main/scripts/workers_openapi.js)\" -- --features openapi"
    },
    ...
}
Enter fullscreen mode Exit fullscreen mode

In this case, just

npm run openapi
Enter fullscreen mode Exit fullscreen mode

generates OpenAPI document!


Thank you for reading. If you’re interested in Ohkami, check out the GitHub repo and start coding!

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs