Working through airport query params

This commit is contained in:
2025-04-07 08:13:22 -04:00
parent 769762dfa7
commit b770c343ec
10 changed files with 171 additions and 286 deletions

View File

@@ -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<Vec<String>>,
pub name: Option<String>,
// pub bounds: Option<Polygon<Point>>,
pub categories: Option<Vec<AirportCategory>>,
pub has_metar: Option<bool>,
}
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<Vec<Self>> {
let pool = db::pool();
let airports: Vec<Self> = sqlx::query_as::<_, Self>(&format!(
"SELECT * FROM {}",
TABLE_NAME
))
.fetch_all(pool)
.await?;
Ok(airports)
}
pub async fn count(_filter: &AirportFilter) -> ApiResult<i64> {
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<String> = 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::<Vec<String>>()
// .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::<String>()
// }
//
// 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::<Vec<String>>()
// .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::<Vec<String>>()
// .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<Self> {
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<Self>) -> ApiResult<Vec<Self>> {
// let mut conn: r2d2::PooledConnection<diesel::r2d2::ConnectionManager<PgConnection>> =
// db::connection()?;
// let mut inserted_airports: Vec<Self> = 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(())
}
}

View File

@@ -1,7 +1,7 @@
use std::str::FromStr; use std::str::FromStr;
use actix_web::web::Json; use actix_web::web::Json;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder}; use sqlx::{Execute, Postgres, QueryBuilder};
use crate::airports::model::airport_category::AirportCategory; use crate::airports::model::airport_category::AirportCategory;
use crate::airports::{Frequency, Runway, UpdateFrequency, UpdateRunway}; use crate::airports::{Frequency, Runway, UpdateFrequency, UpdateRunway};
use crate::db; use crate::db;
@@ -33,7 +33,38 @@ pub struct Airport {
pub public: bool, pub public: bool,
} }
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Deserialize)]
pub struct AirportQuery {
pub page: Option<u32>,
pub limit: Option<u32>,
pub icaos: Option<String>,
pub iatas: Option<String>,
pub locals: Option<String>,
pub names: Option<String>,
pub categories: Option<String>,
pub iso_countries: Option<String>,
pub iso_regions: Option<String>,
pub municipalities: Option<String>,
}
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 { struct AirportRow {
pub icao: String, pub icao: String,
pub iata: Option<String>, pub iata: Option<String>,
@@ -51,39 +82,23 @@ struct AirportRow {
pub public: bool, pub public: bool,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateAirport { pub struct UpdateAirport {
#[serde(skip_serializing_if = "Option::is_none")]
pub icao: Option<String>, pub icao: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iata: Option<String>, pub iata: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub local: Option<String>, pub local: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>, pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<AirportCategory>, pub category: Option<AirportCategory>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iso_country: Option<String>, pub iso_country: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iso_region: Option<String>, pub iso_region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub municipality: Option<String>, pub municipality: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub elevation_ft: Option<f32>, pub elevation_ft: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub longitude: Option<f32>, pub longitude: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latitude: Option<f32>, pub latitude: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_tower: Option<bool>, pub has_tower: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_beacon: Option<bool>, pub has_beacon: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runways: Option<Vec<UpdateRunway>>, pub runways: Option<Vec<UpdateRunway>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequencies: Option<Vec<UpdateFrequency>>, pub frequencies: Option<Vec<UpdateFrequency>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public: Option<bool>, pub public: Option<bool>,
} }
@@ -161,19 +176,95 @@ impl Airport {
} }
} }
pub async fn select_all() -> ApiResult<Vec<Self>> { pub async fn select_all(query: &AirportQuery) -> ApiResult<Vec<Self>> {
let pool = db::pool(); let pool = db::pool();
let airports: Vec<AirportRow> = sqlx::query_as(&format!( let mut builder = QueryBuilder::<Postgres>::new("SELECT * FROM ");
r#" builder.push(TABLE_NAME);
SELECT * FROM {}
"#,
TABLE_NAME
))
.fetch_all(pool)
.await?;
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<AirportRow> = 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::<Postgres>::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<Self> { pub async fn insert(&self) -> ApiResult<Self> {

View File

@@ -9,22 +9,9 @@ use crate::{
use actix_multipart::Multipart; use actix_multipart::Multipart;
use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError}; use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError};
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use crate::airports::UpdateAirport; use crate::airports::{AirportQuery, UpdateAirport};
use crate::users::ADMIN_ROLE; use crate::users::ADMIN_ROLE;
#[derive(Debug, Serialize, Deserialize)]
struct AirportsQuery {
icaos: Option<String>,
name: Option<String>,
bounds: Option<String>,
categories: Option<String>,
order_field: Option<String>,
order_by: Option<String>,
has_metar: Option<String>,
limit: Option<i32>,
page: Option<i32>,
}
#[post("/import")] #[post("/import")]
async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse { async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
if let Err(err) = verify_role(&auth, ADMIN_ROLE) { if let Err(err) = verify_role(&auth, ADMIN_ROLE) {
@@ -69,8 +56,27 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
#[get("")] #[get("")]
async fn get_airports(req: HttpRequest) -> HttpResponse { async fn get_airports(req: HttpRequest) -> HttpResponse {
match Airport::select_all().await { let mut query = match web::Query::<AirportQuery>::from_query(req.query_string()) {
Ok(airports) => HttpResponse::Ok().json(airports), 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) => { Err(err) => {
log::error!("{}", err); log::error!("{}", err);
ResponseError::error_response(&err) ResponseError::error_response(&err)

View File

@@ -168,8 +168,8 @@ pub async fn delete_file(path: &str) -> ApiResult<ResponseData> {
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct Paged<T> { pub struct Paged<T> {
pub data: T, pub data: T,
pub page: i32, pub page: u32,
pub limit: i32, pub limit: u32,
pub total: i64, pub total: i64,
} }

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Delete Airport name: Delete Airport
type: http type: http
seq: 4 seq: 5
} }
delete { delete {

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Delete All Airports name: Delete All Airports
type: http type: http
seq: 5 seq: 6
} }
delete { delete {

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Get Airport name: Get Airport
type: http type: http
seq: 2 seq: 3
} }
get { get {

View File

@@ -1,11 +1,17 @@
meta { meta {
name: Get All Airports name: Get All Airports
type: http type: http
seq: 3 seq: 4
} }
get { get {
url: {{BASE_URL}}/airports url: {{BASE_URL}}/airports?icaos=00AA&page=1&limit=1000
body: none body: none
auth: none auth: none
} }
params:query {
icaos: 00AA
page: 1
limit: 1000
}

View File

@@ -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)
}

View File

@@ -144368,7 +144368,7 @@
"iata": "", "iata": "",
"local": "64CL", "local": "64CL",
"name": "Goodyear Blimp Base Airport", "name": "Goodyear Blimp Base Airport",
"category": "balloonport", "category": "balloon_port",
"iso_country": "US", "iso_country": "US",
"iso_region": "US-CA", "iso_region": "US-CA",
"municipality": "Gardena", "municipality": "Gardena",