Skip to content

OpenAPI Schema Generation

mik-sdk automatically generates OpenAPI 3.0 schemas from your routes! macro, derive macros, and doc comments. The schema is generated at test time only and is never included in the WASM binary.

  1. Define routes with typed inputs and outputs:

    #[derive(Type)]
    pub struct User {
    pub id: String,
    pub name: String,
    }
    /// List all users
    routes! {
    GET "/users" => list_users -> Vec<User>,
    }
  2. Generate the schema:

    Terminal window
    cargo test __mik_write_schema -- --nocapture
  3. Find your schema at openapi.json in the crate root.

The routes! macro generates a hidden __mik_schema module containing:

  • json() - Returns the complete OpenAPI schema as JSON
  • write_to(path) - Writes the schema to a file

A test named __mik_write_schema is also generated that writes the schema to openapi.json.

// Auto-generated by routes! macro (simplified)
#[cfg(not(target_arch = "wasm32"))]
pub mod __mik_schema {
pub fn json() -> &'static str { /* OpenAPI JSON */ }
pub fn write_to(path: &Path) -> io::Result<()> { /* write file */ }
}
#[cfg(all(not(target_arch = "wasm32"), test))]
#[test]
fn __mik_write_schema() {
__mik_schema::write_to(Path::new("openapi.json")).unwrap();
}
Terminal window
# Navigate to your handler crate
cd examples/hello-world
# Generate openapi.json
cargo test __mik_write_schema -- --nocapture
# Output:
# Generated openapi.json (4119 bytes)

The schema is written to openapi.json in the crate’s root directory (where Cargo.toml is located).

Generate schemas for all crates that use the routes! macro:

Terminal window
# From workspace root
cargo test __mik_write_schema --workspace -- --nocapture

Each crate with a routes! macro will have its own openapi.json generated.

Terminal window
# Pretty-print with jq
cat openapi.json | jq .
# Validate with an OpenAPI linter
npx @redocly/cli lint openapi.json

The generated schema includes:

SourceSchema Content
Cargo.tomlAPI title, version, and description (from package.name, package.version, package.description)
DefaultServers array with WASI P2 runtime description
Handler functionsUnique operationId (format: package_name.handler_name)
routes! pathsPath definitions with methods
#[derive(Type)]Request/response body schemas
#[derive(Query)]Query parameter definitions
#[derive(Path)]Path parameter definitions
/// doc commentsOperation summaries
#[field(x_* = ...)]OpenAPI extension attributes
#[field(deprecated = true)]Deprecated field markers
#[deprecated] on routesDeprecated operation markers
#[status(code)] on routesCustom success status codes (201, 204, etc.)
Path prefixesAuto-generated tags (e.g., /users/{id}Users)
Error responsesRFC 7807 ProblemDetails for 4XX/5XX

The schema’s info section is automatically populated from your Cargo.toml:

[package]
name = "my-api"
version = "0.1.0"
description = "My awesome API service"

Generates:

{
"info": {
"title": "my-api",
"version": "0.1.0",
"description": "My awesome API service"
}
}

The schema includes a default server entry indicating the portable nature of WASI components:

{
"servers": [
{
"url": "/",
"description": "WASI HTTP component - runs on any WASI Preview 2 compliant runtime"
}
]
}

The relative URL / means the API is served from wherever the component is deployed, making it truly portable across different hosts and runtimes.

Each route automatically gets a unique operationId in the format package_name.handler_name. This ensures uniqueness when merging multiple OpenAPI schemas from different services.

// In crate "user-service"
routes! {
GET "/users" => list_users,
POST "/users" => create_user,
}

Generates:

  • operationId: "user_service.list_users"
  • operationId: "user_service.create_user"

Given this handler:

/// User resource
#[derive(Type)]
pub struct User {
pub id: String,
#[field(min = 1, max = 100)]
pub name: String,
#[field(format = "email")]
pub email: Option<String>,
}
#[derive(Query)]
pub struct ListQuery {
pub search: Option<String>,
#[field(default = 1)]
pub page: u32,
#[field(default = 20, max = 100)]
pub limit: u32,
}
/// List all users with pagination
routes! {
GET "/users" => list_users(query: ListQuery) -> Vec<User>,
}

The generated schema includes:

{
"openapi": "3.0.0",
"info": {
"title": "my-api",
"version": "0.1.0",
"description": "My API service"
},
"paths": {
"/users": {
"get": {
"operationId": "my_api.list_users",
"tags": ["Users"],
"summary": "List all users with pagination",
"parameters": [
{
"name": "search",
"in": "query",
"required": false,
"schema": { "type": "string" }
},
{
"name": "page",
"in": "query",
"required": false,
"schema": { "type": "integer", "default": 1 }
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": { "type": "integer", "default": 20, "maximum": 100 }
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": { "$ref": "#/components/schemas/User" }
}
}
}
},
"4XX": {
"description": "Client Error",
"content": {
"application/problem+json": {
"schema": { "$ref": "#/components/schemas/ProblemDetails" }
}
}
},
"5XX": {
"description": "Server Error",
"content": {
"application/problem+json": {
"schema": { "$ref": "#/components/schemas/ProblemDetails" }
}
}
}
}
}
}
},
"components": {
"schemas": {
"User": {
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"email": { "type": "string", "format": "email", "nullable": true }
}
},
"ProblemDetails": {
"type": "object",
"description": "RFC 7807 Problem Details for HTTP APIs",
"required": ["type", "title", "status"],
"properties": {
"type": { "type": "string", "default": "about:blank" },
"title": { "type": "string" },
"status": { "type": "integer", "minimum": 100, "maximum": 599 },
"detail": { "type": "string" },
"instance": { "type": "string" }
}
}
}
}
}

Add doc comments directly above routes:

routes! {
/// List all users with optional search
GET "/users" => list_users(query: ListQuery),
/// Create a new user
POST "/users" => create_user(body: CreateInput),
/// Get user by ID
GET "/users/{id}" => get_user(path: Id),
}

Use the docs attribute on fields:

#[derive(Type)]
pub struct CreateUserInput {
#[field(docs = "User's display name", min = 1, max = 100)]
pub name: String,
#[field(docs = "Valid email address", format = "email")]
pub email: String,
#[field(docs = "Optional profile bio")]
pub bio: Option<String>,
}

Add custom x-* extension attributes to fields using the x_ prefix. Underscores are converted to hyphens in the output:

#[derive(Type)]
pub struct User {
#[field(x_example = "john@example.com", x_sensitive = true)]
pub email: String,
#[field(x_deprecated_reason = "Use user_id instead", x_internal = true)]
pub id: String,
#[field(x_priority = 10)]
pub name: String,
}

Generates:

{
"email": {
"type": "string",
"x-example": "john@example.com",
"x-sensitive": true
},
"id": {
"type": "string",
"x-deprecated-reason": "Use user_id instead",
"x-internal": true
},
"name": {
"type": "string",
"x-priority": 10
}
}

Supported value types:

  • String: x_example = "value"
  • Boolean: x_internal = true
  • Integer: x_priority = 10
  • Float: x_weight = 0.5

Mark fields or routes as deprecated using the deprecated attribute:

#[derive(Type)]
pub struct User {
pub id: String,
#[field(deprecated = true, docs = "Use id instead")]
pub legacy_id: String,
}
routes! {
GET "/users" => list_users,
#[deprecated]
GET "/api/v1/users" => list_users_v1,
}

Generates:

{
"properties": {
"id": { "type": "string" },
"legacy_id": {
"type": "string",
"deprecated": true,
"description": "Use id instead"
}
}
}

For routes:

{
"/api/v1/users": {
"get": {
"deprecated": true,
"summary": "..."
}
}
}

By default, all routes document a 200 success response. Use #[status(code)] to specify a different status code:

routes! {
GET "/users" => list_users -> Vec<User>, // 200 (default)
#[status(201)]
POST "/users" => create_user(body: Input) -> User, // 201 Created
#[status(202)]
POST "/jobs" => start_job(body: JobInput) -> Job, // 202 Accepted
#[status(204)]
DELETE "/users/{id}" => delete_user(path: Id), // 204 No Content
}

Generates appropriate response documentation:

{
"/users": {
"post": {
"responses": {
"201": {
"description": "Created",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
}
}
}
}
}

Common status codes and their descriptions:

CodeDescriptionUse Case
200SuccessDefault for GET, PUT, PATCH
201CreatedPOST that creates a resource
202AcceptedAsync operations
204No ContentDELETE, or updates with no body

Add schema generation to your CI pipeline:

.github/workflows/ci.yml
- name: Generate OpenAPI schemas
run: cargo test __mik_write_schema --workspace -- --nocapture
- name: Validate OpenAPI schemas
run: |
npm install -g @redocly/cli
find . -name "openapi.json" -exec redocly lint {} \;
- name: Upload schemas as artifacts
uses: actions/upload-artifact@v4
with:
name: openapi-schemas
path: "**/openapi.json"

Serve your openapi.json with documentation tools:

Terminal window
# Swagger UI
docker run -p 8080:8080 -e SWAGGER_JSON=/schema/openapi.json \
-v $(pwd)/openapi.json:/schema/openapi.json swaggerapi/swagger-ui
# Redoc
npx @redocly/cli preview-docs openapi.json

Generate typed clients from your schema:

Terminal window
# TypeScript
npx openapi-typescript openapi.json -o api-types.ts
# Rust
openapi-generator generate -i openapi.json -g rust -o ./client
# Python
openapi-generator generate -i openapi.json -g python -o ./client

The schema generation uses utoipa internally for type-safe OpenAPI building, ensuring the generated schema is always valid OpenAPI 3.0.

Ensure your crate has the routes! macro:

routes! {
GET "/" => home,
}

Types must use derive macros to appear in the schema:

#[derive(Type)] // For request/response bodies
#[derive(Query)] // For query parameters
#[derive(Path)] // For path parameters

The test is only generated when NOT targeting WASM:

Terminal window
# Correct - native target
cargo test __mik_write_schema
# Wrong - WASM target (test won't exist)
cargo test --target wasm32-wasip2 __mik_write_schema