From 05c49dee4c057fe31b7dfb2c546c208d368af962 Mon Sep 17 00:00:00 2001 From: Ben Sherriff Date: Thu, 10 Apr 2025 18:08:06 -0400 Subject: [PATCH] Added airport data to map --- api/src/airports/model/airport.rs | 109 ++++++++++++++++++++- api/src/auth/routes.rs | 60 ++++++++++-- api/src/error.rs | 6 ++ api/src/main.rs | 5 + api/src/metars/model.rs | 2 +- api/src/users/model.rs | 80 +++++++++++++-- bruno/Users/Change Password.bru | 20 ++++ bruno/Users/Create API Key.bru | 23 ----- bruno/Users/Login.bru | 2 +- bruno/Users/Logout.bru | 2 +- bruno/Users/Register.bru | 2 +- ui/src/App.css | 20 ++-- ui/src/App.tsx | 36 ++++--- ui/src/components/AirportLayer.tsx | 104 ++++++++++++++++++++ ui/src/components/Header/Header.module.css | 1 - ui/src/components/Header/index.tsx | 11 ++- ui/src/lib/airport.ts | 40 ++++++++ ui/src/lib/airport.types.ts | 93 ++++++++++++++++++ ui/src/lib/index.ts | 72 ++++++++++++++ ui/src/lib/metar.types.ts | 43 ++++++++ 20 files changed, 653 insertions(+), 78 deletions(-) create mode 100644 bruno/Users/Change Password.bru delete mode 100644 bruno/Users/Create API Key.bru create mode 100644 ui/src/components/AirportLayer.tsx create mode 100644 ui/src/lib/airport.ts create mode 100644 ui/src/lib/airport.types.ts create mode 100644 ui/src/lib/index.ts create mode 100644 ui/src/lib/metar.types.ts diff --git a/api/src/airports/model/airport.rs b/api/src/airports/model/airport.rs index f7c3dc2..e974ee4 100644 --- a/api/src/airports/model/airport.rs +++ b/api/src/airports/model/airport.rs @@ -46,11 +46,12 @@ pub struct AirportQuery { pub icaos: Option, pub iatas: Option, pub locals: Option, - pub names: Option, + pub name: Option, pub categories: Option, pub iso_countries: Option, pub iso_regions: Option, pub municipalities: Option, + pub bounds: Option, pub metars: Option, } @@ -62,16 +63,48 @@ impl Default for AirportQuery { icaos: None, iatas: None, locals: None, - names: None, + name: None, categories: None, iso_countries: None, iso_regions: None, municipalities: None, + bounds: None, metars: None, } } } +#[derive(Debug, Deserialize)] +pub struct Bounds { + pub north_east_lat: f32, + pub north_east_lon: f32, + pub south_west_lat: f32, + pub south_west_lon: f32, +} + +impl Bounds { + fn parse(input: &str) -> ApiResult { + let parts: Vec<&str> = input.split(',').collect(); + if parts.len() != 4 { + return Err(Error::new( + 400, + format!("Expected 4 fields in bounds but received {}", parts.len()), + )); + } + let north_east_lat = parts[0].trim().parse::()?; + let north_east_lon = parts[1].trim().parse::()?; + let south_west_lat = parts[2].trim().parse::()?; + let south_west_lon = parts[3].trim().parse::()?; + + Ok(Bounds { + north_east_lat, + north_east_lon, + south_west_lat, + south_west_lon, + }) + } +} + #[derive(Debug, Deserialize, sqlx::FromRow)] struct AirportRow { pub icao: String, @@ -265,8 +298,20 @@ impl Airport { &query.municipalities, ); Self::push_condition_array(&mut builder, &mut has_where, "local", &query.locals); - Self::push_condition_array(&mut builder, &mut has_where, "name", &query.names); Self::push_condition_array(&mut builder, &mut has_where, "category", &query.categories); + Self::push_condition_like(&mut builder, &mut has_where, "name", &query.name); + Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds)?; + + // Order by AircraftCategory + builder.push(" ORDER BY CASE category "); + builder.push(" WHEN 'large_airport' THEN 1 "); + builder.push(" WHEN 'medium_airport' THEN 2 "); + builder.push(" WHEN 'small_airport' THEN 3 "); + builder.push(" WHEN 'seaplane_base' THEN 4 "); + builder.push(" WHEN 'heliport' THEN 5 "); + builder.push(" WHEN 'balloon_port' THEN 6 "); + builder.push(" WHEN 'unknown' THEN 7 "); + builder.push(" ELSE 8 END"); // Apply pagination. if let Some(limit) = query.limit { @@ -361,8 +406,12 @@ impl Airport { &query.municipalities, ); Self::push_condition_array(&mut builder, &mut has_where, "local", &query.locals); - Self::push_condition_array(&mut builder, &mut has_where, "name", &query.names); Self::push_condition_array(&mut builder, &mut has_where, "category", &query.categories); + Self::push_condition_like(&mut builder, &mut has_where, "name", &query.name); + if let Err(err) = Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds) { + log::error!("Error parsing bounds string: {}", err); + return 0; + } let sql_query = builder.build_query_scalar(); sql_query.fetch_one(pool).await.unwrap_or_else(|_| 0) @@ -529,4 +578,56 @@ impl Airport { } } } + + fn push_condition_like<'a>( + builder: &mut QueryBuilder<'a, Postgres>, + has_where: &mut bool, + column: &str, + field: &'a Option, + ) { + // Query column like + if let Some(ref value) = field { + if !*has_where { + builder.push(" WHERE "); + *has_where = true; + } else { + builder.push(" AND "); + } + // Using ILIKE with wildcards for partial matching + builder + .push(column) + .push(" ILIKE ") + .push_bind(format!("%{}%", value)); + } + } + + fn push_condition_bounds<'a>( + builder: &mut QueryBuilder<'a, Postgres>, + has_where: &mut bool, + field: &'a Option, + ) -> ApiResult<()> { + // Query bounds + if let Some(ref bounds_string) = field { + if !*has_where { + builder.push(" WHERE "); + *has_where = true; + } else { + builder.push(" AND "); + } + let bounds = Bounds::parse(bounds_string)?; + builder + .push("(") + .push("latitude BETWEEN ") + .push_bind(bounds.south_west_lat) + .push(" AND ") + .push_bind(bounds.north_east_lat) + .push(" AND ") + .push("longitude BETWEEN ") + .push_bind(bounds.south_west_lon) + .push(" AND ") + .push_bind(bounds.north_east_lon) + .push(")"); + } + Ok(()) + } } diff --git a/api/src/auth/routes.rs b/api/src/auth/routes.rs index 3ca5d8d..7c12eba 100644 --- a/api/src/auth/routes.rs +++ b/api/src/auth/routes.rs @@ -2,7 +2,7 @@ use std::sync::OnceLock; use actix_web::{ post, web, HttpResponse, ResponseError, cookie::{Cookie, time::Duration}, - HttpRequest, + HttpRequest, put, }; use crate::{ auth::{verify_hash, Session, SESSION_COOKIE_NAME}, @@ -10,7 +10,9 @@ use crate::{ users::{LoginRequest, RegisterRequest, User, UserResponse}, }; -use crate::auth::{Auth, DEFAULT_SESSION_TTL}; +use crate::auth::{hash, Auth, DEFAULT_SESSION_TTL}; +use crate::error::ApiResult; +use crate::users::UpdateUser; #[post("/register")] async fn register(user: web::Json, req: HttpRequest) -> HttpResponse { @@ -132,21 +134,61 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse { HttpResponse::Ok().cookie(session_cookie).finish() } -#[post("/key")] -async fn create_api_key(req: HttpRequest, auth: Auth) -> HttpResponse { +#[put("/password")] +async fn change_password( + password: web::Json, + req: HttpRequest, + auth: Auth, +) -> HttpResponse { let ip_address = req.peer_addr().unwrap().ip().to_string(); - let api_key = Session::new(128, &auth.user.email, &ip_address, None); + let email = auth.user.email; - // TODO: store api key - HttpResponse::Ok().body(api_key.session_id) + if let None = User::select(&email).await { + return HttpResponse::Unauthorized().finish(); + }; + + let update_user = UpdateUser { + email: None, + password: Some(password.into_inner()), + role: None, + first_name: None, + last_name: None, + }; + + match update_user.update(&email).await { + Ok(user) => { + let response: UserResponse = user.into(); + log::info!( + "Successful password change attempt [Email: {}] [IP Address: {}]", + &email, + ip_address + ); + HttpResponse::Ok().json(response) + } + Err(err) => { + log::error!( + "Invalid password change attempt [Email: {}] [IP Address: {}]: {}", + &email, + ip_address, + err + ); + ResponseError::error_response(&Error::new(500, err.to_string())) + } + } +} + +#[post("/password-reset")] +async fn password_reset(req: HttpRequest, auth: Auth) -> HttpResponse { + let ip_address = req.peer_addr().unwrap().ip().to_string(); + HttpResponse::Ok().finish() } pub fn init_routes(config: &mut web::ServiceConfig) { config.service( - web::scope("auth") + web::scope("account") .service(register) .service(login) .service(logout) - .service(create_api_key), + .service(change_password), ); } diff --git a/api/src/error.rs b/api/src/error.rs index b5fcc77..a86047d 100644 --- a/api/src/error.rs +++ b/api/src/error.rs @@ -80,6 +80,12 @@ impl From for Error { } } +impl From for Error { + fn from(error: core::num::ParseFloatError) -> Self { + Self::new(500, format!("Parse error: {}", error)) + } +} + impl From for Error { fn from(error: std::env::VarError) -> Self { Self::new( diff --git a/api/src/main.rs b/api/src/main.rs index 31caf3d..f206390 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -33,6 +33,11 @@ async fn main() -> Result<(), Box> { log::debug!("Creating default administrator"); let password = admin_password.unwrap(); let password_hash = hash(&password)?; + if email == "admin@example.com" || password == "CHANGEME" { + log::warn!( + "Default admin credentials are in use, update the ADMIN_EMAIL and ADMIN_PASSWORD." + ); + } let admin_user = User { email, password_hash, diff --git a/api/src/metars/model.rs b/api/src/metars/model.rs index f0ea6fb..e6a29cf 100644 --- a/api/src/metars/model.rs +++ b/api/src/metars/model.rs @@ -216,7 +216,7 @@ impl MetarRow { raw_text, data ) - VALUES ($1, $2, $3, $4, $5) + VALUES ($1, $2, $3, $4) "#, TABLE_NAME, )) diff --git a/api/src/users/model.rs b/api/src/users/model.rs index c9d5e4a..d6f7752 100644 --- a/api/src/users/model.rs +++ b/api/src/users/model.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; - +use sqlx::{Postgres, QueryBuilder}; use crate::{auth::hash, error::ApiResult}; use crate::db; @@ -8,9 +8,6 @@ pub const ADMIN_ROLE: &str = "ADMIN"; pub const USER_ROLE: &str = "USER"; const TABLE_NAME: &str = "users"; -/** - * RegisterRequest - */ #[derive(Debug, Serialize, Deserialize)] pub struct RegisterRequest { pub email: String, @@ -34,18 +31,12 @@ impl RegisterRequest { } } -/** - * LoginRequest - */ #[derive(Debug, Serialize, Deserialize)] pub struct LoginRequest { pub email: String, pub password: String, } -/** - * UserResponse - */ #[derive(Debug, Serialize, Deserialize)] pub struct UserResponse { pub email: String, @@ -65,6 +56,75 @@ impl From for UserResponse { } } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct UpdateUser { + pub email: Option, + pub password: Option, + pub role: Option, + pub first_name: Option, + pub last_name: Option, +} + +impl UpdateUser { + pub async fn update(&self, email: &str) -> ApiResult { + let pool = db::pool(); + + let mut query_builder: QueryBuilder = + QueryBuilder::new(&format!("UPDATE {} SET ", TABLE_NAME)); + + let mut first_clause = true; + + let mut push_comma = |query_builder: &mut QueryBuilder| { + if !first_clause { + query_builder.push(", "); + } else { + first_clause = false; + } + }; + + if let Some(ref email) = self.email { + push_comma(&mut query_builder); + query_builder.push("email = "); + query_builder.push_bind(email); + } + if let Some(ref password) = self.password { + push_comma(&mut query_builder); + let password_hash = hash(password)?; + query_builder.push("password_hash = "); + query_builder.push_bind(password_hash); + } + if let Some(ref role) = self.role { + push_comma(&mut query_builder); + query_builder.push("role = "); + query_builder.push_bind(role); + } + if let Some(ref first_name) = self.first_name { + push_comma(&mut query_builder); + query_builder.push("first_name = "); + query_builder.push_bind(first_name); + } + if let Some(ref last_name) = self.last_name { + push_comma(&mut query_builder); + query_builder.push("last_name = "); + query_builder.push_bind(last_name); + } + push_comma(&mut query_builder); + query_builder.push("updated_at = "); + query_builder.push_bind(Utc::now()); + + query_builder.push(" WHERE email = "); + query_builder.push_bind(email.to_string()); + query_builder.push(" RETURNING *"); + + dbg!(&query_builder.sql()); + + let query = query_builder.build_query_as::(); + let user = query.fetch_one(pool).await?; + + Ok(user) + } +} + #[derive(Serialize, Deserialize, sqlx::FromRow, Debug)] pub struct User { pub email: String, diff --git a/bruno/Users/Change Password.bru b/bruno/Users/Change Password.bru new file mode 100644 index 0000000..c550208 --- /dev/null +++ b/bruno/Users/Change Password.bru @@ -0,0 +1,20 @@ +meta { + name: Change Password + type: http + seq: 4 +} + +put { + url: {{BASE_URL}}/account/password + body: json + auth: none +} + +body:json { + "New Password" +} + +script:post-response { + const apiKey = res.body + bru.setVar("bearer",apiKey) +} diff --git a/bruno/Users/Create API Key.bru b/bruno/Users/Create API Key.bru deleted file mode 100644 index d426255..0000000 --- a/bruno/Users/Create API Key.bru +++ /dev/null @@ -1,23 +0,0 @@ -meta { - name: Create API Key - type: http - seq: 4 -} - -post { - url: {{BASE_URL}}/auth/key - body: none - auth: none -} - -body:json { - { - "email": "john.doe@gmail.com", - "password": "fake_password123" - } -} - -script:post-response { - const apiKey = res.body - bru.setVar("bearer",apiKey) -} diff --git a/bruno/Users/Login.bru b/bruno/Users/Login.bru index 3909e92..22ffd81 100644 --- a/bruno/Users/Login.bru +++ b/bruno/Users/Login.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{BASE_URL}}/auth/login + url: {{BASE_URL}}/account/login body: json auth: none } diff --git a/bruno/Users/Logout.bru b/bruno/Users/Logout.bru index ea71de3..92e0bb6 100644 --- a/bruno/Users/Logout.bru +++ b/bruno/Users/Logout.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{BASE_URL}}/auth/logout + url: {{BASE_URL}}/account/logout body: none auth: none } diff --git a/bruno/Users/Register.bru b/bruno/Users/Register.bru index db0b5da..0120590 100644 --- a/bruno/Users/Register.bru +++ b/bruno/Users/Register.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{BASE_URL}}/auth/register + url: {{BASE_URL}}/account/register body: json auth: none } diff --git a/ui/src/App.css b/ui/src/App.css index aade941..c2430c7 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -6,22 +6,26 @@ body, height: 100%; margin: 0; padding: 0; + overflow: hidden; } /* Set up Flexbox layout */ .App { display: flex; flex-direction: column; - height: 100vh; /* Full viewport height */ + height: 100%; +} + +.app-header { + background-color: #333; + color: #fff; +} + +.map-wrapper { + flex: 1; } -/* Make the map container fill the remaining space */ .leaflet-container { - flex: 1 1 auto; /* Allow the map to grow and fill space */ + height: 100%; width: 100%; } - -/*.leaflet-container {*/ -/* width: 100%;*/ -/* height: 100vh;*/ -/*}*/ diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 27e14b9..e1bf450 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -5,10 +5,11 @@ import './App.css'; import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; import markerIcon from 'leaflet/dist/images/marker-icon.png'; import markerShadow from 'leaflet/dist/images/marker-shadow.png'; -// import { Header } from '@components/Header'; // Fix for default marker icon issues in React-Leaflet import L from 'leaflet'; +import { Header } from '@components/Header'; +import AirportLayer from '@components/AirportLayer.tsx'; // Fix Leaflet's default icon path issues with Webpack // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -26,21 +27,24 @@ const tileLayerUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; function App() { return (
- {/*
*/} - - - +
+
+ + + + +
); } diff --git a/ui/src/components/AirportLayer.tsx b/ui/src/components/AirportLayer.tsx new file mode 100644 index 0000000..7d33679 --- /dev/null +++ b/ui/src/components/AirportLayer.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { Airport, AirportCategory } from '@lib/airport.types.ts'; +import { Marker, Popup, useMapEvents } from 'react-leaflet'; +import { getAirports } from '@lib/airport.ts'; +import L from 'leaflet'; + +interface Bounds { + northEast: { lat: number; lon: number }; + southWest: { lat: number; lon: number }; +} + +export default function AirportLayer() { + const [airports, setAirports] = useState([]); + + useMapEvents({ + moveend: (event) => { + const map = event.target; + const bounds = map.getBounds(); + + const boundsParam: Bounds = { + northEast: { + lat: bounds.getNorth(), + lon: bounds.getEast() + }, + southWest: { + lat: bounds.getSouth(), + lon: bounds.getWest() + } + }; + + // Call getAirports with the current map bounds and desired parameters. + getAirports({ + bounds: boundsParam, + metars: true, + categories: [AirportCategory.SMALL, AirportCategory.MEDIUM, AirportCategory.LARGE], + limit: 200 + }) + .then((response) => { + console.log(response); + setAirports(response.data); + }) + .catch((error) => { + console.error('Error fetching airports:', error); + setAirports([]); + }); + } + }); + + return ( + <> + {airports.map((airport, index) => { + const markerColor = getMarkerColor(airport); + const icon = createCustomIcon(markerColor); + return ( + + +
+

{airport.name || 'Unnamed Airport'}

+

ICAO: {airport.icao || 'N/A'}

+

Flight Category: {airport.latest_metar ? airport.latest_metar.flight_category : 'No METAR Data'}

+
+
+
+ ); + })} + + ); +} + +function getMarkerColor(airport: Airport): string { + if (airport.latest_metar) { + switch (airport.latest_metar.flight_category.toUpperCase()) { + case 'IFR': + return '#ff0100'; + case 'LIFR': + return '#7f007f'; + case 'MVFR': + return '#00f'; + case 'VFR': + return '#018000'; + case 'UNKNOWN': + return '#3e3e3e'; + default: + return '#3e3e3e'; + } + } else { + return '#696969'; + } +} + +function createCustomIcon(color: string): L.DivIcon { + return L.divIcon({ + html: `
`, + className: '', + iconSize: [20, 20], + iconAnchor: [10, 10] + }); +} diff --git a/ui/src/components/Header/Header.module.css b/ui/src/components/Header/Header.module.css index 52997aa..693393d 100644 --- a/ui/src/components/Header/Header.module.css +++ b/ui/src/components/Header/Header.module.css @@ -1,6 +1,5 @@ .header { height: 56px; - margin-bottom: 120px; background-color: var(--mantine-color-body); border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); } diff --git a/ui/src/components/Header/index.tsx b/ui/src/components/Header/index.tsx index 9f3df62..b6f6d4e 100644 --- a/ui/src/components/Header/index.tsx +++ b/ui/src/components/Header/index.tsx @@ -1,12 +1,13 @@ import { useState } from 'react'; -import { Burger, Container, Group, Text } from '@mantine/core'; +import { Avatar, Burger, Container, Group, Text } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; +// import { ReactComponent as Logo } from '../../../public/logo.svg'; import classes from './Header.module.css'; const links = [ { link: '/', label: 'Map' }, { link: '/airports', label: 'Airports' }, - { link: '/metars', label: 'METARs' } + { link: '/metars', label: 'Metars' } ]; export function Header() { @@ -31,7 +32,11 @@ export function Header() { return (
- Aviation Weather + + Aviation Weather + + + {/**/} {items} diff --git a/ui/src/lib/airport.ts b/ui/src/lib/airport.ts new file mode 100644 index 0000000..bce99fb --- /dev/null +++ b/ui/src/lib/airport.ts @@ -0,0 +1,40 @@ +import { Airport, AirportCategory, Bounds, GetAirportsResponse } from '@lib/airport.types.ts'; +import { getRequest } from '@lib/index.ts'; + +export async function getAirport({ icao }: { icao: string }): Promise { + const response = await getRequest(`airports/${icao}`); + return response?.json() || {}; +} + +interface GetAirportsParameters { + icaos?: string[]; + name?: string; + categories?: AirportCategory[]; + bounds?: Bounds; + metars?: boolean; + page?: number; + limit?: number; +} + +export async function getAirports({ + icaos, + name, + categories, + bounds, + metars = false, + limit = 1000, + page = 1 +}: GetAirportsParameters): Promise { + const response = await getRequest('airports', { + bounds: bounds + ? `${bounds?.northEast.lat},${bounds?.northEast.lon},${bounds?.southWest.lat},${bounds?.southWest.lon}` + : undefined, + categories: categories ?? undefined, + icaos: icaos ?? undefined, + name: name ?? undefined, + metars: metars ?? undefined, + limit, + page + }); + return response?.json() || { data: [] }; +} diff --git a/ui/src/lib/airport.types.ts b/ui/src/lib/airport.types.ts new file mode 100644 index 0000000..786444c --- /dev/null +++ b/ui/src/lib/airport.types.ts @@ -0,0 +1,93 @@ +import { Metar } from './metar.types'; + +export enum AirportCategory { + SMALL = 'small_airport', + MEDIUM = 'medium_airport', + LARGE = 'large_airport', + HELIPORT = 'heliport', + BALLOONPORT = 'balloon_port', + CLOSED = 'closed', + SEAPLANE = 'seaplane_base', + UNKNOWN = 'unknown' +} + +export function airportCategoryToText(category: AirportCategory): string { + switch (category) { + case AirportCategory.SMALL: + return 'Small'; + case AirportCategory.MEDIUM: + return 'Medium'; + case AirportCategory.LARGE: + return 'Large'; + case AirportCategory.HELIPORT: + return 'Helipad'; + case AirportCategory.CLOSED: + return 'Closed'; + case AirportCategory.SEAPLANE: + return 'Seaplane Base'; + case AirportCategory.BALLOONPORT: + return 'Balloon Port'; + default: + return 'Unknown'; + } +} + +export enum AirportOrderField { + ICAO = 'icao', + NAME = 'name', + CATEGORY = 'category', + CONTINENT = 'continent', + ISO_COUNTRY = 'iso_country', + ISO_REGION = 'iso_region', + MUNICIPALITY = 'municipality' +} + +export interface Bounds { + northEast: Coordinate; + southWest: Coordinate; +} + +export interface Coordinate { + lat: number; + lon: number; +} + +export interface Airport { + icao: string; + iata: string; + local: string; + name: string; + category: AirportCategory; + iso_country: string; + iso_region: string; + municipality: string; + elevation_ft: number; + latitude: number; + longitude: number; + has_tower: boolean; + has_beacon: boolean; + has_metar: boolean; + public: boolean; + runways: Runway[]; + frequencies: Frequency[]; + latest_metar?: Metar; +} + +export interface Runway { + id: string; + length_ft: number; + width_ft: number; + surface: string; +} + +export interface Frequency { + id: string; + frequency_mhz: number; +} + +export interface GetAirportsResponse { + data: Airport[]; + limit: number; + page: number; + total: number; +} diff --git a/ui/src/lib/index.ts b/ui/src/lib/index.ts new file mode 100644 index 0000000..470dbee --- /dev/null +++ b/ui/src/lib/index.ts @@ -0,0 +1,72 @@ +// const serviceHost = process.env.SERVICE_HOST || 'http://localhost'; +// const servicePort = process.env.SERVICE_PORT || 5000;' +// const baseURL = `${serviceHost}:${servicePort}`; +const baseUrl = 'http://localhost:5000'; + +export async function getRequest(endpoint: string, params: Record = {}): Promise { + Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]); + const urlParams = new URLSearchParams(params); + const url = urlParams && urlParams.size > 0 ? `${baseUrl}/${endpoint}?${urlParams}` : `${baseUrl}/${endpoint}`; + return await fetch(url, { + method: 'GET', + credentials: 'include' + }); +} + +interface PostOptions { + headers?: Record; + type?: 'json' | 'form'; +} + +export async function postRequest(endpoint: string, body?: any, options?: PostOptions): Promise { + const url = `${baseUrl}/${endpoint}`; + let response; + if (body && (!options?.type || options.type === 'json')) { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify(body) + }); + } else { + response = await fetch(url, { + method: 'POST', + credentials: 'include', + body + }); + } + return response; +} + +export async function putRequest(endpoint: string, body?: any, options?: PostOptions): Promise { + const url = `${baseUrl}/${endpoint}`; + let response; + if (body && (!options?.type || options.type === 'json')) { + response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify(body) + }); + } else { + response = await fetch(url, { + method: 'PUT', + credentials: 'include', + body + }); + } + return response; +} + +export async function deleteRequest(endpoint: string): Promise { + const url = `${baseUrl}/${endpoint}`; + const response = await fetch(url, { + method: 'DELETE', + credentials: 'include' + }); + return response; +} diff --git a/ui/src/lib/metar.types.ts b/ui/src/lib/metar.types.ts new file mode 100644 index 0000000..848f91f --- /dev/null +++ b/ui/src/lib/metar.types.ts @@ -0,0 +1,43 @@ +export interface SkyCondition { + sky_cover: string; + cloud_base_ft_agl: number; +} + +export interface QualityControlFlags { + auto: boolean; + auto_station_without_precipitation: boolean; + auto_station_with_precipication: boolean; + maintenance_indicator_on: boolean; + corrected: boolean; +} + +export interface RunwayVisualRange { + runway: string; + visibility_ft: string; + variable_visibility_high_ft: string; + variable_visibility_low_ft: string; +} + +export interface Metar { + raw_text: string; + station_id: string; + observation_time: string; + temp_c: number; + dewpoint_c: number; + wind_dir_degrees: string; + wind_speed_kt: number; + wind_gust_kt: number; + variable_wind_dir_degrees: string; + visibility_statute_mi: string; + runway_visual_range: RunwayVisualRange[]; + altim_in_hg: number; + sea_level_pressure_mb: number; + quality_control_flags: QualityControlFlags; + weather_phenomena: string[]; + sky_condition: SkyCondition[]; + flight_category: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'; + three_hr_pressure_tendency_mb: number; + max_t_c: number; + min_t_c: number; + precip_in: number; +}