diff --git a/Cargo.lock b/Cargo.lock index a711366..8e9076c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -450,14 +450,19 @@ dependencies = [ "chrono", "envconfig", "futures", + "futures-util", "gql_client", "hex", "infer", + "influxdb2", + "influxdb2-structmap", "lapin", "lazy_static", "once_cell", "prisma-client-rust", "prisma-client-rust-cli", + "prometheus", + "prometheus-http-query", "rand 0.8.5", "redis", "reqwest 0.12.4", @@ -1602,6 +1607,18 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "enum-as-inner" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ffccbb6966c05b32ef8fbac435df276c4ae4d3dc55a8cd0eb9745e6c12f546a" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.65", +] + [[package]] name = "enumflags2" version = "0.7.9" @@ -2000,6 +2017,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "go-parse-duration" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558b88954871f5e5b2af0e62e2e176c8bde7a6c2c4ed41b13d138d96da2e2cbd" + [[package]] name = "gql_client" version = "1.0.7" @@ -2093,6 +2116,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "hermit-abi" version = "0.3.9" @@ -2377,6 +2406,55 @@ dependencies = [ "cfb", ] +[[package]] +name = "influxdb2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1618728e8679dbb4af70d418bc5599bde2b12da82c03b689f96f8a71dd1ce2c" +dependencies = [ + "base64 0.13.1", + "bytes", + "chrono", + "csv", + "fallible-iterator", + "futures", + "go-parse-duration", + "influxdb2-derive", + "influxdb2-structmap", + "ordered-float 3.9.2", + "parking_lot 0.11.2", + "reqwest 0.11.27", + "secrecy", + "serde", + "serde_json", + "snafu", + "url", +] + +[[package]] +name = "influxdb2-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990f899841aa30130fc06f7938e3cc2cbc3d5b92c03fd4b5d79a965045abcf16" +dependencies = [ + "itertools 0.10.5", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", +] + +[[package]] +name = "influxdb2-structmap" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1408e712051787357e99ff732e44e8833e79cea0fabc9361018abfbff72b6265" +dependencies = [ + "chrono", + "num-traits", + "ordered-float 3.9.2", +] + [[package]] name = "inout" version = "0.1.3" @@ -2476,7 +2554,7 @@ version = "0.1.0" source = "git+https://github.com/Brendonovich/prisma-engines?tag=pcr-0.6.10#c4aeef82dbae310e974d6122160c7e3b5fb6df53" dependencies = [ "backtrace", - "heck", + "heck 0.3.3", "serde", "toml", ] @@ -2779,7 +2857,7 @@ dependencies = [ "indexmap 1.9.3", "metrics 0.18.1", "num_cpus", - "ordered-float", + "ordered-float 2.10.1", "parking_lot 0.11.2", "quanta", "radix_trie", @@ -3152,6 +3230,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-float" +version = "3.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1e1c390732d15f1d48471625cd92d154e66db2c56645e29a9cd26f4699f72dc" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.4.3" @@ -3681,6 +3768,66 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "procfs" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "731e0d9356b0c25f16f33b5be79b1c57b562f141ebfcdb0ad8ac2c13a24293b4" +dependencies = [ + "bitflags 2.5.0", + "hex", + "lazy_static", + "procfs-core", + "rustix 0.38.34", +] + +[[package]] +name = "procfs-core" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" +dependencies = [ + "bitflags 2.5.0", + "hex", +] + +[[package]] +name = "prometheus" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "libc", + "memchr", + "parking_lot 0.12.2", + "procfs", + "protobuf", + "thiserror", +] + +[[package]] +name = "prometheus-http-query" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fcebfa99f03ae51220778316b37d24981e36322c82c24848f48c5bd0f64cbdb" +dependencies = [ + "enum-as-inner", + "mime", + "reqwest 0.12.4", + "serde", + "time", + "url", +] + +[[package]] +name = "protobuf" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" + [[package]] name = "psl" version = "0.1.0" @@ -4477,6 +4624,15 @@ dependencies = [ "sha2 0.10.8", ] +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.0" @@ -4521,7 +4677,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" dependencies = [ - "ordered-float", + "ordered-float 2.10.1", "serde", ] @@ -4746,6 +4902,27 @@ dependencies = [ "version_check", ] +[[package]] +name = "snafu" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab12d3c261b2308b0d80c26fffb58d17eba81a4be97890101f416b478c79ca7" +dependencies = [ + "doc-comment", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1508efa03c362e23817f96cde18abed596a25219a8b2c66e8db33c03543d315b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "socket2" version = "0.4.10" diff --git a/Cargo.toml b/Cargo.toml index ef9e17e..5c021f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,11 @@ sha1 = "0.10.6" hex = "0.4.3" gql_client = "1.0.7" actix-cors = "0.7.0" +prometheus = { version = "0.13.4", features = ["process"] } +futures-util = "0.3.30" +prometheus-http-query = "0.8.3" +influxdb2 = "0.5.1" +influxdb2-structmap = "0.2.0" [profile.release] lto = true diff --git a/LICENSE b/LICENSE index 7c77bb2..209b968 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -MIT License Copyright (c) 2022 Dustin Rouillard +MIT License Copyright (c) 2024 Dustin Rouillard Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/README.md b/README.md index 70beba3..aa23e96 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ -# Dustin API +# dstn.to API There is probably a lot of better ways to do many of the things I've done here, but this is one of the first things I've done in rust, make suggestions if you see anything done weirdly! +Built with actix-web, makes use of prisma to handle queries to a postgresql database and uses valkey/redis for storage of tokens and caching data. + ## Used for -- Spotify History and Now Playing API / Queue Messages -- File and Screenshot Uploads +- Spotify History and Now Playing API / Provides realtime queue for [gateway](https://github.com/dustinrouillard/dustin-gateway) +- File and Screenshot Uploads (Multipart uploads to an s3 bucket) - Github pinned repositories -- Blog System for Personal Site \ No newline at end of file +- Blog System for [Personal Site](https://github.com/dustinrouillard/personal-site) +- Local weather (This just proxies my [weather worker](https://github.com/dustinrouillard/weather-worker)) +- Analytics tracking (commands per day, etc) +- Prometheus metrics (API route and process metrics) \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index d861a51..5669d1e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,9 @@ pub struct Config { #[envconfig(from = "LISTEN_PORT", default = "8080")] pub listen_port: u16, + #[envconfig(from = "METRICS_LISTEN_PORT", default = "8081")] + pub metrics_listen_port: u16, + #[envconfig( from = "SPOTIFY_CLIENT_ID", default = "01ba26764aca4594a26f4cc59cd3f01f" @@ -64,4 +67,28 @@ pub struct Config { #[envconfig(from = "GITHUB_PAT", default = "")] pub github_pat: String, + + #[envconfig(from = "WEATHER_COORDS", default = "37.8283/-96.5795")] + pub weather_coords: String, + + #[envconfig( + from = "PROMETHEUS_HOST", + default = "https://prometheus.monit.kush/" + )] + pub prometheus_host: String, + + #[envconfig(from = "INFLUXDB_TOKEN", default = "")] + pub influxdb_token: String, + + #[envconfig( + from = "INFLUXDB_HOST", + default = "http://influxdb.kube-system" + )] + pub influxdb_host: String, + + #[envconfig(from = "INFLUXDB_ORG", default = "lab")] + pub influxdb_org: String, + + #[envconfig(from = "INFLUXDB_BUCKET", default = "api")] + pub influxdb_bucket: String, } diff --git a/src/connectivity/influxdb.rs b/src/connectivity/influxdb.rs new file mode 100644 index 0000000..21d007d --- /dev/null +++ b/src/connectivity/influxdb.rs @@ -0,0 +1,23 @@ +use envconfig::Envconfig; +use influxdb2::Client; + +use crate::config::Config; + +#[derive(Clone)] +pub struct InfluxManager { + pub client: Client, +} + +impl InfluxManager { + pub async fn new() -> Self { + let config = Config::init_from_env().unwrap(); + let client = influxdb2::Client::new( + config.influxdb_host, + config.influxdb_org, + config.influxdb_token, + ); + + tracing::info!("Connected to influxdb"); + Self { client } + } +} diff --git a/src/connectivity/metrics.rs b/src/connectivity/metrics.rs new file mode 100644 index 0000000..277e31b --- /dev/null +++ b/src/connectivity/metrics.rs @@ -0,0 +1,55 @@ +use prometheus::{ + Histogram, HistogramOpts, IntCounterVec, Opts, Registry, +}; + +use lazy_static::lazy_static; + +#[warn(dead_code)] +#[derive(Clone)] +pub struct ApiMetrics {} + +lazy_static! { + pub static ref REGISTRY: Registry = Registry::new(); + pub static ref INCOMING_REQUESTS: IntCounterVec = IntCounterVec::new( + Opts::new("dstn_api_http_requests", "Incoming Requests"), + &["status"] + ) + .expect("metric can be created"); + pub static ref COMMANDS_RUN: IntCounterVec = IntCounterVec::new( + Opts::new("dstn_api_commands_run", "Commands Run"), + &["command", "action"] + ) + .expect("metric can be created"); + pub static ref RESPONSE_TIME_COLLECTOR: Histogram = + Histogram::with_opts(HistogramOpts::new( + "dstn_api_response_time", + "Response Times" + )) + .expect("metric can be created"); +} + +impl ApiMetrics { + pub async fn new() -> Result { + REGISTRY + .register(Box::new(COMMANDS_RUN.clone())) + .expect("collector can be registered"); + + REGISTRY + .register(Box::new(INCOMING_REQUESTS.clone())) + .expect("collector can be registered"); + + REGISTRY + .register(Box::new(RESPONSE_TIME_COLLECTOR.clone())) + .expect("collector can be registered"); + + Ok(Self {}) + } + + pub fn track_request(status_code: String, response_time: f64) { + RESPONSE_TIME_COLLECTOR.observe(response_time); + + INCOMING_REQUESTS + .with_label_values(&[&status_code.to_string()]) + .inc() + } +} diff --git a/src/connectivity/middleware.rs b/src/connectivity/middleware.rs new file mode 100644 index 0000000..4a2459f --- /dev/null +++ b/src/connectivity/middleware.rs @@ -0,0 +1,66 @@ +use std::future::{ready, Ready}; + +use actix_web::{ + dev::{ + forward_ready, Service, ServiceRequest, ServiceResponse, Transform, + }, + Error, +}; +use futures_util::future::LocalBoxFuture; + +use crate::connectivity::metrics::ApiMetrics; + +pub struct ResponseMeta; + +impl Transform for ResponseMeta +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = ResponseMetaMiddleware; + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(ResponseMetaMiddleware { service })) + } +} + +pub struct ResponseMetaMiddleware { + service: S, +} + +impl Service for ResponseMetaMiddleware +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = + LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + let start_time = chrono::Utc::now().time(); + let fut = self.service.call(req); + + Box::pin(async move { + let res = fut.await?; + let end_time = chrono::Utc::now().time(); + + let status_code = + format!("{:.1$}xx", res.status().as_u16() / 100, 0); + let response_time = (end_time - start_time).num_milliseconds(); + + ApiMetrics::track_request(status_code.into(), response_time as f64); + + Ok(res) + }) + } +} diff --git a/src/connectivity/mod.rs b/src/connectivity/mod.rs index 2687dee..1ff2cc9 100644 --- a/src/connectivity/mod.rs +++ b/src/connectivity/mod.rs @@ -1,4 +1,8 @@ +pub mod influxdb; +pub mod metrics; +pub mod middleware; pub mod prisma; +pub mod prometheus; pub mod rabbit; pub mod s3; pub mod valkey; diff --git a/src/connectivity/prometheus.rs b/src/connectivity/prometheus.rs new file mode 100644 index 0000000..92f346c --- /dev/null +++ b/src/connectivity/prometheus.rs @@ -0,0 +1,23 @@ +use std::io::Error; + +use prometheus_http_query::Client; + +use envconfig::Envconfig; + +use crate::config::Config; + +#[warn(dead_code)] +#[derive(Clone)] +pub struct PrometheusClient { + pub client: Client, +} + +impl PrometheusClient { + pub async fn new() -> Result { + let config = Config::init_from_env().unwrap(); + + let client = Client::try_from(config.prometheus_host).unwrap(); + + Ok(Self { client }) + } +} diff --git a/src/main.rs b/src/main.rs index 34d9920..b249924 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,9 +10,11 @@ use actix_cors::Cors; use actix_multipart::form::MultipartFormConfig; use actix_web::{middleware, web, App, HttpServer}; use connectivity::{ + influxdb::InfluxManager, prometheus::PrometheusClient, rabbit::RabbitManager, s3::S3Manager, valkey::ValkeyManager, }; use envconfig::Envconfig; +use futures::future; use tokio::time; use tracing_actix_web::TracingLogger; use tracing_subscriber::{ @@ -28,6 +30,8 @@ pub struct ServerState { pub rabbit: RabbitManager, pub prisma: PrismaClient, pub s3: S3Manager, + pub prometheus: PrometheusClient, + pub influxdb: InfluxManager, } #[tokio::main] @@ -35,7 +39,7 @@ async fn main() -> Result<(), Box> { let config = Config::init_from_env().unwrap(); let env_filter = - EnvFilter::try_from_default_env().unwrap_or(EnvFilter::new("info")); + EnvFilter::try_from_env("DSTN_LOG").unwrap_or(EnvFilter::new("info")); let fmt_layer = fmt::layer().with_target(false); let subscriber = Registry::default().with(env_filter).with(fmt_layer); tracing::subscriber::set_global_default(subscriber) @@ -43,15 +47,16 @@ async fn main() -> Result<(), Box> { let valkey = connectivity::valkey::ValkeyManager::new().await; let rabbit = connectivity::rabbit::RabbitManager::new().await; + let _analytics = connectivity::metrics::ApiMetrics::new().await.unwrap(); + let s3 = connectivity::s3::S3Manager::new().await.unwrap(); + let prometheus = connectivity::prometheus::PrometheusClient::new() + .await + .unwrap(); - let prisma = PrismaClient::_builder().build().await.unwrap(); + let influxdb = connectivity::influxdb::InfluxManager::new().await; - tracing::info!( - "Starting HTTP Server on {}:{}", - config.listen_host, - config.listen_port - ); + let prisma = PrismaClient::_builder().build().await.unwrap(); if config.env == "dev" { tracing::info!("Running in DEV mode"); @@ -62,6 +67,8 @@ async fn main() -> Result<(), Box> { rabbit, prisma, s3, + prometheus, + influxdb, }); let data_http = web::Data::clone(&data); @@ -79,7 +86,7 @@ async fn main() -> Result<(), Box> { }); } - HttpServer::new(move || { + let api_server = HttpServer::new(move || { let cors = Cors::default() .allowed_origin_fn(|origin, _req_head| { origin.as_bytes().ends_with(b".dstn.to") @@ -98,10 +105,14 @@ async fn main() -> Result<(), Box> { .wrap(cors) .wrap(middleware::NormalizePath::default()) .wrap(TracingLogger::default()) + .wrap(connectivity::middleware::ResponseMeta) .default_service(web::to(services::base::index)) + .service(services::base::health) .service( web::scope("/v2") .service(services::base::health) + .service(services::analytics::factory::analytics_factory()) + .service(services::weather::factory::weather_factory()) .service(services::uploads::factory::uploads_factory()) .service(services::spotify::factory::spotify_factory()) .service(services::github::factory::github_factory()) @@ -109,9 +120,30 @@ async fn main() -> Result<(), Box> { ) }) .bind(((config.listen_host).to_owned(), config.listen_port))? - .run() - .await - .unwrap(); + .run(); + + tracing::info!( + "Started API Server on {}:{}", + config.listen_host, + config.listen_port + ); + + let metrics_server = HttpServer::new(move || { + App::new() + .default_service(web::to(services::base::index)) + .service(services::base::get_metrics) + .service(services::base::health) + }) + .bind(((config.listen_host).to_owned(), config.metrics_listen_port))? + .run(); + + tracing::info!( + "Started metrics server on {}:{}", + config.listen_host, + config.metrics_listen_port + ); + + future::try_join(api_server, metrics_server).await?; Ok(()) } diff --git a/src/services/analytics/factory.rs b/src/services/analytics/factory.rs new file mode 100644 index 0000000..feb716d --- /dev/null +++ b/src/services/analytics/factory.rs @@ -0,0 +1,14 @@ +use actix_web::{web, Scope}; +use actix_web_lab::middleware::from_fn; + +use crate::services; + +pub fn analytics_factory() -> Scope { + web::scope("/analytics") + .service(services::analytics::routes::get_analytics) + .service( + web::scope("") + .wrap(from_fn(services::uploads::middleware::uploads_auth_mw)) + .service(services::analytics::routes::track_command), + ) +} diff --git a/src/services/analytics/mod.rs b/src/services/analytics/mod.rs new file mode 100644 index 0000000..2527156 --- /dev/null +++ b/src/services/analytics/mod.rs @@ -0,0 +1,2 @@ +pub mod factory; +pub mod routes; diff --git a/src/services/analytics/routes.rs b/src/services/analytics/routes.rs new file mode 100644 index 0000000..967e44f --- /dev/null +++ b/src/services/analytics/routes.rs @@ -0,0 +1,98 @@ +use actix_web::{get, http::Error, post, web, HttpRequest, HttpResponse}; +use chrono::Utc; +use envconfig::Envconfig; +use futures::stream; +use influxdb2::{ + models::{DataPoint, Query}, + FromDataPoint, +}; +use serde_json::json; + +use crate::{config::Config, ServerState}; + +#[derive(Debug, FromDataPoint, Default)] +struct QueryResult { + value: i64, +} + +#[get("")] +async fn get_analytics( + state: web::Data, +) -> Result { + let influxdb = &state.influxdb; + let config = Config::init_from_env().unwrap(); + + let day_query = Query::new(format!( + "from(bucket: \"{}\") + |> range(start: -24h) + |> filter(fn: (r) => r._measurement == \"commands\") + |> count() + ", + config.influxdb_bucket + )); + let day_result: Vec = influxdb + .client + .query::(Some(day_query)) + .await + .unwrap(); + let day_count = day_result.first().map_or(0, |r| r.value); + + let week_query = Query::new(format!( + "from(bucket: \"{}\") + |> range(start: -7d) + |> filter(fn: (r) => r._measurement == \"commands\") + |> count() + ", + config.influxdb_bucket + )); + let week_result: Vec = influxdb + .client + .query::(Some(week_query)) + .await + .unwrap(); + let week_count = week_result.first().map_or(0, |r| r.value); + + Ok(HttpResponse::Ok().json(json!( + { + "analytics": { + "commands": { + "day": day_count, + "week": week_count + }, + } + } + ))) +} + +#[post("/commands")] +async fn track_command( + req: HttpRequest, + state: web::Data, +) -> Result { + let influxdb = &state.influxdb; + let config = Config::init_from_env().unwrap(); + + let command_name = match req.headers().get("command-name") { + Some(value) => value.to_str().unwrap().to_string(), + None => "".to_string(), + }; + + let action_name = match req.headers().get("action-name") { + Some(value) => value.to_str().unwrap().to_string(), + None => "".to_string(), + }; + + let command_log = DataPoint::builder("commands") + .field("command", command_name) + .field("action", action_name) + .timestamp(Utc::now().timestamp_nanos_opt().unwrap()) + .build() + .unwrap(); + + let _ = influxdb + .client + .write(&config.influxdb_bucket, stream::iter(vec![command_log])) + .await; + + Ok(HttpResponse::NoContent().finish()) +} diff --git a/src/services/base.rs b/src/services/base.rs index 2a771e0..0e74e50 100644 --- a/src/services/base.rs +++ b/src/services/base.rs @@ -1,15 +1,48 @@ use actix_web::{get, http::Error, HttpResponse}; +use prometheus::Encoder; use serde_json::json; pub async fn index() -> Result { - Ok( - HttpResponse::NotFound() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "route_not_found"}).to_string()), - ) + Ok(HttpResponse::NotFound().json(json!({"code": "route_not_found"}))) } #[get("/health")] async fn health() -> Result { Ok(HttpResponse::NoContent().finish()) } + +#[get("/metrics")] +async fn get_metrics() -> Result { + let registry = &crate::connectivity::metrics::REGISTRY; + + let encoder = prometheus::TextEncoder::new(); + + let mut buffer = Vec::new(); + if let Err(e) = encoder.encode(®istry.gather(), &mut buffer) { + eprintln!("could not encode custom metrics: {}", e); + }; + let mut res = match String::from_utf8(buffer.clone()) { + Ok(v) => v, + Err(e) => { + eprintln!("custom metrics could not be from_utf8'd: {}", e); + String::default() + } + }; + buffer.clear(); + + let mut buffer = Vec::new(); + if let Err(e) = encoder.encode(&prometheus::gather(), &mut buffer) { + eprintln!("could not encode prometheus metrics: {}", e); + }; + res.push_str("\n"); + res.push_str(&match String::from_utf8(buffer.clone()) { + Ok(v) => v, + Err(e) => { + eprintln!("prometheus metrics could not be from_utf8'd: {}", e); + String::default() + } + }); + buffer.clear(); + + Ok(HttpResponse::Ok().body(res)) +} diff --git a/src/services/blog/assets.rs b/src/services/blog/assets.rs index 053f8ca..1d5b1be 100644 --- a/src/services/blog/assets.rs +++ b/src/services/blog/assets.rs @@ -45,8 +45,7 @@ async fn upload_asset_for_post( if response.status_code() != 200 { return Ok( HttpResponse::BadRequest() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "failed_upload_to_s3"}).to_string()), + .json(json!({"code": "failed_upload_to_s3"})), ); } @@ -68,22 +67,18 @@ async fn upload_asset_for_post( match asset { Err(error) if error.is_prisma_error::() => Ok( HttpResponse::BadRequest() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "asset_already_exists"}).to_string()), + .json(json!({"code": "asset_already_exists"})), ), Err(_) => { Ok( HttpResponse::BadRequest() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "failed_to_create_asset"}).to_string()), + .json(json!({"code": "failed_to_create_asset"})), ) }, Ok(asset) => Ok( HttpResponse::Ok() - .append_header(("Content-type", "application/json")) - .body( - json!({"asset": { "hash": hash, "post_id": post_id.to_string(), "file_type": ext, "file_size": size, "upload_date": asset.upload_date }}) - .to_string(), + .json( + json!({"asset": { "hash": hash, "post_id": post_id.to_string(), "file_type": ext, "file_size": size, "upload_date": asset.upload_date }}), ), ), } @@ -117,11 +112,7 @@ async fn get_assets_for_post( }) .collect(); - Ok( - HttpResponse::Ok() - .append_header(("Content-type", "application/json")) - .body(json!({"assets": assets}).to_string()), - ) + Ok(HttpResponse::Ok().json(json!({"assets": assets}))) } #[delete("/posts/{id}/assets/{hash}")] @@ -155,16 +146,9 @@ async fn delete_asset_for_post( .await; if res.unwrap().status_code() != 204 { - return Ok( - HttpResponse::BadRequest() - .append_header(("Content-type", "application/json")) - .body( - json!({ - "code": "failed_to_delete_from_s3" - }) - .to_string(), - ), - ); + return Ok(HttpResponse::BadRequest().json(json!({ + "code": "failed_to_delete_from_s3" + }))); } let _ = prisma @@ -175,26 +159,12 @@ async fn delete_asset_for_post( Ok(HttpResponse::NoContent().finish()) } - None => Ok( - HttpResponse::NotFound() - .append_header(("Content-type", "application/json")) - .body( - json!({ - "code": "asset_not_found" - }) - .to_string(), - ), - ), + None => Ok(HttpResponse::NotFound().json(json!({ + "code": "asset_not_found" + }))), }, - Err(_) => Ok( - HttpResponse::BadRequest() - .append_header(("Content-type", "application/json")) - .body( - json!({ - "code": "error_with_asset_lookup" - }) - .to_string(), - ), - ), + Err(_) => Ok(HttpResponse::BadRequest().json(json!({ + "code": "error_with_asset_lookup" + }))), } } diff --git a/src/services/blog/auth.rs b/src/services/blog/auth.rs index 2350810..652d342 100644 --- a/src/services/blog/auth.rs +++ b/src/services/blog/auth.rs @@ -46,8 +46,7 @@ async fn login( if !valid { return Ok( HttpResponse::Unauthorized() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "invalid_authentication"}).to_string()), + .json(json!({"code": "invalid_authentication"})), ); } @@ -65,22 +64,16 @@ async fn login( let response = json!({ "user": { "id": user.id, "username": user.username, "display_name": user.display_name, }, "session": { "token": session_token } }); - Ok( - HttpResponse::Ok() - .append_header(("Content-type", "application/json")) - .body(response.to_string()), - ) + Ok(HttpResponse::Ok().json(response)) } None => Ok( HttpResponse::Unauthorized() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "invalid_username"}).to_string()), + .json(json!({"code": "invalid_username"})), ), }, Err(_) => Ok( HttpResponse::Unauthorized() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "failed_to_lookup_user"}).to_string()), + .json(json!({"code": "failed_to_lookup_user"})), ), } } @@ -90,20 +83,13 @@ async fn get_user(req: HttpRequest) -> Result { let exts = req.extensions_mut(); let user = exts.get::().unwrap(); - Ok( - HttpResponse::Ok() - .append_header(("Content-type", "application/json")) - .body( - json!({ - "user": { - "id": user.id, - "username": user.username, - "display_name": user.display_name, - } - }) - .to_string(), - ), - ) + Ok(HttpResponse::Ok().json(json!({ + "user": { + "id": user.id, + "username": user.username, + "display_name": user.display_name, + } + }))) } #[patch("/me")] @@ -142,20 +128,13 @@ async fn update_user( .await .unwrap(); - Ok( - HttpResponse::Ok() - .append_header(("Content-type", "application/json")) - .body( - json!({ - "user": { - "id": user_record.id, - "username": user_record.username, - "display_name": user_record.display_name, - } - }) - .to_string(), - ), - ) + Ok(HttpResponse::Ok().json(json!({ + "user": { + "id": user_record.id, + "username": user_record.username, + "display_name": user_record.display_name, + } + }))) } #[patch("/auth")] @@ -172,8 +151,7 @@ async fn change_password( if body.password == body.new_password { return Ok( HttpResponse::BadRequest() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "old_and_new_passwords_match"}).to_string()), + .json(json!({"code": "old_and_new_passwords_match"})), ); } @@ -183,8 +161,7 @@ async fn change_password( if !valid { return Ok( HttpResponse::Unauthorized() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "current_password_invalid"}).to_string()), + .json(json!({"code": "current_password_invalid"})), ); } diff --git a/src/services/blog/middleware.rs b/src/services/blog/middleware.rs index 5a69bee..7bcce4d 100644 --- a/src/services/blog/middleware.rs +++ b/src/services/blog/middleware.rs @@ -32,8 +32,7 @@ pub async fn blog_admin_auth_mw( ServiceResponse::new( req.request().to_owned(), HttpResponse::Unauthorized() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "missing_authentication"}).to_string()), + .json(json!({"code": "missing_authentication"})), ) .map_into_boxed_body(), ); @@ -50,11 +49,7 @@ pub async fn blog_admin_auth_mw( ServiceResponse::new( req.request().to_owned(), HttpResponse::Unauthorized() - .append_header(("Content-type", "application/json")) - .body( - json!({"code": "invalid_authentication_state"}) - .to_string(), - ), + .json(json!({"code": "invalid_authentication_state"})), ) .map_into_boxed_body(), ); @@ -91,12 +86,9 @@ pub async fn blog_admin_auth_mw( return Ok( ServiceResponse::new( req.request().to_owned(), - HttpResponse::Unauthorized() - .append_header(("Content-type", "application/json")) - .body( - json!({"code": "invalid_authentication_user"}) - .to_string(), - ), + HttpResponse::Unauthorized().json( + json!({"code": "invalid_authentication_user"}), + ), ) .map_into_boxed_body(), ) @@ -107,11 +99,7 @@ pub async fn blog_admin_auth_mw( ServiceResponse::new( req.request().to_owned(), HttpResponse::Unauthorized() - .append_header(("Content-type", "application/json")) - .body( - json!({"code": "invalid_authentication_user"}) - .to_string(), - ), + .json(json!({"code": "invalid_authentication_user"})), ) .map_into_boxed_body(), ); diff --git a/src/services/blog/posts.rs b/src/services/blog/posts.rs index 8e792ae..70466e9 100644 --- a/src/services/blog/posts.rs +++ b/src/services/blog/posts.rs @@ -7,7 +7,7 @@ use actix_web::{ web::{self}, HttpResponse, }; -use prisma_client_rust::Direction; +use prisma_client_rust::{operator::or, Direction}; use serde_json::json; use crate::{ @@ -56,11 +56,7 @@ async fn get_posts( }) .collect(); - Ok( - HttpResponse::Ok() - .append_header(("Content-type", "application/json")) - .body(json!({"posts": posts}).to_string()), - ) + Ok(HttpResponse::Ok().json(json!({"posts": posts}))) } #[get("/posts")] @@ -104,11 +100,7 @@ async fn get_all_posts( }) .collect(); - Ok( - HttpResponse::Ok() - .append_header(("Content-type", "application/json")) - .body(json!({"posts": posts}).to_string()), - ) + Ok(HttpResponse::Ok().json(json!({"posts": posts}))) } #[post("/posts")] @@ -149,84 +141,75 @@ async fn create_post( let post = prisma.blog_posts().create(post_params).exec().await; match post { - Ok(post) => Ok( - HttpResponse::Created() - .append_header(("Content-type", "application/json")) - .body( - json!({ - "post": { - "id": post.id, - "title": post.title, - "slug": post.slug, - "description": post.description, - "image": post.image, - "visibility": post.visibility, - "tags": post.tags, - "body": post.body, - "created_at": post.created_at, - "published_at": post.published_at, - } - }) - .to_string(), - ), - ), + Ok(post) => Ok(HttpResponse::Created().json(json!({ + "post": { + "id": post.id, + "title": post.title, + "slug": post.slug, + "description": post.description, + "image": post.image, + "visibility": post.visibility, + "tags": post.tags, + "body": post.body, + "created_at": post.created_at, + "published_at": post.published_at, + } + }))), Err(_) => { return Ok( HttpResponse::InternalServerError() - .append_header(("Content-type", "application/json")) - .body( - json!({"code": "uncaught_error_creating_post"}).to_string(), - ), + .json(json!({"code": "uncaught_error_creating_post"})), ); } } } -#[get("/posts/{id}")] +#[get("/posts/{id_or_slug}")] async fn get_post( - id: web::Path, + id_or_slug: web::Path, state: web::Data, ) -> Result { let prisma = &mut &state.prisma; + + let post_query = match &id_or_slug.parse::() { + Ok(_) => blog_posts::id::equals(id_or_slug.to_string()), + Err(_) => blog_posts::slug::equals(id_or_slug.to_string()), + }; + let post = prisma .blog_posts() - .find_first(vec![blog_posts::id::equals(id.to_string())]) + .find_first(vec![ + post_query, + or(vec![ + blog_posts::visibility::equals("public".to_string()), + blog_posts::visibility::equals("unlisted".to_string()), + ]), + ]) .exec() .await; match post { Ok(post) => match post { - Some(post) => Ok( - HttpResponse::Ok() - .append_header(("Content-type", "application/json")) - .body( - json!({ - "post": { - "id": post.id, - "title": post.title, - "slug": post.slug, - "description": post.description, - "image": post.image, - "visibility": post.visibility, - "tags": post.tags, - "body": post.body, - "published_at": post.published_at, - } - }) - .to_string(), - ), - ), + Some(post) => Ok(HttpResponse::Ok().json(json!({ + "post": { + "id": post.id, + "title": post.title, + "slug": post.slug, + "description": post.description, + "image": post.image, + "visibility": post.visibility, + "tags": post.tags, + "body": post.body, + "published_at": post.published_at, + } + }))), None => Ok( - HttpResponse::NotFound() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "post_not_found"}).to_string()), + HttpResponse::NotFound().json(json!({"code": "post_not_found"})), ), }, Err(_) => { return Ok( - HttpResponse::NotFound() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "post_not_found"}).to_string()), + HttpResponse::NotFound().json(json!({"code": "post_not_found"})), ); } } @@ -252,8 +235,7 @@ async fn update_post( None => { return Ok( HttpResponse::NotFound() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "post_not_found"}).to_string()), + .json(json!({"code": "post_not_found"})), ); } }; @@ -284,37 +266,31 @@ async fn update_post( match post_update { Err(_) => Ok( - HttpResponse::NotFound() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "post_not_found"}).to_string()), + HttpResponse::NotFound().json(json!({"code": "post_not_found"})), ), Ok(post) => Ok( - HttpResponse::Ok() - .append_header(("Content-type", "application/json")) - .body( - json!({ - "post": { - "id": post.id, - "title": post.title, - "slug": post.slug, - "description": post.description, - "image": post.image, - "visibility": post.visibility, - "tags": post.tags, - "body": post.body, - "created_at": post.created_at, - "published_at": post.published_at, - } - }) - .to_string(), - ), + HttpResponse::Ok().body( + json!({ + "post": { + "id": post.id, + "title": post.title, + "slug": post.slug, + "description": post.description, + "image": post.image, + "visibility": post.visibility, + "tags": post.tags, + "body": post.body, + "created_at": post.created_at, + "published_at": post.published_at, + } + }) + .to_string(), + ), ), } } Err(_) => Ok( - HttpResponse::Unauthorized() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "post_not_found"}).to_string()), + HttpResponse::Unauthorized().json(json!({"code": "post_not_found"})), ), } } @@ -338,8 +314,7 @@ async fn delete_post( None => { return Ok( HttpResponse::NotFound() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "post_not_found"}).to_string()), + .json(json!({"code": "post_not_found"})), ); } }; @@ -357,9 +332,7 @@ async fn delete_post( } Err(_) => { return Ok( - HttpResponse::NotFound() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "post_not_found"}).to_string()), + HttpResponse::NotFound().json(json!({"code": "post_not_found"})), ); } } diff --git a/src/services/github/routes.rs b/src/services/github/routes.rs index dfdf90e..deaa31e 100644 --- a/src/services/github/routes.rs +++ b/src/services/github/routes.rs @@ -91,9 +91,5 @@ async fn github_pinned( serde_json::from_str(&cached.unwrap()).unwrap() }; - Ok( - HttpResponse::Ok() - .insert_header(("Content-Type", "application/json")) - .body(json!({"repositories": response}).to_string()), - ) + Ok(HttpResponse::Ok().json(json!({"repositories": response}))) } diff --git a/src/services/mod.rs b/src/services/mod.rs index fd7671b..7f13440 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,5 +1,7 @@ +pub mod analytics; pub mod base; pub mod blog; pub mod github; pub mod spotify; pub mod uploads; +pub mod weather; diff --git a/src/services/spotify/routes.rs b/src/services/spotify/routes.rs index c05c99a..82c281c 100644 --- a/src/services/spotify/routes.rs +++ b/src/services/spotify/routes.rs @@ -31,11 +31,7 @@ async fn current( let valkey = &mut data.valkey.clone(); let json = helpers::get_playing(valkey).await; - Ok( - HttpResponse::Ok() - .insert_header(("Content-Type", "application/json")) - .body(json!({"success": true, "data": &json}).to_string()), - ) + Ok(HttpResponse::Ok().json(json!({"success": true, "data": &json}))) } #[get("/recents")] @@ -74,11 +70,7 @@ async fn recent_listens( }) .collect(); - Ok( - HttpResponse::Ok() - .insert_header(("Content-Type", "application/json")) - .body(json!({"recents": recents}).to_string()), - ) + Ok(HttpResponse::Ok().json(json!({"recents": recents}))) } #[get("/authorize")] @@ -99,11 +91,7 @@ async fn authorize( "message": "Spotify is already setup." }); - return Ok( - HttpResponse::BadRequest() - .insert_header(("Content-Type", "application/json")) - .body(json.to_string()), - ); + return Ok(HttpResponse::BadRequest().body(json.to_string())); } let config = Config::init_from_env().unwrap(); @@ -113,11 +101,7 @@ async fn authorize( let url = format!("https://accounts.spotify.com/authorize?client_id={}&response_type=code&scope={}&redirect_uri={}", config.spotify_client_id, scope, redirect_uri); let json = json!({ "url": url }); - Ok( - HttpResponse::Ok() - .append_header(("Content-type", "application/json")) - .body(json.to_string()), - ) + Ok(HttpResponse::Ok().body(json.to_string())) } #[get("/setup")] @@ -139,11 +123,7 @@ async fn setup( "message": "Spotify is already setup." }); - return Ok( - HttpResponse::BadRequest() - .insert_header(("Content-Type", "application/json")) - .body(json.to_string()), - ); + return Ok(HttpResponse::BadRequest().body(json.to_string())); } let config = Config::init_from_env().unwrap(); diff --git a/src/services/uploads/middleware.rs b/src/services/uploads/middleware.rs index bcc1584..5344d4c 100644 --- a/src/services/uploads/middleware.rs +++ b/src/services/uploads/middleware.rs @@ -27,8 +27,7 @@ pub async fn uploads_auth_mw( ServiceResponse::new( req.request().to_owned(), HttpResponse::Unauthorized() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "missing_authentication"}).to_string()), + .json(json!({"code": "missing_authentication"})), ) .map_into_boxed_body(), ); @@ -45,10 +44,7 @@ pub async fn uploads_auth_mw( ServiceResponse::new( req.request().to_owned(), HttpResponse::Unauthorized() - .append_header(("Content-type", "application/json")) - .body( - json!({"code": "invalid_authentication"}).to_string(), - ), + .json(json!({"code": "invalid_authentication"})), ) .map_into_boxed_body(), ); diff --git a/src/services/uploads/routes.rs b/src/services/uploads/routes.rs index dbf6a0e..ca6a137 100644 --- a/src/services/uploads/routes.rs +++ b/src/services/uploads/routes.rs @@ -20,8 +20,7 @@ async fn upload_to_cdn( { return Ok( HttpResponse::BadRequest() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "invalid_asset_type"}).to_string()), + .json(json!({"code": "invalid_asset_type"})), ); } @@ -34,8 +33,7 @@ async fn upload_to_cdn( if asset_type.to_string() == "images" { return Ok( HttpResponse::BadRequest() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "prohibited_file_type"}).to_string()), + .json(json!({"code": "prohibited_file_type"})), ); } @@ -69,8 +67,7 @@ async fn upload_to_cdn( if response.status_code() != 200 { return Ok( HttpResponse::BadRequest() - .append_header(("Content-type", "application/json")) - .body(json!({"code": "failed_upload_to_s3"}).to_string()), + .json(json!({"code": "failed_upload_to_s3"})), ); } @@ -83,7 +80,6 @@ async fn upload_to_cdn( Ok( HttpResponse::Ok() - .append_header(("Content-type", "application/json")) - .body(json!({"data": { "url": format!("https://{alias_url}/{hash}.{ext}") }}).to_string()), + .json(json!({"data": { "url": format!("https://{alias_url}/{hash}.{ext}") }})), ) } diff --git a/src/services/weather/factory.rs b/src/services/weather/factory.rs new file mode 100644 index 0000000..cd9471e --- /dev/null +++ b/src/services/weather/factory.rs @@ -0,0 +1,7 @@ +use crate::services; +use actix_web::{web, Scope}; + +pub fn weather_factory() -> Scope { + web::scope("/weather") + .service(services::weather::routes::get_current_weather) +} diff --git a/src/services/weather/helper.rs b/src/services/weather/helper.rs new file mode 100644 index 0000000..f1c3038 --- /dev/null +++ b/src/services/weather/helper.rs @@ -0,0 +1,32 @@ +use std::io::Error; + +use envconfig::Envconfig; + +use crate::{config::Config, structs::weather::UpstreamWeather}; + +pub async fn fetch_weather_data() -> Result { + let config = Config::init_from_env().unwrap(); + + let client = reqwest::Client::new(); + let res = client + .get(format!( + "https://weather.dstn.to/coords/{}", + config.weather_coords + )) + .send() + .await + .unwrap(); + + let status = res.status().as_u16(); + + if status != 200 { + return Err(Error::new( + std::io::ErrorKind::Other, + "weather query failed", + )); + } + + let body = res.json::().await.unwrap(); + + Ok(body) +} diff --git a/src/services/weather/mod.rs b/src/services/weather/mod.rs new file mode 100644 index 0000000..8d67660 --- /dev/null +++ b/src/services/weather/mod.rs @@ -0,0 +1,3 @@ +pub mod factory; +pub mod helper; +pub mod routes; diff --git a/src/services/weather/routes.rs b/src/services/weather/routes.rs new file mode 100644 index 0000000..f695a5e --- /dev/null +++ b/src/services/weather/routes.rs @@ -0,0 +1,35 @@ +use actix_web::{get, http::Error, HttpResponse}; +use serde_json::json; + +use crate::services::weather::helper::fetch_weather_data; + +#[get("/current")] +async fn get_current_weather() -> Result { + let weather = fetch_weather_data().await; + + match weather { + Ok(weather) => { + let temperature = format!( + "{:.0}", + (weather.temperature.current - 273.15) * 9.0 / 5.0 + 32.0 + ) + .parse::() + .unwrap(); + + let humidity = weather.humidity; + + Ok(HttpResponse::Ok().json(json!( + { + "weather": { + "temperature": temperature, + "humidity": humidity + } + } + ))) + } + Err(_) => Ok( + HttpResponse::BadRequest() + .json(json!({"code": "weather_query_failed"})), + ), + } +} diff --git a/src/structs/mod.rs b/src/structs/mod.rs index dc4b9fa..04e2d79 100644 --- a/src/structs/mod.rs +++ b/src/structs/mod.rs @@ -2,3 +2,4 @@ pub mod blog; pub mod github; pub mod spotify; pub mod uploads; +pub mod weather; diff --git a/src/structs/weather.rs b/src/structs/weather.rs new file mode 100644 index 0000000..0a51887 --- /dev/null +++ b/src/structs/weather.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct UpstreamWeather { + pub city: String, + pub temperature: Temperature, + pub humidity: i64, + pub conditions: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct Condition { + pub icon: Option, + pub code: String, + pub description: String, +} + +#[derive(Serialize, Deserialize)] +pub struct Temperature { + pub current: f64, + pub max: f64, + pub min: f64, +}