Backend API Design - Mardens-Inc/Pricing-App GitHub Wiki

This document explains the REST API design patterns, conventions, and real-time update mechanisms.

Table of Contents


API Overview

Base Information

  • Base Path: /api/
  • Protocol: HTTP/HTTPS
  • Format: JSON
  • Port: 1421 (development)
  • Documentation: /docs/swagger/ and /docs/rapidoc/

API Structure

/api/
├── /list                    # Location management
├── /inventory/{id}/         # Inventory operations
│   ├── /columns             # Column configuration
│   ├── /options             # Database options
│   ├── /substitutions       # Item substitutions
│   └── /updates             # SSE stream
├── /spreadsheet/            # File upload/processing
├── /history/{id}/           # Change history
├── /pos/                    # POS integration
├── /icons                   # Icon management
└── /server/                 # Server information

REST Conventions

HTTP Methods

Method Purpose Example
GET Retrieve resource(s) GET /api/list
POST Create new resource POST /api/list
PUT Update existing resource PUT /api/list/{id}
DELETE Remove resource DELETE /api/list/{id}

URL Structure

Pattern: /api/{resource}/{id?}/{subresource?}/{sub-id?}

Examples:

GET    /api/list                    # List all locations
GET    /api/list/{id}               # Get one location
POST   /api/list                    # Create location
PUT    /api/list/{id}               # Update location
DELETE /api/list/{id}               # Delete location

GET    /api/inventory/{id}          # Get inventory records
POST   /api/inventory/{id}          # Create record
PUT    /api/inventory/{id}/{rec_id} # Update record
DELETE /api/inventory/{id}/{rec_id} # Delete record

GET    /api/inventory/{id}/columns  # Get columns config
PUT    /api/inventory/{id}/columns  # Update columns

ID Format

All IDs in URLs are hashed strings (not numeric):

# Correct
GET /api/list/x7J8kLm9N2pQr4Tv

# Wrong
GET /api/list/123

See Database Patterns for details.

Query Parameters

Filtering:

GET /api/inventory/{id}?query=shirt&query_columns=description,category

Pagination:

GET /api/inventory/{id}?limit=50&offset=100

Sorting:

GET /api/inventory/{id}?sort_by=date&sort_order=DESC

Combined:

GET /api/inventory/{id}?query=shirt&query_columns=description&limit=50&offset=0&sort_by=date&sort_order=DESC

OpenAPI Documentation

utoipa Integration

Every endpoint is documented with the #[utoipa::path] macro:

#[utoipa::path(
    get,
    path = "/api/list/{id}",
    params(
        ("id" = String, Path, description = "Location ID (hashed)")
    ),
    responses(
        (status = 200, description = "Location found", body = Location),
        (status = 404, description = "Location not found"),
        (status = 401, description = "Unauthorized")
    ),
    tag = "Locations",
    security(("bearer_auth" = []))
)]
pub async fn get_location(
    pool: web::Data<MySqlPool>,
    id: web::Path<String>,
) -> Result<impl Responder> {
    // Implementation
}

Documentation Structure

Components:

  • path - URL path with parameter placeholders
  • params - Path/query parameters with types and descriptions
  • request_body - Request body schema (for POST/PUT)
  • responses - Possible response codes with schemas
  • tag - Groups endpoints in documentation
  • security - Authentication requirements

Accessing Documentation

Swagger UI: http://localhost:1421/docs/swagger/

  • Interactive API explorer
  • Try-it-out functionality
  • Schema validation

RapiDoc: http://localhost:1421/docs/rapidoc/

  • Alternative modern UI
  • Better for complex APIs

Registering New Endpoints

Add to src-actix/api_doc.rs:

#[derive(OpenApi)]
#[openapi(
    paths(
        list::get_all_locations,
        list::get_location,
        list::create_location,
        // Add new endpoints here
    ),
    components(schemas(
        Location,
        LocationRequest,
        // Add new schemas here
    )),
    tags(
        (name = "Locations", description = "Location management endpoints")
    )
)]
struct ApiDoc;

Authentication

Middleware

Protected endpoints are wrapped in authentication middleware:

use actix_authentication_middleware::AuthenticationMiddleware;

web::scope("/api/inventory")
    .wrap(AuthenticationMiddleware::new())
    .route("/{id}", web::get().to(get_inventory))

Getting User Information

use actix_authentication_middleware::UserRequestExt;

pub async fn protected_endpoint(
    req: HttpRequest,
) -> Result<impl Responder> {
    // Get authenticated user
    let user = req.get_user()?;

    log::info!("User {} accessed endpoint", user.id);

    // Use user information
    Ok(web::Json(json!({
        "user_id": user.id,
        "username": user.username
    })))
}

Authentication Flow

1. Client includes JWT token in Authorization header
   Authorization: Bearer <token>
   ↓
2. AuthenticationMiddleware validates token
   ↓
3. If valid: User info injected into request
   If invalid: 401 Unauthorized response
   ↓
4. Endpoint handler can access user via req.get_user()

Public Endpoints

Some endpoints don't require authentication:

// No middleware wrapper
web::scope("/api/server")
    .route("/version", web::get().to(get_version))
    .route("/health", web::get().to(health_check))

Request/Response Patterns

Request Body (POST/PUT)

Content-Type: application/json

#[derive(Deserialize, utoipa::ToSchema)]
pub struct LocationRequest {
    pub name: String,
    pub location: String,
    pub po: String,
    pub vendor: String,
    pub department: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub image: Option<String>,
}

#[post("/api/list")]
pub async fn create_location(
    pool: web::Data<MySqlPool>,
    data: web::Json<LocationRequest>,
) -> Result<impl Responder> {
    // data.name, data.location, etc.
}

Response Body

Content-Type: application/json

#[derive(Serialize, utoipa::ToSchema)]
pub struct Location {
    #[serde(serialize_with = "serde_hash::hashids::serialize")]
    pub id: u64,  // Automatically encoded to hashed string

    pub name: String,
    pub location: String,
    pub post_date: DateTime<Utc>,
}

Ok(web::Json(location))

Response with Custom Headers

use actix_web::http::header;

Ok(HttpResponse::Ok()
    .insert_header((header::CONTENT_TYPE, "application/json"))
    .insert_header(("X-Custom-Header", "value"))
    .json(data))

File Upload

Content-Type: multipart/form-data

use actix_multipart::Multipart;

#[post("/api/spreadsheet/prepare")]
pub async fn upload_file(
    mut payload: Multipart
) -> Result<impl Responder> {
    while let Some(item) = payload.next().await {
        let mut field = item?;

        // Get filename
        let content_disposition = field.content_disposition();
        let filename = content_disposition
            .get_filename()
            .ok_or(anyhow!("No filename"))?;

        // Read bytes
        let mut bytes = Vec::new();
        while let Some(chunk) = field.next().await {
            bytes.extend_from_slice(&chunk?);
        }

        // Save file
        let identifier = Uuid::new_v4();
        let path = format!("uploads/{}.xlsx", identifier);
        std::fs::write(&path, bytes)?;

        return Ok(web::Json(json!({
            "identifier": identifier.to_string()
        })));
    }

    Err(anyhow!("No file in request"))
}

File Download

use actix_files::NamedFile;

#[get("/api/icons/{filename}")]
pub async fn get_icon(
    filename: web::Path<String>
) -> Result<NamedFile> {
    let path = format!("icons/{}", sanitize_filename::sanitize(&filename));
    Ok(NamedFile::open(path)?)
}

Real-time Updates (SSE)

Server-Sent Events

SSE provides one-way real-time updates from server to client.

Why SSE instead of WebSockets?

  1. Simpler protocol (HTTP-based)
  2. Automatic reconnection
  3. Works through proxies
  4. Sufficient for our use case (server → client only)

Implementation

Server Side:

use actix_web_lab::sse;
use tokio::sync::broadcast;
use std::collections::HashMap;
use std::sync::OnceLock;
use dashmap::DashMap;

// Global broadcast channels
static CHANNELS: OnceLock<DashMap<String, broadcast::Sender<String>>> = OnceLock::new();

#[get("/api/inventory/{id}/updates")]
pub async fn inventory_updates(
    id: web::Path<String>,
) -> Result<impl Responder> {
    let inv_id = decode_single(&id)?;

    // Get or create channel for this inventory
    let channels = CHANNELS.get_or_init(|| DashMap::new());
    let tx = channels.entry(inv_id.to_string())
        .or_insert_with(|| {
            let (tx, _) = broadcast::channel(100);
            tx
        })
        .clone();

    let rx = tx.subscribe();

    // Convert broadcast receiver to SSE stream
    let stream = BroadcastStream::new(rx)
        .map(|msg| {
            let data = msg.unwrap_or_else(|_| "error".to_string());
            Ok::<_, actix_web::Error>(
                sse::Event::Data(sse::Data::new(data))
            )
        });

    Ok(sse::Sse::from_stream(stream))
}

// Broadcasting updates
pub fn broadcast_inventory_update(
    inventory_id: &str,
    event_type: &str,
    record_id: &str,
    data: &serde_json::Value,
) {
    let channels = CHANNELS.get().unwrap();

    if let Some(tx) = channels.get(inventory_id) {
        let message = json!({
            "type": event_type,
            "record_id": record_id,
            "data": data
        }).to_string();

        let _ = tx.send(message);
    }
}

Client Side (Frontend):

const eventSource = new EventSource(`/api/inventory/${id}/updates`);

eventSource.onmessage = (event) => {
    const update = JSON.parse(event.data);

    switch (update.type) {
        case 'record_created':
            // Add new record to table
            break;
        case 'record_updated':
            // Update existing record
            break;
        case 'record_deleted':
            // Remove record from table
            break;
    }
};

eventSource.onerror = () => {
    // Automatic reconnection by browser
    console.log('SSE connection error, reconnecting...');
};

Event Types

  • record_created - New record added
  • record_updated - Record modified
  • record_deleted - Record removed
  • bulk_update - Multiple records changed

Broadcasting After Mutations

#[post("/api/inventory/{id}")]
pub async fn create_record(
    pool: web::Data<MySqlPool>,
    id: web::Path<String>,
    data: web::Json<RecordRequest>,
) -> Result<impl Responder> {
    let inv_id = decode_single(&id)?;

    // Create record in database
    let record = inventory_db::create(&pool, inv_id, &data).await?;

    // Broadcast update to all listeners
    broadcast_inventory_update(
        &inv_id.to_string(),
        "record_created",
        &record.id.to_string(),
        &json!(record)
    );

    Ok(web::Json(record))
}

Error Handling

Error Response Format

{
    "error": "Error message describing what went wrong"
}

HTTP Status Codes

Code Meaning When to Use
200 OK Successful GET, PUT, DELETE
201 Created Successful POST
400 Bad Request Invalid input data
401 Unauthorized Missing/invalid authentication
403 Forbidden Authenticated but not authorized
404 Not Found Resource doesn't exist
409 Conflict Resource already exists
500 Internal Server Error Unexpected server error

Error Handling Pattern

use database_common_lib::http_error;

#[get("/api/list/{id}")]
pub async fn get_location(
    pool: web::Data<MySqlPool>,
    id: web::Path<String>,
) -> http_error::Result<impl Responder> {
    // Decode ID
    let db_id = decode_single(&id)
        .map_err(|e| http_error::bad_request(format!("Invalid ID: {}", e)))?;

    // Query database
    let location = list_db::get_by_id(&pool, db_id)
        .await
        .map_err(|e| {
            log::error!("Database error: {}", e);
            http_error::not_found("Location not found")
        })?;

    Ok(web::Json(location))
}

Custom Error Types

#[derive(Debug)]
pub enum ApiError {
    NotFound(String),
    InvalidInput(String),
    DatabaseError(sqlx::Error),
}

impl std::fmt::Display for ApiError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            ApiError::NotFound(msg) => write!(f, "Not found: {}", msg),
            ApiError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
            ApiError::DatabaseError(e) => write!(f, "Database error: {}", e),
        }
    }
}

impl actix_web::error::ResponseError for ApiError {
    fn status_code(&self) -> StatusCode {
        match self {
            ApiError::NotFound(_) => StatusCode::NOT_FOUND,
            ApiError::InvalidInput(_) => StatusCode::BAD_REQUEST,
            ApiError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

Versioning

Current Approach

The API is currently unversioned. All endpoints are at /api/.

Future Versioning Strategy

If breaking changes are needed:

Option 1: URL Versioning

/api/v1/list
/api/v2/list

Option 2: Header Versioning

GET /api/list
Accept: application/vnd.pricing-app.v2+json

Option 3: Query Parameter

GET /api/list?version=2

Recommendation: URL versioning is clearest for REST APIs.


Best Practices

Do's

  1. Always decode IDs at endpoint entry
  2. Always use OpenAPI documentation
  3. Always validate input data
  4. Always log errors before returning
  5. Use appropriate HTTP status codes
  6. Broadcast SSE updates after mutations
  7. Use transactions for multi-table operations

Don'ts

  1. Don't expose database IDs directly
  2. Don't expose internal error details to clients
  3. Don't forget authentication on protected endpoints
  4. Don't skip OpenAPI documentation
  5. Don't use SELECT * in production queries

Example: Complete Endpoint

#[utoipa::path(
    post,
    path = "/api/inventory/{id}",
    params(("id" = String, Path, description = "Inventory ID")),
    request_body = RecordRequest,
    responses(
        (status = 201, description = "Record created", body = Record),
        (status = 400, description = "Invalid data"),
        (status = 401, description = "Unauthorized")
    ),
    tag = "Inventory",
    security(("bearer_auth" = []))
)]
pub async fn create_record(
    req: HttpRequest,
    pool: web::Data<MySqlPool>,
    id: web::Path<String>,
    data: web::Json<RecordRequest>,
) -> http_error::Result<impl Responder> {
    // 1. Get authenticated user
    let user = req.get_user()?;

    // 2. Decode ID
    let inv_id = decode_single(&id)
        .map_err(|e| http_error::bad_request(format!("Invalid ID: {}", e)))?;

    // 3. Validate input
    if data.name.is_empty() {
        return Err(http_error::bad_request("Name is required"));
    }

    // 4. Database operation
    let record = inventory_db::create(&pool, inv_id, &data)
        .await
        .map_err(|e| {
            log::error!("Failed to create record: {}", e);
            http_error::internal_server_error("Failed to create record")
        })?;

    // 5. Log to history
    history_db::create_entry(inv_id, record.id, user.id, "created", &json!({}), &pool).await?;

    // 6. Broadcast update
    broadcast_inventory_update(
        &inv_id.to_string(),
        "record_created",
        &record.id.to_string(),
        &json!(record)
    );

    // 7. Return response
    Ok(HttpResponse::Created().json(record))
}

Next Steps


Last Updated: 2025-11-04

⚠️ **GitHub.com Fallback** ⚠️