Backend API Design - Mardens-Inc/Pricing-App GitHub Wiki
This document explains the REST API design patterns, conventions, and real-time update mechanisms.
- API Overview
- REST Conventions
- OpenAPI Documentation
- Authentication
- Request/Response Patterns
- Real-time Updates (SSE)
- Error Handling
- Versioning
-
Base Path:
/api/ - Protocol: HTTP/HTTPS
- Format: JSON
- Port: 1421 (development)
-
Documentation:
/docs/swagger/and/docs/rapidoc/
/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
| 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} |
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
All IDs in URLs are hashed strings (not numeric):
# Correct
GET /api/list/x7J8kLm9N2pQr4Tv
# Wrong
GET /api/list/123
See Database Patterns for details.
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
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
}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
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
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;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))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
})))
}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()
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))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.
}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))use actix_web::http::header;
Ok(HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/json"))
.insert_header(("X-Custom-Header", "value"))
.json(data))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"))
}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)?)
}SSE provides one-way real-time updates from server to client.
- Simpler protocol (HTTP-based)
- Automatic reconnection
- Works through proxies
- Sufficient for our use case (server → client only)
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...');
};-
record_created- New record added -
record_updated- Record modified -
record_deleted- Record removed -
bulk_update- Multiple records changed
#[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": "Error message describing what went wrong"
}| 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 |
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))
}#[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,
}
}
}The API is currently unversioned. All endpoints are at /api/.
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.
- Always decode IDs at endpoint entry
- Always use OpenAPI documentation
- Always validate input data
- Always log errors before returning
- Use appropriate HTTP status codes
- Broadcast SSE updates after mutations
- Use transactions for multi-table operations
- Don't expose database IDs directly
- Don't expose internal error details to clients
- Don't forget authentication on protected endpoints
- Don't skip OpenAPI documentation
- Don't use SELECT * in production queries
#[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))
}- Backend Testing - Test your endpoints
- Backend Modules - Understand each API group
- Common Tasks - Add new endpoints
Last Updated: 2025-11-04