Skip to main content

howler_core/
web_server.rs

1use axum::{
2    extract::{Path, State},
3    http::StatusCode,
4    routing::{get, post},
5    Json, Router,
6};
7use serde::{Deserialize, Serialize};
8use std::sync::Arc;
9
10use crate::db::Database;
11use crate::models::{Sighting, Source};
12use crate::streaming::{Broadcast, StreamingEvent};
13use crate::websocket::ws_handler;
14
15#[derive(Clone)]
16pub struct AppState {
17    pub db: Arc<Database>,
18    pub broadcast: Broadcast,
19}
20
21#[derive(Deserialize)]
22pub struct CreateSighting {
23    pub species: String,
24    pub scientific_name: Option<String>,
25    pub latitude: f64,
26    pub longitude: f64,
27    pub source: String,
28    pub source_id: String,
29    pub details: Option<String>,
30}
31
32#[derive(Serialize)]
33pub struct ApiResponse<T> {
34    pub success: bool,
35    pub data: Option<T>,
36    pub error: Option<String>,
37}
38
39pub fn build_router(state: AppState) -> Router {
40    Router::new()
41        .route("/api/sightings", get(get_sightings).post(create_sighting))
42        .route(
43            "/api/sightings/{id}",
44            get(get_sighting_by_id).put(update_sighting),
45        )
46        .route("/api/analysis/{id}", post(run_analysis))
47        .route("/api/export/{format}", get(export_sightings))
48        .route("/ws/stream", get(ws_handler))
49        .with_state(state)
50}
51
52pub async fn start_server(host: &str, port: u16, state: AppState) -> anyhow::Result<()> {
53    let router = build_router(state);
54    let addr = format!("{}:{}", host, port);
55    let listener = tokio::net::TcpListener::bind(&addr).await?;
56    axum::serve(listener, router).await?;
57    Ok(())
58}
59
60async fn get_sightings(
61    State(state): State<AppState>,
62) -> Result<Json<ApiResponse<Vec<Sighting>>>, (StatusCode, Json<ApiResponse<()>>)> {
63    match state.db.get_all_sightings() {
64        Ok(sightings) => Ok(Json(ApiResponse {
65            success: true,
66            data: Some(sightings),
67            error: None,
68        })),
69        Err(e) => Err((
70            StatusCode::INTERNAL_SERVER_ERROR,
71            Json(ApiResponse {
72                success: false,
73                data: None,
74                error: Some(e.to_string()),
75            }),
76        )),
77    }
78}
79
80async fn get_sighting_by_id(
81    State(state): State<AppState>,
82    Path(id): Path<i64>,
83) -> Result<Json<ApiResponse<Sighting>>, (StatusCode, Json<ApiResponse<()>>)> {
84    let sightings = state.db.get_all_sightings().map_err(|e| {
85        (
86            StatusCode::INTERNAL_SERVER_ERROR,
87            Json(ApiResponse {
88                success: false,
89                data: None,
90                error: Some(e.to_string()),
91            }),
92        )
93    })?;
94
95    match sightings.into_iter().find(|s| s.id == Some(id)) {
96        Some(sighting) => Ok(Json(ApiResponse {
97            success: true,
98            data: Some(sighting),
99            error: None,
100        })),
101        None => Err((
102            StatusCode::NOT_FOUND,
103            Json(ApiResponse {
104                success: false,
105                data: None,
106                error: Some("Sighting not found".to_string()),
107            }),
108        )),
109    }
110}
111
112async fn create_sighting(
113    State(state): State<AppState>,
114    Json(input): Json<CreateSighting>,
115) -> Result<(StatusCode, Json<ApiResponse<i64>>), (StatusCode, Json<ApiResponse<()>>)> {
116    let now = chrono::Utc::now();
117    let source = match input.source.as_str() {
118        "GBIF" => Source::GBIF,
119        "Movebank" => Source::Movebank,
120        "iNaturalist" | "INaturalist" => Source::INaturalist,
121        "IUCN" => Source::IUCN,
122        _ => Source::GBIF,
123    };
124
125    let sighting = Sighting {
126        id: None,
127        species: input.species,
128        scientific_name: input.scientific_name,
129        latitude: input.latitude,
130        longitude: input.longitude,
131        observed_on: now,
132        source,
133        source_id: input.source_id,
134        details: input.details,
135    };
136
137    match state.db.insert_sighting(&sighting) {
138        Ok(id) => {
139            let mut saved = sighting.clone();
140            saved.id = Some(id);
141            let _ = state
142                .broadcast
143                .publish(StreamingEvent::SightingCreated(saved));
144            Ok((
145                StatusCode::CREATED,
146                Json(ApiResponse {
147                    success: true,
148                    data: Some(id),
149                    error: None,
150                }),
151            ))
152        }
153        Err(e) => Err((
154            StatusCode::INTERNAL_SERVER_ERROR,
155            Json(ApiResponse {
156                success: false,
157                data: None,
158                error: Some(e.to_string()),
159            }),
160        )),
161    }
162}
163
164async fn update_sighting(
165    State(state): State<AppState>,
166    Path(id): Path<i64>,
167    Json(input): Json<CreateSighting>,
168) -> Result<Json<ApiResponse<i64>>, (StatusCode, Json<ApiResponse<()>>)> {
169    let now = chrono::Utc::now();
170    let source = match input.source.as_str() {
171        "GBIF" => Source::GBIF,
172        "Movebank" => Source::Movebank,
173        "iNaturalist" | "INaturalist" => Source::INaturalist,
174        "IUCN" => Source::IUCN,
175        _ => Source::GBIF,
176    };
177
178    let sighting = Sighting {
179        id: Some(id),
180        species: input.species,
181        scientific_name: input.scientific_name,
182        latitude: input.latitude,
183        longitude: input.longitude,
184        observed_on: now,
185        source,
186        source_id: input.source_id,
187        details: input.details,
188    };
189
190    match state.db.insert_sighting(&sighting) {
191        Ok(_inserted_id) => {
192            let _ = state
193                .broadcast
194                .publish(StreamingEvent::SightingUpdated(sighting));
195            Ok(Json(ApiResponse {
196                success: true,
197                data: Some(id),
198                error: None,
199            }))
200        }
201        Err(e) => Err((
202            StatusCode::INTERNAL_SERVER_ERROR,
203            Json(ApiResponse {
204                success: false,
205                data: None,
206                error: Some(e.to_string()),
207            }),
208        )),
209    }
210}
211
212async fn run_analysis(
213    State(state): State<AppState>,
214    Path(id): Path<i64>,
215) -> Result<Json<ApiResponse<String>>, (StatusCode, Json<ApiResponse<()>>)> {
216    let sightings = state.db.get_all_sightings().map_err(|e| {
217        (
218            StatusCode::INTERNAL_SERVER_ERROR,
219            Json(ApiResponse {
220                success: false,
221                data: None,
222                error: Some(e.to_string()),
223            }),
224        )
225    })?;
226
227    let analysis_id = format!("analysis_{}", id);
228    let msg = format!(
229        "Analysis complete for sighting {}: {} total sightings in dataset",
230        id,
231        sightings.len()
232    );
233    let _ = state
234        .broadcast
235        .publish(StreamingEvent::AnalysisComplete(msg.clone()));
236
237    Ok(Json(ApiResponse {
238        success: true,
239        data: Some(analysis_id),
240        error: None,
241    }))
242}
243
244async fn export_sightings(
245    State(state): State<AppState>,
246    Path(format): Path<String>,
247) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<ApiResponse<()>>)> {
248    let sightings = state.db.get_all_sightings().map_err(|e| {
249        (
250            StatusCode::INTERNAL_SERVER_ERROR,
251            Json(ApiResponse {
252                success: false,
253                data: None,
254                error: Some(e.to_string()),
255            }),
256        )
257    })?;
258
259    match format.as_str() {
260        "json" => {
261            let json_data = serde_json::to_value(&sightings).map_err(|e| {
262                (
263                    StatusCode::INTERNAL_SERVER_ERROR,
264                    Json(ApiResponse {
265                        success: false,
266                        data: None,
267                        error: Some(e.to_string()),
268                    }),
269                )
270            })?;
271            Ok(Json(ApiResponse {
272                success: true,
273                data: Some(json_data),
274                error: None,
275            }))
276        }
277        _ => Err((
278            StatusCode::BAD_REQUEST,
279            Json(ApiResponse {
280                success: false,
281                data: None,
282                error: Some(format!("Unsupported export format: {}", format)),
283            }),
284        )),
285    }
286}