Compare commits

...

18 Commits

Author SHA1 Message Date
phundrak e08210e52d docs: clarify documentation
Publish Docker Images / coverage-and-sonar (push) Successful in 10m21s
Publish Docker Images / build-docker (push) Successful in 7m37s
Publish Docker Images / push-docker (push) Successful in 21s
2026-06-06 16:10:58 +02:00
phundrak 3679c7e8cd feat(starttls): remove opportunistic value
This commit removes the `Opportunistic` value from the struct `StartTls`.
This value was strictly equivalent to `Always` and could potentially
cause confusion.
2026-06-06 16:10:58 +02:00
phundrak b5a83f100d chore(ci): add linting, formatting check, and auditing 2026-06-06 16:10:58 +02:00
phundrak 3c86e9eb36 fix(server): fix TOCTOU race condition in tests 2026-06-06 15:49:19 +02:00
phundrak c700d65b34 style: format code
Publish Docker Images / push-docker (push) Has been cancelled
Publish Docker Images / build-docker (push) Has been cancelled
Publish Docker Images / coverage-and-sonar (push) Has been cancelled
2026-06-06 15:49:19 +02:00
phundrak 8bf2917eb7 chore(audit): deny wildcard versions in Cargo.toml 2026-06-06 15:49:19 +02:00
phundrak fc8dc805a9 feat(RateLimit): add Retry-After header for 429 errors 2026-06-06 15:49:19 +02:00
phundrak 5b6dd0c4f7 fix(health): move test to dedicated test mod 2026-06-06 15:49:19 +02:00
phundrak b29a095a38 refactor(RateLimitConfig): replace magic values with struct method 2026-06-06 15:49:19 +02:00
phundrak 85621d9364 feat(contact): sanitize user-submitted data 2026-06-06 15:49:19 +02:00
phundrak 5baa73d272 fix: typo 2026-06-06 15:33:45 +02:00
phundrak ff6aa10d91 feat(logs): only activate json or pretty logs one at a time 2026-06-06 15:33:45 +02:00
phundrak 598af596c7 refactor: simplify code 2026-06-06 15:33:45 +02:00
phundrak 9f576d7509 fix(contact): sanatize user-supplied data in logs 2026-06-06 15:33:45 +02:00
phundrak 2216d7da58 fix(logs): make tracing target consistent 2026-06-06 15:33:45 +02:00
phundrak d4fdc2f468 refactor: better value cloning 2026-06-06 15:33:33 +02:00
phundrak dcb3dc60a4 fix(RateLimit): apply rate limiting based on client IP 2026-06-06 15:33:33 +02:00
phundrak b38e6110d2 feat(settings): proper CORS in production
If the backend starts in production mode with no `frontend_url` is set,
immediately panic and stop.
2026-06-06 15:33:33 +02:00
13 changed files with 241 additions and 145 deletions
+14 -2
View File
@@ -40,9 +40,21 @@ jobs:
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
skipPush: ${{ github.event_name == 'pull_request' }}
- name: Format Check
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just format-check
- name: Audit
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just audit
- name: Lint
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just lint-report
- name: Coverage
run: |
nix develop --no-pure-eval --accept-flake-config --command just coverage
shell: bash -c "nix develop --no-pure-eval --accept-flake-config --command {0}"
run: just coverage-ci
- name: Sonar analysis
uses: SonarSource/sonarqube-scan-action@v6
+1 -1
View File
@@ -321,7 +321,7 @@ backend/
The contact form supports multiple SMTP configurations:
- **Implicit TLS (SMTPS)** - typically port 465
- **STARTTLS (Always/Opportunistic)** - typically port 587
- **STARTTLS (Always)** - typically port 587
- **Unencrypted** (for local dev) - with or without authentication
The `SmtpTransport` is built dynamically from `EmailSettings` based on
+1 -1
View File
@@ -31,7 +31,7 @@ registries = []
[bans]
multiple-versions = "allow"
wildcards = "allow"
wildcards = "deny"
highlight = "all"
workspace-default-features = "allow"
external-default-features = "allow"
+4
View File
@@ -24,6 +24,10 @@ build-release:
lint:
cargo clippy --all-targets
lint-report:
mkdir -p coverage
cargo clippy --all-targets --message-format=json > coverage/clippy.json
release-build:
cargo build --release
+3 -6
View File
@@ -24,7 +24,7 @@ pub mod startup;
/// Logging and tracing setup
pub mod telemetry;
type MaybeListener = Option<poem::listener::TcpListener<String>>;
type MaybeListener = Option<std::net::TcpListener>;
fn prepare(listener: MaybeListener) -> startup::Application {
dotenvy::dotenv().ok();
@@ -70,11 +70,8 @@ pub async fn run(listener: MaybeListener) -> Result<(), std::io::Error> {
}
#[cfg(test)]
fn make_random_tcp_listener() -> poem::listener::TcpListener<String> {
let tcp_listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind a random TCP listener");
let port = tcp_listener.local_addr().unwrap().port();
poem::listener::TcpListener::bind(format!("127.0.0.1:{port}"))
fn make_random_tcp_listener() -> std::net::TcpListener {
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind a random TCP listener")
}
#[cfg(test)]
+30 -16
View File
@@ -8,11 +8,13 @@ use std::{net::IpAddr, num::NonZeroU32, sync::Arc, time::Duration};
use governor::{
Quota, RateLimiter,
clock::DefaultClock,
state::{InMemoryState, NotKeyed},
clock::{Clock, DefaultClock},
state::keyed::DefaultKeyedStateStore,
};
use poem::{Endpoint, Error, IntoResponse, Middleware, Request, Response, Result};
type BakitRateLimiter = RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>;
/// Rate limiting configuration.
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
@@ -37,17 +39,26 @@ impl RateLimitConfig {
}
}
/// Return default values for disabling rate limiting.
#[must_use]
pub const fn disabled() -> Self {
Self {
burst_size: u32::MAX,
per_seconds: 1,
}
}
/// Creates a rate limiter from this configuration.
///
/// # Panics
///
/// Panics if `burst_size` is zero.
#[must_use]
pub fn create_limiter(&self) -> RateLimiter<NotKeyed, InMemoryState, DefaultClock> {
pub fn create_limiter(&self) -> BakitRateLimiter {
let quota = Quota::with_period(Duration::from_secs(self.per_seconds))
.expect("Failed to create quota")
.allow_burst(NonZeroU32::new(self.burst_size).expect("Burst size must be non-zero"));
RateLimiter::direct(quota)
RateLimiter::keyed(quota)
}
}
@@ -60,7 +71,7 @@ impl Default for RateLimitConfig {
/// Middleware for rate limiting based on IP address.
pub struct RateLimit {
limiter: Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>,
limiter: Arc<BakitRateLimiter>,
}
impl RateLimit {
@@ -87,7 +98,7 @@ impl<E: Endpoint> Middleware<E> for RateLimit {
/// The endpoint wrapper that performs rate limiting checks.
pub struct RateLimitEndpoint<E> {
endpoint: E,
limiter: Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>,
limiter: Arc<BakitRateLimiter>,
}
impl<E: Endpoint> Endpoint for RateLimitEndpoint<E> {
@@ -95,20 +106,22 @@ impl<E: Endpoint> Endpoint for RateLimitEndpoint<E> {
async fn call(&self, req: Request) -> Result<Self::Output> {
// Check rate limit
if self.limiter.check().is_err() {
let client_ip = Self::get_client_ip(&req)
.map_or_else(|| "unknown".to_string(), |ip| ip.to_string());
let client_ip =
Self::get_client_ip(&req).unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
if let Err(negative) = self.limiter.check_key(&client_ip) {
tracing::event!(
target: "backend::middleware::rate_limit",
tracing::Level::WARN,
client_ip = %client_ip,
"Rate limit exceeded"
);
return Err(Error::from_status(
poem::http::StatusCode::TOO_MANY_REQUESTS,
));
let clock = DefaultClock::default();
let wait = negative.wait_time_from(clock.now());
let response = Response::builder()
.status(poem::http::StatusCode::TOO_MANY_REQUESTS)
.header("Retry-After", wait.as_secs().to_string())
.finish();
return Err(Error::from_response(response));
}
// Process the request
@@ -148,14 +161,15 @@ mod tests {
fn rate_limit_config_creates_limiter() {
let config = RateLimitConfig::new(5, 1);
let limiter = config.create_limiter();
let ip = IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED);
// First 5 requests should succeed
for _ in 0..5 {
assert!(limiter.check().is_ok());
assert!(limiter.check_key(&ip).is_ok());
}
// 6th request should fail
assert!(limiter.check().is_err());
assert!(limiter.check_key(&ip).is_err());
}
#[tokio::test]
+5 -4
View File
@@ -54,7 +54,7 @@ impl Error for ContactError {}
/// issues beyond the client's control.
impl From<lettre::transport::smtp::Error> for ContactError {
fn from(value: lettre::transport::smtp::Error) -> Self {
tracing::event!(target: "contact", tracing::Level::ERROR, "SMTP Error details: {}", format!("{value:?}"));
tracing::event!(target: "backend::contact", tracing::Level::ERROR, "SMTP Error details: {}", format!("{value:?}"));
Self::OtherError(value.to_string())
}
}
@@ -89,15 +89,16 @@ impl std::fmt::Display for ContactError {
/// If no specific field can be identified, returns a generic `ValidationError`.
impl From<ValidationErrors> for ContactError {
fn from(value: ValidationErrors) -> Self {
if validator::ValidationErrors::has_error(&Err(value.clone()), "name") {
let errors = value.field_errors();
if errors.contains_key("name") {
return Self::ValidationNameError("backend.contact.errors.validation.name".to_owned());
}
if validator::ValidationErrors::has_error(&Err(value.clone()), "email") {
if errors.contains_key("email") {
return Self::ValidationEmailError(
"backend.contact.errors.validation.email".to_owned(),
);
}
if validator::ValidationErrors::has_error(&Err(value), "message") {
if errors.contains_key("message") {
return Self::ValidationMessageError(
"backend.contact.errors.validation.message".to_owned(),
);
+105 -25
View File
@@ -18,6 +18,23 @@ use crate::settings::{EmailSettings, Starttls};
pub mod errors;
use errors::ContactError;
/// Strips control characters that could enable protocol injection
///
/// When `keep_newlines` is true, `\n` is preserved (needed for
/// multi-line fields). For name and email fields, all control
/// characters are removed - no assumptions are made about valid name
/// *content*.
fn strip_control_chars(s: &str, keep_newlines: bool) -> String {
s.chars()
.filter(|c| {
if keep_newlines && (*c == '\n') {
return true;
}
!c.is_control()
})
.collect()
}
impl TryFrom<&EmailSettings> for SmtpTransport {
type Error = lettre::transport::smtp::Error;
@@ -45,7 +62,7 @@ impl TryFrom<&EmailSettings> for SmtpTransport {
Ok(builder.credentials(creds).build())
}
}
Starttls::Opportunistic | Starttls::Always => {
Starttls::Always => {
// STARTTLS - typically port 587
tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Using STARTTLS");
let creds = Credentials::new(settings.user.clone(), settings.password.clone());
@@ -72,6 +89,14 @@ struct ContactRequest {
honeypot: Option<String>,
}
impl ContactRequest {
fn sanitize(&mut self) {
self.name = strip_control_chars(&self.name, false);
self.email = strip_control_chars(&self.email, false);
self.message = strip_control_chars(&self.message, true);
}
}
impl TryFrom<&ContactRequest> for lettre::message::Mailbox {
type Error = ContactError;
@@ -160,7 +185,7 @@ impl ContactApi {
body: Json<ContactRequest>,
remote_addr: Option<poem::web::Data<&poem::web::RemoteAddr>>,
) -> ContactApiResponse {
let body = body.0;
let mut body = body.0;
if let Some(ref honeypot) = body.honeypot
&& !honeypot.trim().is_empty()
{
@@ -172,6 +197,7 @@ impl ContactApi {
);
return ContactApiResponse::Ok(ContactResponse::honeypot_response().into());
}
body.sanitize();
if let Err(e) = body.validate() {
return ContactApiResponse::BadRequest(
<validator::ValidationErrors as std::convert::Into<ContactResponse>>::into(e)
@@ -182,9 +208,10 @@ impl ContactApi {
Ok(()) => {
tracing::event!(
target: "backend::contact",
tracing::Level::INFO, "Message from \"{} <{}>\" sent successfully",
body.name,
body.email
tracing::Level::INFO,
name = %body.name,
email = %body.email,
"Contact form message sent successfully"
);
ContactApiResponse::Ok(ContactResponse::success().into())
}
@@ -216,15 +243,15 @@ impl ContactApi {
"New contact form submission:\n\nName: {}\nEmail: {}\n\nMessage:\n{}",
request.name, request.email, request.message
);
tracing::event!(target: "email", tracing::Level::DEBUG, "Sending email content to recipient: {}", email_body);
tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Sending email content to recipient: {}", email_body);
let email = Message::builder()
.from(self.settings.try_sender_into_mailbox()?)
.reply_to(request.try_into()?)
.to(self.settings.try_recpient_into_mailbox()?)
.to(self.settings.try_recipient_into_mailbox()?)
.subject(format!("Contact Form: {}", request.name))
.header(ContentType::TEXT_PLAIN)
.body(email_body)?;
tracing::event!(target: "contact", tracing::Level::DEBUG, "Email to be sent: {}", format!("{email:?}"));
tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Email to be sent: {}", format!("{email:?}"));
Ok(email)
}
@@ -402,23 +429,6 @@ mod tests {
assert!(result.is_ok());
}
#[test]
fn smtp_transport_starttls_opportunistic() {
let settings = EmailSettings {
host: "smtp.example.com".to_string(),
port: 587,
user: "user@example.com".to_string(),
password: "password".to_string(),
from: "from@example.com".to_string(),
recipient: "to@example.com".to_string(),
tls: false,
starttls: Starttls::Opportunistic,
};
let result = SmtpTransport::try_from(&settings);
assert!(result.is_ok());
}
#[test]
fn smtp_transport_no_encryption_with_credentials() {
let settings = EmailSettings {
@@ -1001,4 +1011,74 @@ mod tests {
e => panic!("Expected CouldNotSendEmail, got {e:?}"),
}
}
#[test]
fn strip_control_chars_removes_null_bytes() {
let result = strip_control_chars("John\x00Doe", false);
assert_eq!(result, "JohnDoe");
}
#[test]
fn contact_request_sanatize_strips_all_control_chars() {
let mut request = ContactRequest {
name: "John\x00Doe".into(),
email: "john\x00@example.com".into(),
message: "Test\x00message".into(),
honeypot: None,
};
request.sanitize();
assert_eq!(request.name, "JohnDoe");
assert_eq!(request.email, "john@example.com");
assert_eq!(request.message, "Testmessage");
}
#[test]
fn contact_request_sanitize_preserves_newlines_in_message() {
let mut request = ContactRequest {
name: "John\nDoe".into(),
email: "john@example.com".into(),
message: "Line 1\nLine 2\r\nLine 3".into(),
honeypot: None,
};
request.sanitize();
assert_eq!(request.name, "JohnDoe");
assert_eq!(request.email, "john@example.com");
assert_eq!(request.message, "Line 1\nLine 2\nLine 3");
}
#[test]
fn contact_request_sanatize_preserves_unicode_name() {
let mut request_jp = ContactRequest {
name: "田中さん".into(),
email: "tanaka@example.com".into(),
message: "こんにちは!".into(),
honeypot: None,
};
request_jp.sanitize();
assert_eq!(request_jp.name, "田中さん");
assert_eq!(request_jp.email, "tanaka@example.com");
assert_eq!(request_jp.message, "こんにちは!");
let mut request_ar = ContactRequest {
name: "عبدالله".into(),
email: "abdullah@example.com".into(),
message: "مرحباً".into(),
honeypot: None,
};
request_ar.sanitize();
assert_eq!(request_ar.name, "عبدالله");
assert_eq!(request_ar.email, "abdullah@example.com");
assert_eq!(request_ar.message, "مرحباً");
let mut request_uk = ContactRequest {
name: "Олексáндр".into(),
email: "oleksandr@example.com".into(),
message: "Привіт".into(),
honeypot: None,
};
request_uk.sanitize();
assert_eq!(request_uk.name, "Олексáндр");
assert_eq!(request_uk.email, "oleksandr@example.com");
assert_eq!(request_uk.message, "Привіт");
}
}
+10 -7
View File
@@ -28,11 +28,14 @@ impl HealthApi {
}
}
#[tokio::test]
async fn health_check_works() {
let app = crate::get_test_app();
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/api/health").send().await;
resp.assert_status_is_ok();
resp.assert_text("").await;
#[cfg(test)]
mod tests {
#[tokio::test]
async fn health_check_works() {
let app = crate::get_test_app();
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/api/health").send().await;
resp.assert_status_is_ok();
resp.assert_text("").await;
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ pub(crate) struct Api {
impl From<&Settings> for Api {
fn from(value: &Settings) -> Self {
let contact = contact::ContactApi::from(value.clone().email);
let contact = contact::ContactApi::from(value.email.clone());
let health = health::HealthApi;
let meta = meta::MetaApi::from(&value.application);
Self {
+8 -44
View File
@@ -163,12 +163,13 @@ impl EmailSettings {
///
/// # Errors
///
/// Returns a `ContactError` if the email address in the `from` field cannot be parsed
/// into a valid mailbox. This can occur if:
/// Returns a `ContactError` if the email address in the `from`
/// field of `recipient` cannot be parsed into a valid mailbox.
/// This can occur if:
/// - The email address format is invalid
/// - The email address contains invalid characters
/// - The email address structure is malformed
pub fn try_recpient_into_mailbox(
pub fn try_recipient_into_mailbox(
&self,
) -> Result<lettre::message::Mailbox, crate::errors::ContactError> {
Ok(self.recipient.parse::<lettre::message::Mailbox>()?)
@@ -196,8 +197,6 @@ pub enum Starttls {
/// Never use STARTTLS (unencrypted connection)
#[default]
Never,
/// Use STARTTLS if available (opportunistic encryption)
Opportunistic,
/// Always use STARTTLS (required encryption)
Always,
}
@@ -208,10 +207,9 @@ impl TryFrom<&str> for Starttls {
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_lowercase().as_str() {
"off" | "no" | "never" => Ok(Self::Never),
"opportunistic" => Ok(Self::Opportunistic),
"yes" | "always" => Ok(Self::Always),
other => Err(format!(
"{other} is not a supported option. Use either `yes`, `no`, or `opportunistic`"
"{other} is not a supported option. Use either `yes` or `no`"
)),
}
}
@@ -234,7 +232,6 @@ impl std::fmt::Display for Starttls {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let self_str = match self {
Self::Never => "never",
Self::Opportunistic => "opportunistic",
Self::Always => "always",
};
write!(f, "{self_str}")
@@ -252,7 +249,7 @@ impl<'de> serde::Deserialize<'de> for Starttls {
type Value = Starttls;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or boolean representing STARTTLS setting (e.g., 'yes', 'no', 'opportunistic', true, false)")
formatter.write_str("a string or boolean representing STARTTLS setting (e.g., 'yes', 'no', true, false)")
}
fn visit_str<E>(self, value: &str) -> Result<Starttls, E>
@@ -434,13 +431,6 @@ mod tests {
assert_eq!(result, Starttls::Always);
}
#[test]
fn startls_deserialize_from_string_opportunistic() {
let json = r#""opportunistic""#;
let result: Starttls = serde_json::from_str(json).unwrap();
assert_eq!(result, Starttls::Opportunistic);
}
#[test]
fn startls_deserialize_from_bool() {
let json = "true";
@@ -482,18 +472,6 @@ mod tests {
assert_eq!(Starttls::try_from("Yes").unwrap(), Starttls::Always);
}
#[test]
fn startls_try_from_str_opportunistic() {
assert_eq!(
Starttls::try_from("opportunistic").unwrap(),
Starttls::Opportunistic
);
assert_eq!(
Starttls::try_from("OPPORTUNISTIC").unwrap(),
Starttls::Opportunistic
);
}
#[test]
fn startls_try_from_str_invalid() {
let result = Starttls::try_from("invalid");
@@ -517,14 +495,6 @@ mod tests {
);
}
#[test]
fn startls_try_from_string_opportunistic() {
assert_eq!(
Starttls::try_from("opportunistic".to_string()).unwrap(),
Starttls::Opportunistic
);
}
#[test]
fn startls_try_from_string_invalid() {
let result = Starttls::try_from("invalid".to_string());
@@ -553,12 +523,6 @@ mod tests {
assert_eq!(startls.to_string(), "always");
}
#[test]
fn startls_display_opportunistic() {
let startls = Starttls::Opportunistic;
assert_eq!(startls.to_string(), "opportunistic");
}
#[test]
fn rate_limit_settings_default() {
let settings = RateLimitSettings::default();
@@ -696,7 +660,7 @@ mod tests {
tls: false,
};
let result = settings.try_recpient_into_mailbox();
let result = settings.try_recipient_into_mailbox();
assert!(result.is_ok());
let mailbox = result.unwrap();
assert_eq!(mailbox.email.to_string(), "recipient@example.com");
@@ -715,7 +679,7 @@ mod tests {
tls: false,
};
let result = settings.try_recpient_into_mailbox();
let result = settings.try_recipient_into_mailbox();
assert!(result.is_err());
}
}
+54 -30
View File
@@ -6,6 +6,7 @@
//! - Configuring CORS
//! - Starting the HTTP server
use poem::listener::{Listener, TcpAcceptor};
use poem::middleware::{AddDataEndpoint, Cors, CorsEndpoint};
use poem::{EndpointExt, Route};
use poem_openapi::OpenApiService;
@@ -19,10 +20,21 @@ use crate::{
use crate::middleware::rate_limit::RateLimitEndpoint;
type Server = poem::Server<poem::listener::TcpListener<String>, std::convert::Infallible>;
type Server = poem::Server<poem::listener::BoxListener, std::convert::Infallible>;
/// The configured application with rate limiting, CORS, and settings data.
pub type App = AddDataEndpoint<CorsEndpoint<RateLimitEndpoint<Route>>, Settings>;
struct PreBoundListener(std::net::TcpListener);
impl Listener for PreBoundListener {
type Acceptor = TcpAcceptor;
async fn into_acceptor(self) -> std::io::Result<Self::Acceptor> {
TcpAcceptor::from_std(self.0)
}
}
/// Application builder that holds the server configuration before running.
pub struct Application {
server: Server,
@@ -78,13 +90,24 @@ impl From<Application> for RunnableApplication {
"Rate limiting disabled (using very high limits)"
);
// Use very high limits to effectively disable rate limiting
RateLimitConfig::new(u32::MAX, 1)
RateLimitConfig::disabled()
};
let frontend_url = value.settings.frontend_url.clone();
let cors = if value.settings.debug {
Cors::new()
} else {
if !cfg!(test) {
assert!(
!frontend_url.is_empty(),
"CORS: frontend_url must be configured in production"
);
}
Cors::new().allow_origin(frontend_url)
};
let app = value
.app
.with(RateLimit::new(&rate_limit_config))
.with(Cors::new())
.with(cors)
.data(value.settings);
let server = value.server;
@@ -97,8 +120,8 @@ impl Application {
Self::prevent_unencrypted_smtp_with_credentials(settings);
let api_service = OpenApiService::new(
Api::from(settings).apis(),
settings.application.clone().name,
settings.application.clone().version,
settings.application.name.clone(),
settings.application.version.clone(),
)
.url_prefix("/api");
let ui = api_service.swagger_ui();
@@ -124,30 +147,33 @@ impl Application {
fn setup_server(
settings: &Settings,
tcp_listener: Option<poem::listener::TcpListener<String>>,
) -> Server {
let tcp_listener = tcp_listener.unwrap_or_else(|| {
let address = format!(
"{}:{}",
settings.application.host, settings.application.port
);
poem::listener::TcpListener::bind(address)
});
poem::Server::new(tcp_listener)
tcp_listener: Option<std::net::TcpListener>,
) -> (Server, u16, String) {
tcp_listener.map_or_else(
|| {
let port = settings.application.port;
let host = settings.application.host.clone();
let address = format!("{host}:{port}");
let server = poem::Server::new(poem::listener::TcpListener::bind(address).boxed());
(server, port, host)
},
|listener| {
let addr = listener.local_addr().expect("Failed to get bound address");
let port = addr.port();
let host = addr.ip().to_string();
let server = poem::Server::new(PreBoundListener(listener).boxed());
(server, port, host)
},
)
}
/// Builds a new application with the given settings and optional TCP listener.
///
/// If no listener is provided, one will be created based on the settings.
#[must_use]
pub fn build(
settings: Settings,
tcp_listener: Option<poem::listener::TcpListener<String>>,
) -> Self {
let port = settings.application.port;
let host = settings.application.clone().host;
pub fn build(settings: Settings, tcp_listener: Option<std::net::TcpListener>) -> Self {
let (server, port, host) = Self::setup_server(&settings, tcp_listener);
let app = Self::setup_app(&settings);
let server = Self::setup_server(&settings, tcp_listener);
Self {
server,
app,
@@ -165,8 +191,8 @@ impl Application {
/// Returns the host address the application is configured to bind to.
#[must_use]
pub fn host(&self) -> String {
self.host.clone()
pub fn host(&self) -> &str {
&self.host
}
/// Returns the port the application is configured to bind to.
@@ -232,13 +258,11 @@ mod tests {
#[test]
fn application_with_custom_listener() {
let settings = create_test_settings();
let tcp_listener =
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");
let port = tcp_listener.local_addr().unwrap().port();
let listener = poem::listener::TcpListener::bind(format!("127.0.0.1:{port}"));
let expected_port = listener.local_addr().unwrap().port();
let app = Application::build(settings, Some(listener));
assert_eq!(app.host(), "127.0.0.1");
assert_eq!(app.port(), 8080);
assert_eq!(app.port(), expected_port);
}
}
+5 -8
View File
@@ -14,16 +14,13 @@ pub fn get_subscriber(debug: bool) -> impl tracing::Subscriber + Send + Sync {
let env_filter = if debug { "debug" } else { "info" }.to_string();
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(env_filter));
let stdout_log = tracing_subscriber::fmt::layer().pretty();
let subscriber = tracing_subscriber::Registry::default()
.with(env_filter)
.with(stdout_log);
let json_log = if debug {
None
let subscriber = tracing_subscriber::Registry::default().with(env_filter);
let (stdout_log, json_log) = if debug {
(Some(tracing_subscriber::fmt::layer().pretty()), None)
} else {
Some(tracing_subscriber::fmt::layer().json())
(None, Some(tracing_subscriber::fmt::layer().json()))
};
subscriber.with(json_log)
subscriber.with(stdout_log).with(json_log)
}
/// Initializes the global tracing subscriber.