OpenAPI Composition Design
How to compose OpenAPI specs from multiple sources.
Problem
Users need to combine OpenAPI specs from:
- Multiple services -
UserService+OrderServicein one API - 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:
| Protocol | Method | Returns |
|---|---|---|
#[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:
#[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:
#[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:
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
// 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:
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 winsThis 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
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 specsSilent overwrites for schemas would produce incorrect API documentation. Users must resolve conflicts by:
- Ensuring schemas are identical across services
- Renaming one schema (e.g.,
UserSummaryvsUserDetail) - Extracting shared schemas to a common location
Type Definitions
// 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
- Create new
server-less-openapicrate - Add
OpenApiBuilderand typed structs (OpenApiPath,OpenApiSchema, etc.) - Re-export from
server-less - Implement
merge()with schema deduplication/conflict detection - Document conflict resolution behavior
Phase 2: Per-Protocol Methods
- Add
http_openapi_paths()to#[http](alongside existingopenapi_spec()) - Add
jsonrpc_openapi_paths()to#[jsonrpc] - Add similar methods to
#[graphql],#[ws]
Phase 3: Serve Integration
- Update
#[serve]to detect protocols and generate combinedopenapi_spec() - Use
OpenApiBuilderinternally
Phase 4: Protocol-Aware #[openapi]
- Update
#[openapi]to detect sibling protocol attributes - Generate combined spec when multiple protocols present
Design Decisions
Typed paths - Use
OpenApiPathstruct for better IDE support and compile-time validation.#[serve]generatesopenapi_spec()by default - Opt-out via#[serve(openapi = false)].Schema conflict handling - Identical schemas dedupe; different schemas error. Never silently overwrite.
Crate location - New
server-less-openapicrate for clean separation. Re-exported fromserver-less.
Open Questions
None - all decisions made.
Example Usage
Single Service, Multiple Protocols
#[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
// 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)?)?;