diff --git a/api/src/airports/delete.rs b/api/src/airports/delete.rs deleted file mode 100644 index 5e9999b..0000000 --- a/api/src/airports/delete.rs +++ /dev/null @@ -1,233 +0,0 @@ -use std::fmt::Display; -use std::str::FromStr; - -use crate::db; -use log::error; -use serde::{Deserialize, Serialize}; -use sqlx::postgres::types::PgPoint; -use crate::error::ApiResult; - -const TABLE_NAME: &str = "airports"; - -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -struct RunwayDb { - pub icao: String, - pub id: String, - pub length_ft: f32, - pub width_ft: f32, - pub surface: String, -} - -#[derive(Debug)] -pub struct AirportFilter { - pub icaos: Option>, - pub name: Option, - // pub bounds: Option>, - pub categories: Option>, - pub has_metar: Option, -} - -impl Default for AirportFilter { - fn default() -> Self { - AirportFilter { - icaos: None, - name: None, - // bounds: None, - categories: None, - has_metar: None, - } - } -} - -impl AirportDb { - pub async fn find_all(_filter: &AirportFilter, _limit: i32, _page: i32) -> ApiResult> { - let pool = db::pool(); - let airports: Vec = sqlx::query_as::<_, Self>(&format!( - "SELECT * FROM {}", - TABLE_NAME - )) - .fetch_all(pool) - .await?; - - Ok(airports) - } - - pub async fn count(_filter: &AirportFilter) -> ApiResult { - let pool = db::pool(); - let count: i64 = sqlx::query_scalar::<_, i64>(&format!( - "SELECT COUNT(*) FROM {}", - TABLE_NAME - )) - .fetch_one(pool) - .await?; - - Ok(count) - } - - // fn build_query<'a>( - // mut query: QueryBuilder<'a, Postgres>, - // filter: &'a AirportFilter, - // ) -> QueryBuilder<'a, Postgres> { - // if let Some(bounds) = &filter.bounds { - // // convert bounds to a WKT polygon - // if bounds.rings.len() > 1 { - // return Err(ApiError { - // status: 400, - // message: "Only one polygon is allowed".to_string(), - // }); - // } else { - // let mut points: Vec = vec![]; - // bounds.rings.iter().for_each(|ring| { - // ring.iter().for_each(|point| { - // points.push(format!("{} {}", point.get_x(), point.get_y())); - // }); - // }); - // let bounds = format!("POLYGON(({}))", points.join(",")); - // query.push(format!( - // "ST_Contains(ST_GeomFromText('{}', 4326), point)", - // bounds - // )); - // } - // } - // if let Some(categories) = &filter.categories { - // query.push(format!( - // "({})", - // categories - // .iter() - // .map(|category| format!("category = '{}'", category.to_string())) - // .collect::>() - // .join(" OR ") - // )); - // } - // - // fn sanitize_icao(icao: &str) -> String { - // // Sanitize search to only allow [a-zA-Z0-9-\\s] - // icao - // .chars() - // .filter(|c| c.is_alphanumeric() || *c == '-' || *c == ' ') - // .collect::() - // } - // - // if &filter.icaos.is_some() == &true && &filter.name.is_some() == &true { - // let icaos = filter.icaos.as_ref().unwrap(); - // let name = sanitize_icao(filter.name.as_ref().unwrap()); - // let icao_part = format!( - // "({})", - // icaos - // .iter() - // .map(|icao| format!("icao ILIKE '{}'", sanitize_icao(icao))) - // .collect::>() - // .join(" OR ") - // ); - // let name_part = format!("name ILIKE '%{}%'", name); - // parts.push(format!("({} OR {})", icao_part, name_part)); - // } else if let Some(icaos) = &filter.icaos { - // parts.push(format!( - // "({})", - // icaos - // .iter() - // .map(|icao| format!("icao ILIKE '{}'", sanitize_icao(icao))) - // .collect::>() - // .join(" OR ") - // )); - // } else if let Some(name) = &filter.name { - // let search = sanitize_icao(name); - // parts.push(format!("name ILIKE '%{}%'", search)); - // } - // if let Some(has_metar) = &filter.has_metar { - // parts.push(format!("has_metar = {}", has_metar)); - // } - // - // if parts.len() > 0 { - // query = format!("{} WHERE {}", query, parts.join(" AND ")); - // } - // - // return Ok(query); - // } - - pub async fn find_by_icao(icao: &str) -> ApiResult { - let pool = db::pool(); - let airport = - sqlx::query_as::<_, Self>(&format!("SELECT * FROM {} WHERE icao = $1", TABLE_NAME)) - .bind(icao) - .fetch_one(pool) - .await?; - - Ok(airport) - } - - pub async fn insert(&self) -> ApiResult<()> { - let pool = db::pool(); - sqlx::query(&format!( - "INSERT INTO {} ( - icao, - category, - name, - elevation_ft, - iso_country, - iso_region, - municipality, - has_metar, - point, - data - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 - )", - TABLE_NAME - )) - .bind(self.icao.clone()) - .bind(self.category.clone()) - .bind(&self.name) - .bind(self.elevation_ft) - .bind(self.iso_country.clone()) - .bind(self.iso_region.clone()) - .bind(self.municipality.clone()) - .bind(self.has_metar.clone()) - // .bind(self.point.clone()) - .bind(self.data.clone()) - .execute(pool) - .await?; - Ok(()) - } - - // pub fn insert_vec(airports: Vec) -> ApiResult> { - // let mut conn: r2d2::PooledConnection> = - // db::connection()?; - // let mut inserted_airports: Vec = vec![]; - // for airport in airports { - // let airport = Self::from(airport); - // let airport = diesel::insert_into(airports::table) - // .values(airport) - // .on_conflict_do_nothing() - // .get_result(&mut conn)?; - // inserted_airports.push(airport); - // } - // Ok(inserted_airports) - // } - - pub async fn update(&self) -> ApiResult<()> { - // let mut conn = db::pool()?; - // let airport = diesel::update(airports::table) - // .filter(airports::icao.eq(airport.icao.clone())) - // .set(airport) - // .get_result(&mut conn)?; - // Ok(airport) - Ok(()) - } - - pub async fn delete_all() -> ApiResult<()> { - Ok(()) - } - - pub async fn delete_by_icao(_icao: &str) -> ApiResult<()> { - // let mut conn = db::pool()?; - // let res = match icao { - // Some(icao) => { - // diesel::delete(airports::table.filter(airports::icao.eq(icao))).execute(&mut conn)? - // } - // None => diesel::delete(airports::table).execute(&mut conn)?, - // }; - // Ok(res) - Ok(()) - } -} diff --git a/api/src/airports/model/airport.rs b/api/src/airports/model/airport.rs index 68a2f61..fecc198 100644 --- a/api/src/airports/model/airport.rs +++ b/api/src/airports/model/airport.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use actix_web::web::Json; use serde::{Deserialize, Serialize}; -use sqlx::{Postgres, QueryBuilder}; +use sqlx::{Execute, Postgres, QueryBuilder}; use crate::airports::model::airport_category::AirportCategory; use crate::airports::{Frequency, Runway, UpdateFrequency, UpdateRunway}; use crate::db; @@ -33,7 +33,38 @@ pub struct Airport { pub public: bool, } -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Deserialize)] +pub struct AirportQuery { + pub page: Option, + pub limit: Option, + pub icaos: Option, + pub iatas: Option, + pub locals: Option, + pub names: Option, + pub categories: Option, + pub iso_countries: Option, + pub iso_regions: Option, + pub municipalities: Option, +} + +impl Default for AirportQuery { + fn default() -> Self { + Self { + page: Some(1), + limit: Some(1000), + icaos: None, + iatas: None, + locals: None, + names: None, + categories: None, + iso_countries: None, + iso_regions: None, + municipalities: None, + } + } +} + +#[derive(Debug, Deserialize, sqlx::FromRow)] struct AirportRow { pub icao: String, pub iata: Option, @@ -51,39 +82,23 @@ struct AirportRow { pub public: bool, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Deserialize)] pub struct UpdateAirport { - #[serde(skip_serializing_if = "Option::is_none")] pub icao: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub iata: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub local: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub category: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub iso_country: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub iso_region: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub municipality: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub elevation_ft: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub longitude: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub latitude: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub has_tower: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub has_beacon: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub runways: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub frequencies: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub public: Option, } @@ -161,19 +176,95 @@ impl Airport { } } - pub async fn select_all() -> ApiResult> { + pub async fn select_all(query: &AirportQuery) -> ApiResult> { let pool = db::pool(); - let airports: Vec = sqlx::query_as(&format!( - r#" - SELECT * FROM {} - "#, - TABLE_NAME - )) - .fetch_all(pool) - .await?; + let mut builder = QueryBuilder::::new("SELECT * FROM "); + builder.push(TABLE_NAME); - Ok(airports.into_iter().map(From::from).collect()) + let mut has_where = false; + macro_rules! push_condition { + ($field:expr, $value:expr) => { + if let Some(ref val) = $value { + if !has_where { + builder.push(" WHERE "); + has_where = true; + } else { + builder.push(" AND "); + } + builder.push($field).push(" = ").push_bind(val); + } + }; + } + + // push_condition!("icao", query.icaos); + // push_condition!("iata", query.iata); + // push_condition!("iso_country", query.iso_country); + // push_condition!("iso_region", query.iso_region); + // push_condition!("municipality", query.municipality); + + // Apply pagination. + if let Some(limit) = query.limit { + builder.push(" LIMIT ").push_bind(limit as i64); + let offset = if let Some(page) = query.page { + // Calculate offset (page is 1-based). + (page.saturating_sub(1) * limit) as i64 + } else { + 0 + }; + builder.push(" OFFSET ").push_bind(offset); + } + + let query = builder.build_query_as(); + let airport_rows: Vec = query.fetch_all(pool).await?; + Ok(airport_rows.into_iter().map(From::from).collect()) + } + + pub async fn count(query: &AirportQuery) -> i64 { + let pool = db::pool(); + + let mut builder = QueryBuilder::::new("SELECT COUNT(*) FROM "); + builder.push(TABLE_NAME); + + let mut has_where = false; + macro_rules! push_condition_array { + ($column:expr, $field:expr) => { + if let Some(ref value_str) = $field { + // split on commas, trim whitespace, and drop empties + let values: Vec<&str> = value_str + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + if !values.is_empty() { + if !has_where { + builder.push(" WHERE "); + has_where = true; + } else { + builder.push(" AND "); + } + dbg!(&values); + builder.push($column); + builder.push(" = ANY("); + builder.push_bind(values); + builder.push(")"); + } + } + }; + } + + push_condition_array!("icao", query.icaos); + push_condition_array!("iata", query.iatas); + push_condition_array!("iso_country", query.iso_countries); + push_condition_array!("iso_region", query.iso_regions); + push_condition_array!("municipality", query.municipalities); + push_condition_array!("local", query.locals); + push_condition_array!("name", query.names); + push_condition_array!("category", query.categories); + + let sql_query = builder.build_query_scalar(); + dbg!(&sql_query.sql()); + sql_query.fetch_one(pool).await.unwrap_or_else(|_| 0) } pub async fn insert(&self) -> ApiResult { diff --git a/api/src/airports/routes.rs b/api/src/airports/routes.rs index cae8f39..f6b2b65 100644 --- a/api/src/airports/routes.rs +++ b/api/src/airports/routes.rs @@ -9,22 +9,9 @@ use crate::{ use actix_multipart::Multipart; use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError}; use serde::{Serialize, Deserialize}; -use crate::airports::UpdateAirport; +use crate::airports::{AirportQuery, UpdateAirport}; use crate::users::ADMIN_ROLE; -#[derive(Debug, Serialize, Deserialize)] -struct AirportsQuery { - icaos: Option, - name: Option, - bounds: Option, - categories: Option, - order_field: Option, - order_by: Option, - has_metar: Option, - limit: Option, - page: Option, -} - #[post("/import")] async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse { if let Err(err) = verify_role(&auth, ADMIN_ROLE) { @@ -69,8 +56,27 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse { #[get("")] async fn get_airports(req: HttpRequest) -> HttpResponse { - match Airport::select_all().await { - Ok(airports) => HttpResponse::Ok().json(airports), + let mut query = match web::Query::::from_query(req.query_string()) { + Ok(q) => q.into_inner(), + Err(_) => AirportQuery::default(), + }; + + let total = Airport::count(&query).await; + let page = query.page.unwrap_or(1); + let mut limit = query.limit.unwrap_or(total as u32); + if limit > 1000 { + limit = 1000 + } + query.limit = Some(limit); + query.page = Some(page); + + match Airport::select_all(&query).await { + Ok(airports) => HttpResponse::Ok().json(Paged { + data: airports, + page, + limit, + total, + }), Err(err) => { log::error!("{}", err); ResponseError::error_response(&err) diff --git a/api/src/db/mod.rs b/api/src/db/mod.rs index 4078903..60951e6 100644 --- a/api/src/db/mod.rs +++ b/api/src/db/mod.rs @@ -168,8 +168,8 @@ pub async fn delete_file(path: &str) -> ApiResult { #[derive(Serialize, Deserialize)] pub struct Paged { pub data: T, - pub page: i32, - pub limit: i32, + pub page: u32, + pub limit: u32, pub total: i64, } diff --git a/bruno/Airports/Delete Airport.bru b/bruno/Airports/Delete Airport.bru index 2969aef..4f2801e 100644 --- a/bruno/Airports/Delete Airport.bru +++ b/bruno/Airports/Delete Airport.bru @@ -1,7 +1,7 @@ meta { name: Delete Airport type: http - seq: 4 + seq: 5 } delete { diff --git a/bruno/Airports/Delete All Airports.bru b/bruno/Airports/Delete All Airports.bru index 2220ad4..d6eb3e9 100644 --- a/bruno/Airports/Delete All Airports.bru +++ b/bruno/Airports/Delete All Airports.bru @@ -1,7 +1,7 @@ meta { name: Delete All Airports type: http - seq: 5 + seq: 6 } delete { diff --git a/bruno/Airports/Get Airport.bru b/bruno/Airports/Get Airport.bru index 8949bd5..27ed00f 100644 --- a/bruno/Airports/Get Airport.bru +++ b/bruno/Airports/Get Airport.bru @@ -1,7 +1,7 @@ meta { name: Get Airport type: http - seq: 2 + seq: 3 } get { diff --git a/bruno/Airports/Get All Airports.bru b/bruno/Airports/Get All Airports.bru index a2131c5..32fe234 100644 --- a/bruno/Airports/Get All Airports.bru +++ b/bruno/Airports/Get All Airports.bru @@ -1,11 +1,17 @@ meta { name: Get All Airports type: http - seq: 3 + seq: 4 } get { - url: {{BASE_URL}}/airports + url: {{BASE_URL}}/airports?icaos=00AA&page=1&limit=1000 body: none auth: none } + +params:query { + icaos: 00AA + page: 1 + limit: 1000 +} diff --git a/bruno/Airports/Import Airports.bru b/bruno/Airports/Import Airports.bru new file mode 100644 index 0000000..41219de --- /dev/null +++ b/bruno/Airports/Import Airports.bru @@ -0,0 +1,15 @@ +meta { + name: Import Airports + type: http + seq: 2 +} + +post { + url: {{BASE_URL}}/airports/import + body: multipartForm + auth: none +} + +body:multipart-form { + : @file(/Users/bsherriff/git/private/aviation-weather/data/airports_2023-12-21.json) +} diff --git a/data/airports_2023-12-21.json b/data/airports_2023-12-21.json index 2235cd0..478e4fd 100644 --- a/data/airports_2023-12-21.json +++ b/data/airports_2023-12-21.json @@ -144368,7 +144368,7 @@ "iata": "", "local": "64CL", "name": "Goodyear Blimp Base Airport", - "category": "balloonport", + "category": "balloon_port", "iso_country": "US", "iso_region": "US-CA", "municipality": "Gardena",