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.
Ohkami
- macro-less and type-safe APIs for intuitive and declarative code
-
various runtimes are supported:
tokio
,async-std
,smol
,nio
,glommio
andworker
(Cloudflare Workers),lambda
(AWS Lambda) - extremely fast, no-network testing, well-structured middlewares, Server-Sent Events, WebSocket, highly integrated OpenAPI document generation, ...
Quick Start
- Add to
dependencies
:
[dependencies]
ohkami = { version = "0.23", features = ["rt_tokio"] }
tokio = { version = "1", features = ["full"] }
- 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
…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;
}
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")],
});
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,
}
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"
]
}
}
}
}
}
}
}
}
}
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,
}
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"
]
}
}
}
}
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![])
}
{
...
"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"
...
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
}
}
}
equivalent to
impl openapi::Schema for CreateUser<'_> {
fn schema() -> impl Into<openapi::schema::SchemaRef> {
openapi::object()
.property("name", openapi::string())
}
}
- 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
},
)
}
}
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())
)
}
}
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}
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
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
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"
},
...
}
In this case, just
npm run openapi
generates OpenAPI document!
Thank you for reading. If you’re interested in Ohkami, check out the GitHub repo and start coding!
Top comments (0)