howler-core 0.1.0

Core library for the Howler wolf-tracking platform: database, data ingestion, ML, web server, and real-time streaming
Documentation
use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

use crate::db::Database;
use crate::models::{Sighting, Source};
use crate::streaming::{Broadcast, StreamingEvent};
use crate::websocket::ws_handler;

#[derive(Clone)]
pub struct AppState {
    pub db: Arc<Database>,
    pub broadcast: Broadcast,
}

#[derive(Deserialize)]
pub struct CreateSighting {
    pub species: String,
    pub scientific_name: Option<String>,
    pub latitude: f64,
    pub longitude: f64,
    pub source: String,
    pub source_id: String,
    pub details: Option<String>,
}

#[derive(Serialize)]
pub struct ApiResponse<T> {
    pub success: bool,
    pub data: Option<T>,
    pub error: Option<String>,
}

pub fn build_router(state: AppState) -> Router {
    Router::new()
        .route("/api/sightings", get(get_sightings).post(create_sighting))
        .route(
            "/api/sightings/{id}",
            get(get_sighting_by_id).put(update_sighting),
        )
        .route("/api/analysis/{id}", post(run_analysis))
        .route("/api/export/{format}", get(export_sightings))
        .route("/ws/stream", get(ws_handler))
        .with_state(state)
}

pub async fn start_server(host: &str, port: u16, state: AppState) -> anyhow::Result<()> {
    let router = build_router(state);
    let addr = format!("{}:{}", host, port);
    let listener = tokio::net::TcpListener::bind(&addr).await?;
    axum::serve(listener, router).await?;
    Ok(())
}

async fn get_sightings(
    State(state): State<AppState>,
) -> Result<Json<ApiResponse<Vec<Sighting>>>, (StatusCode, Json<ApiResponse<()>>)> {
    match state.db.get_all_sightings() {
        Ok(sightings) => Ok(Json(ApiResponse {
            success: true,
            data: Some(sightings),
            error: None,
        })),
        Err(e) => Err((
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(ApiResponse {
                success: false,
                data: None,
                error: Some(e.to_string()),
            }),
        )),
    }
}

async fn get_sighting_by_id(
    State(state): State<AppState>,
    Path(id): Path<i64>,
) -> Result<Json<ApiResponse<Sighting>>, (StatusCode, Json<ApiResponse<()>>)> {
    let sightings = state.db.get_all_sightings().map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(ApiResponse {
                success: false,
                data: None,
                error: Some(e.to_string()),
            }),
        )
    })?;

    match sightings.into_iter().find(|s| s.id == Some(id)) {
        Some(sighting) => Ok(Json(ApiResponse {
            success: true,
            data: Some(sighting),
            error: None,
        })),
        None => Err((
            StatusCode::NOT_FOUND,
            Json(ApiResponse {
                success: false,
                data: None,
                error: Some("Sighting not found".to_string()),
            }),
        )),
    }
}

async fn create_sighting(
    State(state): State<AppState>,
    Json(input): Json<CreateSighting>,
) -> Result<(StatusCode, Json<ApiResponse<i64>>), (StatusCode, Json<ApiResponse<()>>)> {
    let now = chrono::Utc::now();
    let source = match input.source.as_str() {
        "GBIF" => Source::GBIF,
        "Movebank" => Source::Movebank,
        "iNaturalist" | "INaturalist" => Source::INaturalist,
        "IUCN" => Source::IUCN,
        _ => Source::GBIF,
    };

    let sighting = Sighting {
        id: None,
        species: input.species,
        scientific_name: input.scientific_name,
        latitude: input.latitude,
        longitude: input.longitude,
        observed_on: now,
        source,
        source_id: input.source_id,
        details: input.details,
    };

    match state.db.insert_sighting(&sighting) {
        Ok(id) => {
            let mut saved = sighting.clone();
            saved.id = Some(id);
            let _ = state
                .broadcast
                .publish(StreamingEvent::SightingCreated(saved));
            Ok((
                StatusCode::CREATED,
                Json(ApiResponse {
                    success: true,
                    data: Some(id),
                    error: None,
                }),
            ))
        }
        Err(e) => Err((
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(ApiResponse {
                success: false,
                data: None,
                error: Some(e.to_string()),
            }),
        )),
    }
}

async fn update_sighting(
    State(state): State<AppState>,
    Path(id): Path<i64>,
    Json(input): Json<CreateSighting>,
) -> Result<Json<ApiResponse<i64>>, (StatusCode, Json<ApiResponse<()>>)> {
    let now = chrono::Utc::now();
    let source = match input.source.as_str() {
        "GBIF" => Source::GBIF,
        "Movebank" => Source::Movebank,
        "iNaturalist" | "INaturalist" => Source::INaturalist,
        "IUCN" => Source::IUCN,
        _ => Source::GBIF,
    };

    let sighting = Sighting {
        id: Some(id),
        species: input.species,
        scientific_name: input.scientific_name,
        latitude: input.latitude,
        longitude: input.longitude,
        observed_on: now,
        source,
        source_id: input.source_id,
        details: input.details,
    };

    match state.db.insert_sighting(&sighting) {
        Ok(_inserted_id) => {
            let _ = state
                .broadcast
                .publish(StreamingEvent::SightingUpdated(sighting));
            Ok(Json(ApiResponse {
                success: true,
                data: Some(id),
                error: None,
            }))
        }
        Err(e) => Err((
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(ApiResponse {
                success: false,
                data: None,
                error: Some(e.to_string()),
            }),
        )),
    }
}

async fn run_analysis(
    State(state): State<AppState>,
    Path(id): Path<i64>,
) -> Result<Json<ApiResponse<String>>, (StatusCode, Json<ApiResponse<()>>)> {
    let sightings = state.db.get_all_sightings().map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(ApiResponse {
                success: false,
                data: None,
                error: Some(e.to_string()),
            }),
        )
    })?;

    let analysis_id = format!("analysis_{}", id);
    let msg = format!(
        "Analysis complete for sighting {}: {} total sightings in dataset",
        id,
        sightings.len()
    );
    let _ = state
        .broadcast
        .publish(StreamingEvent::AnalysisComplete(msg.clone()));

    Ok(Json(ApiResponse {
        success: true,
        data: Some(analysis_id),
        error: None,
    }))
}

async fn export_sightings(
    State(state): State<AppState>,
    Path(format): Path<String>,
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<ApiResponse<()>>)> {
    let sightings = state.db.get_all_sightings().map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(ApiResponse {
                success: false,
                data: None,
                error: Some(e.to_string()),
            }),
        )
    })?;

    match format.as_str() {
        "json" => {
            let json_data = serde_json::to_value(&sightings).map_err(|e| {
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    Json(ApiResponse {
                        success: false,
                        data: None,
                        error: Some(e.to_string()),
                    }),
                )
            })?;
            Ok(Json(ApiResponse {
                success: true,
                data: Some(json_data),
                error: None,
            }))
        }
        _ => Err((
            StatusCode::BAD_REQUEST,
            Json(ApiResponse {
                success: false,
                data: None,
                error: Some(format!("Unsupported export format: {}", format)),
            }),
        )),
    }
}