Add OpenAPI documentation via Utoipa

Annotates all API handlers and model structs with Utoipa macros to generate
an OpenAPI 3.0 spec at runtime. Serves an interactive Swagger UI at /api/docs
and the raw JSON spec at /api/docs/openapi.json, both publicly accessible
outside the auth middleware.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-27 13:36:05 -04:00
co-authored by Claude Sonnet 4.6
parent 6002597383
commit 44d593fccc
7 changed files with 392 additions and 6 deletions
+3 -1
View File
@@ -2,13 +2,15 @@ use crate::utils::date_serializer;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
use utoipa::ToSchema;
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize, ToSchema)]
pub struct Device {
pub mac_address: String,
pub ipv4_address: String,
pub vendor: String,
#[serde(with = "date_serializer")]
#[schema(value_type = String, format = DateTime)]
pub last_seen: DateTime<Utc>,
pub is_registered: bool,
pub owner: String,
+4 -2
View File
@@ -3,11 +3,13 @@ use chrono::{DateTime, Utc};
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
use serde::{Deserialize, Serialize};
use std::{error::Error, fmt, str::FromStr};
use utoipa::ToSchema;
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize, ToSchema)]
pub struct Notification {
pub id: i64,
#[serde(with = "date_serializer")]
#[schema(value_type = String, format = DateTime)]
pub created_on: DateTime<Utc>,
pub notification_type: NotificationType,
pub title: String,
@@ -50,7 +52,7 @@ impl PartialEq for Notification {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
pub enum NotificationType {
NewDeviceFound,
DeviceOnlineAfterTime,
+66 -1
View File
@@ -1,6 +1,9 @@
use std::error::Error;
use crate::model::devices::Device;
use crate::model::notifications::{Notification, NotificationType};
use crate::settings::get_settings;
use crate::web_server::devices::RegisterDevicePayload;
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
@@ -12,11 +15,64 @@ use tower::ServiceBuilder;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
use axum::Json;
use utoipa::OpenApi;
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
use utoipa::Modify;
use utoipa_swagger_ui::SwaggerUi;
pub mod devices;
pub mod notifications;
pub mod utils;
#[derive(OpenApi)]
#[openapi(
info(
title = "OOTT API",
version = "0.1.0",
description = "Network monitoring and alert system API"
),
paths(
test_api,
devices::list,
devices::read,
devices::register,
devices::unregister,
notifications::list,
notifications::read,
notifications::read_without_flagging,
notifications::mark_as_new,
notifications::mark_all_as_old,
),
components(schemas(
Device,
Notification,
NotificationType,
RegisterDevicePayload,
)),
modifiers(&SecurityAddon),
tags(
(name = "devices", description = "Device management"),
(name = "notifications", description = "Notification management"),
)
)]
struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer_auth",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.build(),
),
);
}
}
pub async fn serve() -> Result<(), Box<dyn Error>> {
info!("Starting web server");
let static_files = ServeDir::new("./web");
@@ -52,7 +108,8 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
"/",
get(|| async { "Go to /web for the UI or to /api for the better UI." }),
)
.nest_service("/web", static_files);
.nest_service("/web", static_files)
.merge(SwaggerUi::new("/api/docs").url("/api/docs/openapi.json", ApiDoc::openapi()));
let web_server_host_and_port = format!(
"{}:{}",
@@ -70,6 +127,14 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
Ok(())
}
#[utoipa::path(
get,
path = "/api/test",
responses(
(status = 200, description = "API is reachable", body = String),
),
security(("bearer_auth" = []))
)]
async fn test_api() -> Result<Json<String>, StatusCode> {
Ok(Json("OOTT_API_OK".to_string()))
}
+61 -1
View File
@@ -6,11 +6,30 @@ use axum::{Json, extract::Query, http::StatusCode};
use chrono::{DateTime, Utc};
use log::{debug, error};
use serde::Deserialize;
use utoipa::ToSchema;
use crate::{db, model::devices::Device};
use crate::web_server::utils;
#[utoipa::path(
get,
path = "/api/devices",
tag = "devices",
params(
("is_registered" = Option<bool>, Query, description = "Filter by registration status"),
("last_seen_from" = Option<String>, Query, description = "Filter devices seen after this datetime (RFC3339)"),
("last_seen_to" = Option<String>, Query, description = "Filter devices seen before this datetime (RFC3339)"),
("owner" = Option<String>, Query, description = "Filter by owner"),
("device_type" = Option<String>, Query, description = "Filter by device type"),
("vendor" = Option<String>, Query, description = "Filter by vendor"),
),
responses(
(status = 200, description = "List of devices", body = Vec<Device>),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list(
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Vec<Device>>, StatusCode> {
@@ -38,6 +57,19 @@ pub async fn list(
}
}
#[utoipa::path(
get,
path = "/api/devices/{mac_address}",
tag = "devices",
params(
("mac_address" = String, Path, description = "MAC address of the device"),
),
responses(
(status = 200, description = "Device found", body = Device),
(status = 404, description = "Device not found"),
),
security(("bearer_auth" = []))
)]
pub async fn read(Path(mac_address): Path<String>) -> Result<Json<Device>, StatusCode> {
match db::devices::read(mac_address) {
Some(value) => Ok(Json(value)),
@@ -45,6 +77,19 @@ pub async fn read(Path(mac_address): Path<String>) -> Result<Json<Device>, Statu
}
}
#[utoipa::path(
put,
path = "/api/devices",
tag = "devices",
request_body = RegisterDevicePayload,
responses(
(status = 201, description = "Device registered"),
(status = 404, description = "Device not found"),
(status = 409, description = "Device already registered"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn register(Json(payload): Json<RegisterDevicePayload>) -> impl IntoResponse {
debug!(
"Device registration received: mac_address={}, owner={}, device_type={}",
@@ -84,6 +129,21 @@ pub async fn register(Json(payload): Json<RegisterDevicePayload>) -> impl IntoRe
}
}
#[utoipa::path(
delete,
path = "/api/devices/{mac_address}",
tag = "devices",
params(
("mac_address" = String, Path, description = "MAC address of the device"),
),
responses(
(status = 200, description = "Device unregistered"),
(status = 404, description = "Device not found"),
(status = 409, description = "Device is not registered"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn unregister(Path(mac_address): Path<String>) -> impl IntoResponse {
let mut device = match db::devices::read(mac_address) {
Some(value) => value,
@@ -119,7 +179,7 @@ pub async fn unregister(Path(mac_address): Path<String>) -> impl IntoResponse {
}
// Payload structs
#[derive(Deserialize)]
#[derive(Deserialize, ToSchema)]
pub struct RegisterDevicePayload {
mac_address: String,
owner: String,
+65
View File
@@ -10,6 +10,20 @@ use log::error;
use crate::{db, model::notifications::Notification, web_server::utils};
#[utoipa::path(
get,
path = "/api/notifications/{id}",
tag = "notifications",
params(
("id" = i64, Path, description = "Notification ID"),
),
responses(
(status = 200, description = "Notification found and marked as read", body = Notification),
(status = 404, description = "Notification not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn read(Path(id): Path<i64>) -> Result<Json<Notification>, StatusCode> {
match db::notifications::mark_as_old(id) {
Ok(_) => {}
@@ -25,6 +39,19 @@ pub async fn read(Path(id): Path<i64>) -> Result<Json<Notification>, StatusCode>
}
}
#[utoipa::path(
get,
path = "/api/notifications/{id}/read_without_flagging",
tag = "notifications",
params(
("id" = i64, Path, description = "Notification ID"),
),
responses(
(status = 200, description = "Notification found without marking as read", body = Notification),
(status = 404, description = "Notification not found"),
),
security(("bearer_auth" = []))
)]
pub async fn read_without_flagging(Path(id): Path<i64>) -> Result<Json<Notification>, StatusCode> {
match db::notifications::read(id) {
Some(value) => Ok(Json(value)),
@@ -32,6 +59,19 @@ pub async fn read_without_flagging(Path(id): Path<i64>) -> Result<Json<Notificat
}
}
#[utoipa::path(
post,
path = "/api/notifications/{id}/mark_as_new",
tag = "notifications",
params(
("id" = i64, Path, description = "Notification ID"),
),
responses(
(status = 200, description = "Notification marked as new"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn mark_as_new(Path(id): Path<i64>) -> impl IntoResponse {
match db::notifications::mark_as_new(id) {
Ok(_) => (StatusCode::OK, "Notification marked as new"),
@@ -45,6 +85,16 @@ pub async fn mark_as_new(Path(id): Path<i64>) -> impl IntoResponse {
}
}
#[utoipa::path(
post,
path = "/api/notifications/mark_all_as_old",
tag = "notifications",
responses(
(status = 200, description = "All notifications marked as old"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn mark_all_as_old() -> impl IntoResponse {
match db::notifications::mark_all_as_old() {
Ok(_) => (StatusCode::OK, "All notifications marked as old"),
@@ -58,6 +108,21 @@ pub async fn mark_all_as_old() -> impl IntoResponse {
}
}
#[utoipa::path(
get,
path = "/api/notifications",
tag = "notifications",
params(
("is_new" = Option<bool>, Query, description = "Filter by new/read status"),
("page_offset" = Option<i64>, Query, description = "Pagination offset"),
("page_limit" = Option<i64>, Query, description = "Maximum number of results to return"),
),
responses(
(status = 200, description = "List of notifications", body = Vec<Notification>),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list(
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Vec<Notification>>, StatusCode> {