Skip to content

OpenAPI Composition Design ​

How to compose OpenAPI specs from multiple sources.

Problem ​

Users need to combine OpenAPI specs from:

  1. Multiple services - UserService + OrderService in one API
  2. Multiple protocols - #[http] + #[jsonrpc] on same impl

Currently, each service generates its own openapi_spec() -> serde_json::Value, but there's no way to merge them.

Design ​

Per-Protocol OpenAPI Methods ​

Each protocol macro generates a dedicated method for OpenAPI paths:

ProtocolMethodReturns
#[http]http_openapi_paths()HTTP REST endpoints
#[jsonrpc]jsonrpc_openapi_paths()POST /rpc endpoint
#[graphql]graphql_openapi_paths()POST /graphql endpoint
#[ws]ws_openapi_paths()WebSocket upgrade endpoint

Each returns Vec<OpenApiPath> (or similar structured type).

Why separate methods?

  • More flexible - can access protocol-specific paths independently
  • Avoids trait impl conflicts (can't impl same trait twice)
  • Follows existing {protocol}_router() convention

Cross-Protocol Composition (Same Impl) ​

The #[serve] macro already knows which protocols are present. It generates a combined spec:

rust
#[http(prefix = "/api")]
#[jsonrpc(path = "/rpc")]
#[serve(http, jsonrpc)]
impl Service {
    fn list_users(&self) -> Vec<User> { ... }
}

// Generated by #[serve]:
impl Service {
    pub fn openapi_spec() -> serde_json::Value {
        OpenApiBuilder::new()
            .title("Service")
            .merge_paths(Self::http_openapi_paths())
            .merge_paths(Self::jsonrpc_openapi_paths())
            .build()
    }
}

Alternatively, #[openapi] can be protocol-aware:

rust
#[http(openapi = false)]  // Disable per-protocol spec
#[jsonrpc]
#[openapi]  // Generates combined spec from detected protocols
impl Service { ... }

Multi-Service Composition (Different Impls) ​

For combining specs from different services, use the runtime builder:

rust
use server_less::OpenApiBuilder;

let spec = OpenApiBuilder::new()
    .title("My API")
    .version("1.0.0")
    .merge(UserService::openapi_spec())
    .merge(OrderService::openapi_spec())
    .build();

Why runtime for multi-service?

  • Services are often in different modules/crates
  • Can't reference both in a single macro invocation
  • Runtime merging is practical and flexible
  • Spec is JSON anyway - no benefit to compile-time merging

OpenApiBuilder API ​

rust
// In server-less-openapi crate, re-exported from server-less

pub struct OpenApiBuilder {
    title: Option<String>,
    version: Option<String>,
    description: Option<String>,
    paths: serde_json::Map<String, serde_json::Value>,
    schemas: serde_json::Map<String, serde_json::Value>,
}

impl OpenApiBuilder {
    pub fn new() -> Self;

    // Metadata
    pub fn title(self, title: impl Into<String>) -> Self;
    pub fn version(self, version: impl Into<String>) -> Self;
    pub fn description(self, desc: impl Into<String>) -> Self;

    // Merge entire specs (from openapi_spec() methods)
    pub fn merge(self, spec: serde_json::Value) -> Self;

    // Merge just paths (from {protocol}_openapi_paths())
    pub fn merge_paths(self, paths: Vec<OpenApiPath>) -> Self;

    // Merge just schemas
    pub fn merge_schemas(self, schemas: Vec<OpenApiSchema>) -> Self;

    // Build final spec
    pub fn build(self) -> serde_json::Value;
}

Conflict Resolution ​

Paths: Last write wins.

When merging specs with the same path:

rust
let spec = OpenApiBuilder::new()
    .merge(ServiceA::openapi_spec())  // Has GET /users
    .merge(ServiceB::openapi_spec())  // Also has GET /users
    .build();
// Result: ServiceB's GET /users wins

This is intentional - paths represent endpoints, and overriding is a valid use case.

Schemas: Dedupe or error.

When merging specs with the same schema name:

  • Identical schemas → deduplicate (keep one)
  • Different schemas → error at runtime
rust
let spec = OpenApiBuilder::new()
    .merge(UserService::openapi_spec())   // schemas: { "User": { name: string } }
    .merge(OrderService::openapi_spec())  // schemas: { "User": { id: int, name: string } }
    .build();
// Error: Schema "User" defined differently in multiple specs

Silent overwrites for schemas would produce incorrect API documentation. Users must resolve conflicts by:

  1. Ensuring schemas are identical across services
  2. Renaming one schema (e.g., UserSummary vs UserDetail)
  3. Extracting shared schemas to a common location

Type Definitions ​

rust
// Structured path representation (optional - could use serde_json::Value)
pub struct OpenApiPath {
    pub path: String,           // e.g., "/users/{id}"
    pub method: String,         // e.g., "get"
    pub operation: OpenApiOperation,
}

pub struct OpenApiOperation {
    pub summary: Option<String>,
    pub operation_id: Option<String>,
    pub parameters: Vec<OpenApiParameter>,
    pub request_body: Option<serde_json::Value>,
    pub responses: serde_json::Map<String, serde_json::Value>,
}

pub struct OpenApiParameter {
    pub name: String,
    pub location: String,  // "path", "query", "header"
    pub required: bool,
    pub schema: serde_json::Value,
}

pub struct OpenApiSchema {
    pub name: String,
    pub schema: serde_json::Value,
}

Alternative: Skip typed structs, use serde_json::Value throughout. Simpler but less type-safe.

Implementation Plan ​

Phase 1: OpenApiBuilder ​

  1. Create new server-less-openapi crate
  2. Add OpenApiBuilder and typed structs (OpenApiPath, OpenApiSchema, etc.)
  3. Re-export from server-less
  4. Implement merge() with schema deduplication/conflict detection
  5. Document conflict resolution behavior

Phase 2: Per-Protocol Methods ​

  1. Add http_openapi_paths() to #[http] (alongside existing openapi_spec())
  2. Add jsonrpc_openapi_paths() to #[jsonrpc]
  3. Add similar methods to #[graphql], #[ws]

Phase 3: Serve Integration ​

  1. Update #[serve] to detect protocols and generate combined openapi_spec()
  2. Use OpenApiBuilder internally

Phase 4: Protocol-Aware #[openapi] ​

  1. Update #[openapi] to detect sibling protocol attributes
  2. Generate combined spec when multiple protocols present

Design Decisions ​

  1. Typed paths - Use OpenApiPath struct for better IDE support and compile-time validation.

  2. #[serve] generates openapi_spec() by default - Opt-out via #[serve(openapi = false)].

  3. Schema conflict handling - Identical schemas dedupe; different schemas error. Never silently overwrite.

  4. Crate location - New server-less-openapi crate for clean separation. Re-exported from server-less.

Open Questions ​

None - all decisions made.

Example Usage ​

Single Service, Multiple Protocols ​

rust
#[http(prefix = "/api")]
#[jsonrpc(path = "/rpc")]
#[serve(http, jsonrpc)]
impl CalculatorService {
    fn add(&self, a: i32, b: i32) -> i32 { a + b }
}

// Access combined spec:
let spec = CalculatorService::openapi_spec();
// Contains:
// - POST /api/adds (from http)
// - POST /rpc (from jsonrpc)

Multiple Services ​

rust
// In user_service.rs
#[http(prefix = "/users")]
impl UserService {
    fn list_users(&self) -> Vec<User> { ... }
    fn get_user(&self, id: String) -> Option<User> { ... }
}

// In order_service.rs
#[http(prefix = "/orders")]
impl OrderService {
    fn list_orders(&self) -> Vec<Order> { ... }
    fn create_order(&self, user_id: String) -> Order { ... }
}

// In main.rs
let spec = OpenApiBuilder::new()
    .title("E-Commerce API")
    .version("1.0.0")
    .merge(UserService::openapi_spec())
    .merge(OrderService::openapi_spec())
    .build();

// Write to file or serve at /openapi.json
std::fs::write("openapi.json", serde_json::to_string_pretty(&spec)?)?;