agentleFS
Sign inSign up

sayna / rules

SaynaAI/sayna/.cursor/rules/openapi.mdc

This document provides comprehensive guidelines for writing OpenAPI annotations in the Sayna Rust/Axum codebase using the utoipa crate. Sayna uses utoipa (v5.3+, latest is v5.4) for OpenAPI 3.1 specification generation. All OpenAPI-related code is feature-gated behind the openapi feature flag to keep dependencies minimal in production builds. Consider adding utoipa-axum for more ergonomic route registration: Use #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] on all types exposed in the API: Always provide realistic examples for every field: For optional fields, use serde attributes…

Cursor rule311 starsChanged 10 months ago
  • Installs packages
# OpenAPI Documentation Guidelines

This document provides comprehensive guidelines for writing OpenAPI annotations in the Sayna Rust/Axum codebase using the `utoipa` crate.

## Overview

Sayna uses `utoipa` (v5.3+, latest is v5.4) for OpenAPI 3.1 specification generation. All OpenAPI-related code is feature-gated behind the `openapi` feature flag to keep dependencies minimal in production builds.

### Key Principles

1. **Feature-Gated**: All OpenAPI code must be conditionally compiled with `#[cfg_attr(feature = "openapi", ...)]`
2. **Centralized Documentation**: All paths, schemas, and tags are registered in `src/docs/openapi.rs`
3. **Complete Examples**: Every field should have meaningful examples that reflect real-world usage
4. **Consistent Style**: Follow established patterns for naming, descriptions, and error responses
5. **Type Safety**: Leverage Rust's type system to ensure documentation matches implementation

## Feature Flag Setup

### Cargo.toml Configuration

```toml
[features]
openapi = [
    "dep:utoipa",
]

[dependencies]
utoipa = { version = "5.3", optional = true, features = ["axum_extras"] }

# Optional: For more ergonomic route registration
# utoipa-axum = { version = "0.2", optional = true }
```

### Optional: Enhanced Axum Integration

Consider adding `utoipa-axum` for more ergonomic route registration:

```rust
use utoipa_axum::{routes, router::OpenApiRouter};

let (router, api) = OpenApiRouter::new()
    .routes(routes!(health_check, list_voices, speak_handler))
    .split_for_parts();
```

### Running with OpenAPI

```bash
# Run server with OpenAPI endpoints
cargo run --features openapi

# Generate OpenAPI spec to file
cargo run --features openapi -- openapi -o docs/openapi.yaml

# Generate JSON format
cargo run --features openapi -- openapi --format json -o docs/openapi.json
```

## Schema Annotations

### Basic Schema Definition

Use `#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]` on all types exposed in the API:

```rust
use serde::{Deserialize, Serialize};

/// Health check response
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct HealthResponse {
    /// Server status
    #[cfg_attr(feature = "openapi", schema(example = "OK"))]
    pub status: String,
}
```

### Field-Level Annotations

#### Examples

Always provide realistic examples for every field:

```rust
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TokenRequest {
    /// The LiveKit room name to generate a token for
    #[cfg_attr(feature = "openapi", schema(example = "conversation-room-123"))]
    pub room_name: String,

    /// Display name for the participant (e.g., "John Doe")
    #[cfg_attr(feature = "openapi", schema(example = "Alice Smith"))]
    pub participant_name: String,

    /// Unique identifier for the participant (e.g., "user-123")
    #[cfg_attr(feature = "openapi", schema(example = "user-alice-456"))]
    pub participant_identity: String,
}
```

#### Optional Fields and Defaults

For optional fields, use serde attributes to control serialization:

```rust
#[derive(Debug, Deserialize, Serialize, Clone)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TTSWebSocketConfig {
    /// Provider name (e.g., "deepgram")
    #[cfg_attr(feature = "openapi", schema(example = "deepgram"))]
    pub provider: String,

    /// Voice ID or name to use for synthesis
    #[serde(skip_serializing_if = "Option::is_none")]
    #[cfg_attr(feature = "openapi", schema(example = "aura-asteria-en"))]
    pub voice_id: Option<String>,

    /// Speaking rate (0.25 to 4.0, 1.0 is normal)
    #[cfg_attr(feature = "openapi", schema(example = 1.0))]
    pub speaking_rate: Option<f32>,

    /// List of participants (defaults to empty for all participants)
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub listen_participants: Vec<String>,
}
```

#### Numeric Constraints

Use schema attributes for numeric validations:

```rust
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct AudioConfig {
    /// Sample rate of the audio in Hz
    #[cfg_attr(feature = "openapi", schema(example = 16000, minimum = 8000, maximum = 48000))]
    pub sample_rate: u32,

    /// Number of audio channels (1 for mono, 2 for stereo)
    #[cfg_attr(feature = "openapi", schema(example = 1, minimum = 1, maximum = 2))]
    pub channels: u16,
}
```

### Complex Types

#### Enums

For enum types used in discriminated unions:

```rust
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "type")]
pub enum IncomingMessage {
    #[serde(rename = "config")]
    Config {
        /// Enable audio processing (STT/TTS). Defaults to true if not specified.
        #[serde(default = "default_audio_enabled")]
        audio: Option<bool>,

        /// STT configuration (required only when audio=true)
        #[serde(skip_serializing_if = "Option::is_none")]
        stt_config: Option<STTWebSocketConfig>,

        /// TTS configuration (required only when audio=true)
        #[serde(skip_serializing_if = "Option::is_none")]
        tts_config: Option<TTSWebSocketConfig>,

        /// Optional LiveKit configuration for real-time audio streaming
        #[serde(skip_serializing_if = "Option::is_none")]
        livekit: Option<LiveKitWebSocketConfig>,
    },

    #[serde(rename = "speak")]
    Speak {
        /// Text to synthesize
        text: String,

        /// Allow this TTS to be interrupted
        #[serde(skip_serializing_if = "Option::is_none")]
        allow_interruption: Option<bool>,

        /// Flush TTS buffer immediately
        #[serde(skip_serializing_if = "Option::is_none")]
        flush: Option<bool>,
    },

    #[serde(rename = "clear")]
    Clear,
}
```

#### HashMap and Collections

For HashMap return types:

```rust
use std::collections::HashMap;

pub type VoicesResponse = HashMap<String, Vec<Voice>>;

// In the path annotation, reference it as:
// body = HashMap<String, Vec<Voice>>
```

#### Nested Types

For types with references to other schemas:

```rust
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct OutgoingMessage {
    #[serde(rename = "type")]
    pub message_type: String,

    /// Unified message structure containing text/data from various sources
    pub message: UnifiedMessage,
}

// Ensure UnifiedMessage is also registered in the central ApiDoc
```

## Path (Endpoint) Annotations

### Basic REST Endpoint

```rust
/// Health check handler
/// Returns a simple JSON response indicating the server is running
#[cfg_attr(
    feature = "openapi",
    utoipa::path(
        get,
        path = "/",
        responses(
            (status = 200, description = "Server is healthy", body = HealthResponse)
        ),
        tag = "health"
    )
)]
pub async fn health_check() -> Result<Json<HealthResponse>, StatusCode> {
    Ok(Json(HealthResponse {
        status: "OK".to_string(),
    }))
}
```

### POST Endpoint with Request Body

```rust
/// Handler for POST /livekit/token endpoint
///
/// Generates a LiveKit JWT token for a participant to join a specific room.
///
/// # Arguments
/// * `state` - Shared application state containing LiveKit configuration
/// * `request` - Token request with room name and participant details
///
/// # Returns
/// * `Response` - JSON response with token or error status
///
/// # Errors
/// * 400 Bad Request - Invalid request data (empty fields)
/// * 500 Internal Server Error - LiveKit service not configured or token generation failed
#[cfg_attr(
    feature = "openapi",
    utoipa::path(
        post,
        path = "/livekit/token",
        request_body = TokenRequest,
        responses(
            (status = 200, description = "Token generated successfully", body = TokenResponse),
            (status = 400, description = "Invalid request (missing or empty fields)"),
            (status = 500, description = "LiveKit service not configured or token generation failed")
        ),
        security(
            ("bearer_auth" = [])
        ),
        tag = "livekit"
    )
)]
pub async fn generate_token(
    State(state): State<Arc<AppState>>,
    Json(request): Json<TokenRequest>,
) -> Response {
    // Implementation
}
```

### Endpoint with Custom Response Headers

```rust
/// Handler for the /speak endpoint
#[cfg_attr(
    feature = "openapi",
    utoipa::path(
        post,
        path = "/speak",
        request_body = SpeakRequest,
        responses(
            (status = 200, description = "Audio generated successfully",
                content_type = "audio/pcm",
                headers(
                    ("x-audio-format" = String, description = "Audio format (linear16, mp3, etc.)"),
                    ("x-sample-rate" = u32, description = "Sample rate in Hz")
                )
            ),
            (status = 400, description = "Invalid request (empty text)"),
            (status = 500, description = "TTS synthesis failed")
        ),
        security(
            ("bearer_auth" = [])
        ),
        tag = "tts"
    )
)]
pub async fn speak_handler(
    State(state): State<Arc<AppState>>,
    Json(request): Json<SpeakRequest>,
) -> Response {
    // Implementation
}
```

### Endpoint Returning Collections

```rust
/// Handler for GET /voices - returns available voices per provider
#[cfg_attr(
    feature = "openapi",
    utoipa::path(
        get,
        path = "/voices",
        responses(
            (status = 200, description = "Available voices grouped by provider", body = HashMap<String, Vec<Voice>>),
            (status = 500, description = "Internal server error")
        ),
        security(
            ("bearer_auth" = [])
        ),
        tag = "voices"
    )
)]
pub async fn list_voices(
    State(state): State<Arc<AppState>>,
) -> Result<Json<VoicesResponse>, StatusCode> {
    // Implementation
}
```

## Central API Documentation (src/docs/openapi.rs)

All paths, schemas, and tags must be registered in the central `ApiDoc` struct:

```rust
use utoipa::OpenApi;

/// OpenAPI documentation structure
#[derive(OpenApi)]
#[openapi(
    info(
        title = "Sayna API",
        version = "0.1.0",
        description = "Real-time voice processing server with Speech-to-Text (STT) and Text-to-Speech (TTS) services",
        contact(
            name = "Sayna",
            url = "https://api.sayna.ai"
        )
    ),
    servers(
        (url = "https://api.sayna.ai", description = "Production API"),
        (url = "http://localhost:3001", description = "Local development")
    ),
    paths(
        // Register all handler functions here
        crate::handlers::api::health_check,
        crate::handlers::voices::list_voices,
        crate::handlers::speak::speak_handler,
        crate::handlers::livekit::generate_token,
    ),
    components(schemas(
        // Register all schema types here
        // REST API types
        HealthResponse,
        Voice,
        SpeakRequest,
        TokenRequest,
        TokenResponse,
        // WebSocket message types
        IncomingMessage,
        OutgoingMessage,
        UnifiedMessage,
        ParticipantDisconnectedInfo,
        // Configuration types
        STTWebSocketConfig,
        TTSWebSocketConfig,
        LiveKitWebSocketConfig,
        Pronunciation,
    )),
    modifiers(&SecurityAddon),
    tags(
        (name = "health", description = "Health check endpoints"),
        (name = "voices", description = "TTS voice management"),
        (name = "tts", description = "Text-to-speech synthesis"),
        (name = "livekit", description = "LiveKit room and token management"),
        (name = "websocket", description = "WebSocket API for real-time communication")
    )
)]
pub struct ApiDoc;
```

## Security Schemes

Security schemes are added via modifiers:

```rust
/// Security scheme configuration
struct SecurityAddon;

impl utoipa::Modify for SecurityAddon {
    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
        if let Some(components) = openapi.components.as_mut() {
            let mut http = utoipa::openapi::security::Http::new(
                utoipa::openapi::security::HttpAuthScheme::Bearer,
            );
            http.bearer_format = Some("JWT".to_string());
            http.description = Some(
                "JWT token obtained from the authentication service. \
                 Required when AUTH_REQUIRED is enabled."
                    .to_string(),
            );

            components.add_security_scheme(
                "bearer_auth",
                utoipa::openapi::security::SecurityScheme::Http(http),
            )
        }
    }
}
```

## Documentation Comments

### Type-Level Documentation

Use Rust doc comments (///) for type-level documentation that appears in the OpenAPI description:

```rust
/// Request body for generating a LiveKit token
///
/// # Example
/// ```json
/// {
///   "room_name": "conversation-room-123",
///   "participant_name": "Alice Smith",
///   "participant_identity": "user-alice-456"
/// }
/// ```
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TokenRequest {
    // fields...
}
```

### Field-Level Documentation

Use Rust doc comments for individual fields:

```rust
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct LiveKitWebSocketConfig {
    /// Room name to join or create
    #[cfg_attr(feature = "openapi", schema(example = "conversation-room-123"))]
    pub room_name: String,

    /// Enable recording for this session
    #[serde(default)]
    pub enable_recording: bool,

    /// List of participant identities to listen to for audio tracks and data messages.
    ///
    /// **Behavior**:
    /// - If **empty** (default): Audio tracks and data messages from **all participants** will be processed
    /// - If **populated**: Only audio tracks and data messages from participants whose identities
    ///   are in this list will be processed; others will be ignored
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub listen_participants: Vec<String>,
}
```

### Handler-Level Documentation

Use Rust doc comments above handlers, with detailed sections:

```rust
/// Handler for POST /livekit/token endpoint
///
/// Generates a LiveKit JWT token for a participant to join a specific room.
///
/// # Arguments
/// * `state` - Shared application state containing LiveKit configuration
/// * `request` - Token request with room name and participant details
///
/// # Returns
/// * `Response` - JSON response with token or error status
///
/// # Errors
/// * 400 Bad Request - Invalid request data (empty fields)
/// * 500 Internal Server Error - LiveKit service not configured or token generation failed
#[cfg_attr(feature = "openapi", utoipa::path(...))]
pub async fn generate_token(...) -> Response {
    // Implementation
}
```

## Best Practices

### 1. Consistent Response Patterns

Always document all possible HTTP status codes:

```rust
responses(
    (status = 200, description = "Success description", body = SuccessType),
    (status = 400, description = "Bad request - validation error"),
    (status = 401, description = "Unauthorized - missing or invalid token"),
    (status = 500, description = "Internal server error")
)
```

### 2. Meaningful Examples

Examples should reflect real-world usage:

```rust
// Good
#[cfg_attr(feature = "openapi", schema(example = "conversation-room-123"))]
pub room_name: String,

// Avoid generic examples
#[cfg_attr(feature = "openapi", schema(example = "string"))]
pub room_name: String,
```

### 3. Complete Field Descriptions

Every field should have a clear description:

```rust
/// The LiveKit room name to generate a token for
#[cfg_attr(feature = "openapi", schema(example = "conversation-room-123"))]
pub room_name: String,
```

### 4. Tag Organization

Use consistent tags to group related endpoints:

- `health` - Health check and status endpoints
- `voices` - Voice management endpoints
- `tts` - Text-to-speech endpoints
- `stt` - Speech-to-text endpoints
- `livekit` - LiveKit integration endpoints
- `websocket` - WebSocket API documentation

### 5. Security Requirements

Always specify security requirements for protected endpoints:

```rust
security(
    ("bearer_auth" = [])
)
```

Omit for public endpoints (like health checks).

### 6. Content Types

Specify custom content types when returning non-JSON:

```rust
responses(
    (status = 200, description = "Audio generated successfully",
        content_type = "audio/pcm",
        headers(...)
    )
)
```

## Testing OpenAPI Generation

Always include tests in `src/docs/openapi.rs`:

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_openapi_spec_generation() {
        let spec = ApiDoc::openapi();
        assert_eq!(spec.info.title, "Sayna API");
        assert_eq!(spec.info.version, "0.1.0");
    }

    #[test]
    fn test_yaml_export() {
        let yaml = spec_yaml();
        assert!(yaml.is_ok());
        let yaml_str = yaml.unwrap();
        assert!(yaml_str.contains("Sayna API"));
    }

    #[test]
    fn test_json_export() {
        let json = spec_json();
        assert!(json.is_ok());
    }
}
```

## Validating OpenAPI Specs

### Using Schemathesis (Recommended)

Schemathesis performs property-based testing and validates your API implementation against the OpenAPI spec:

```bash
# Install
pip install schemathesis

# Generate the spec first
cargo run --features openapi -- openapi -o docs/openapi.yaml

# Validate against running server
schemathesis run docs/openapi.yaml --base-url http://localhost:3001

# Run in CI (stateless validation)
schemathesis run docs/openapi.yaml --dry-run
```

### Using Online Validators

- **Swagger Editor**: Upload to https://editor.swagger.io/
- **Redocly CLI**: `npx @redocly/cli lint docs/openapi.yaml`
- **Spectral**: `npx @stoplight/spectral-cli lint docs/openapi.yaml`

### CI Integration Example

```yaml
# GitHub Actions
validate-openapi:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - name: Generate OpenAPI spec
      run: cargo run --features openapi -- openapi -o docs/openapi.yaml
    - name: Validate with Redocly
      run: npx @redocly/cli lint docs/openapi.yaml
```

## CLI Usage

### Generate OpenAPI Spec

The OpenAPI specification is generated via CLI commands only (no runtime HTTP endpoints):

```bash
# Generate YAML (default)
cargo run --features openapi -- openapi -o docs/openapi.yaml

# Generate JSON
cargo run --features openapi -- openapi --format json -o docs/openapi.json

# Print to stdout
cargo run --features openapi -- openapi

# Print JSON to stdout
cargo run --features openapi -- openapi --format json
```

The generated spec can be viewed using external tools like Swagger Editor, Redoc, or other OpenAPI viewers.

## Common Patterns

### Pattern 1: Request/Response Pairs

Always create paired request/response types:

```rust
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TokenRequest { /* ... */ }

#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TokenResponse { /* ... */ }
```

### Pattern 2: WebSocket vs REST Configs

Separate WebSocket configs (without API keys) from internal configs:

```rust
// WebSocket API type (no API key)
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TTSWebSocketConfig {
    pub provider: String,
    // No api_key field
}

impl TTSWebSocketConfig {
    pub fn to_tts_config(&self, api_key: String) -> TTSConfig {
        // Convert to internal config with API key
    }
}
```

### Pattern 3: Discriminated Unions

Use `#[serde(tag = "type")]` for message enums:

```rust
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "type")]
pub enum OutgoingMessage {
    #[serde(rename = "ready")]
    Ready { /* fields */ },

    #[serde(rename = "stt_result")]
    SttResult { /* fields */ },

    #[serde(rename = "error")]
    Error { /* fields */ },
}
```

## Troubleshooting

### Issue: Type not found in OpenAPI spec

**Solution**: Ensure the type is registered in `components(schemas(...))` in `src/docs/openapi.rs`

### Issue: Endpoint not appearing in spec

**Solution**: Ensure the handler is registered in `paths(...)` in `src/docs/openapi.rs`

### Issue: Examples not showing correctly

**Solution**: Use `schema(example = "value")` not `example = "value"` within `#[cfg_attr]`

### Issue: Optional fields showing as required

**Solution**: Use `#[serde(skip_serializing_if = "Option::is_none")]` for optional fields

### Issue: Compilation errors when openapi feature disabled

**Solution**: Always use `#[cfg_attr(feature = "openapi", ...)]` instead of bare `#[derive(utoipa::ToSchema)]`

## Checklist for Adding New Endpoints

When adding a new endpoint, ensure:

- [ ] Handler function has OpenAPI path annotation with `#[cfg_attr(feature = "openapi", utoipa::path(...))]`
- [ ] All request/response types have `#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]`
- [ ] All fields have examples via `#[cfg_attr(feature = "openapi", schema(example = "..."))]`
- [ ] All types are registered in `src/docs/openapi.rs` components
- [ ] Handler is registered in `src/docs/openapi.rs` paths
- [ ] Appropriate tag is assigned
- [ ] All response status codes are documented
- [ ] Security requirements are specified (if protected endpoint)
- [ ] Rust doc comments are complete and accurate
- [ ] OpenAPI spec regenerates without errors: `cargo run --features openapi -- openapi -o docs/openapi.yaml`
- [ ] Test the generated spec with a validator or Swagger UI

## Version History

- **v0.2.0**: Updated guidelines for utoipa 5.3+/5.4
  - Added Schemathesis validation guidance
  - Added utoipa-axum integration notes
  - Added CI integration examples
- **v0.1.0**: Initial OpenAPI documentation setup with utoipa 5.3
- Uses OpenAPI 3.1 specification
- Feature-gated to keep production builds lean

Discussion

Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.

Posts are public.Sign in to post

No one has posted yet. Be the first.