initial commit; basic server skeleton w/ health endpoint

Through section 3.5.
This commit is contained in:
azdle 2023-11-14 15:55:24 -06:00
commit 6a9f4c87c0
10 changed files with 1710 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

1490
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

26
Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "zero-to-axum"
version = "0.1.0-dev"
authors = [ "azdle <azdle@azdle.net>" ]
edition = "2021"
description = "An axum based HTTP server template."
repository = "https://git.idlestate.org/azdle/zero-to-axum"
license = "MIT OR Apache-2.0"
[dependencies]
anyhow = { version = "1.0.71", features = ["backtrace"] }
axum = "0.6.18"
futures-util = "0.3"
hyper = "0.14.26"
pin-project = "1.1.0"
serde = { version = "1.0.164", features = ["derive"] }
serde_json = "1.0.99"
thiserror = "1.0.40"
tokio = { version = "1.28.2", features = ["full"] }
tokio-stream = "0.1"
tracing = "0.1.37"
tracing-subscriber = { version = "0.3", features =["env-filter"] }
[dev-dependencies]
reqwest = "0.11.18"
test-log = { version = "0.2.12", default-features = false, features = ["trace"] }

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM rust:alpine as builder
WORKDIR /app
COPY . .
RUN apk add musl-dev
RUN cargo build --release
FROM alpine:latest
LABEL org.opencontainers.image.source https://git.idlestate.org/azdle/zero-to-axum
COPY --from=builder /app/target/release/zero-to-axum /usr/local/bin/zero-to-axum
CMD ["zero-to-axum"]

10
README.md Normal file
View file

@ -0,0 +1,10 @@
# Zero to Axum
A template Axum server made from (loosely) following the book "Zero to Production in Rust".
Instead of making a mailing list manager, it manages local user accounts.
## Endpoints
###`/health`
Responds with an empty 200 respose if the server is healthy.

3
src/lib.rs Normal file
View file

@ -0,0 +1,3 @@
mod server;
pub use server::ZeroToAxum;

32
src/main.rs Normal file
View file

@ -0,0 +1,32 @@
use anyhow::{Context, Result};
use tokio::signal;
use tracing::info;
use zero_to_axum::ZeroToAxum;
fn init_tracing() {
use tracing_subscriber::{filter::LevelFilter, fmt, EnvFilter};
let log_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
fmt().pretty().with_env_filter(log_filter).init();
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
let addr = "[::]:3742".parse().unwrap();
let mut server = ZeroToAxum::serve(addr);
// Turn a SIGINT into a graceful shutdown
let server_shutdown = server.take_shutdown_handle().unwrap();
tokio::spawn(async {
signal::ctrl_c().await.unwrap();
info!("[Ctrl-C] Shutting Down");
server_shutdown.send(()).unwrap();
});
server.await.context("run server")
}

70
src/server/mod.rs Normal file
View file

@ -0,0 +1,70 @@
pub mod routes;
use anyhow::Result;
use pin_project::pin_project;
use std::future::Future;
use std::net::SocketAddr;
use std::pin::pin;
use std::pin::Pin;
use tokio::sync::oneshot;
use tracing::info;
#[pin_project]
pub struct ZeroToAxum {
#[pin]
server: Pin<Box<dyn Future<Output = Result<(), hyper::Error>> + Send>>,
bound_addr: SocketAddr,
shutdown: Option<oneshot::Sender<()>>,
}
impl Future for ZeroToAxum {
type Output = Result<(), hyper::Error>;
fn poll(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output> {
self.project().server.poll(cx)
}
}
impl ZeroToAxum {
pub fn shutdown(self) -> Result<()> {
if let Some(shutdown) = self.shutdown {
shutdown
.send(())
.map_err(|()| anyhow::anyhow!("failed to send"))
} else {
Err(anyhow::anyhow!("shutdown handle gone"))
}
}
pub fn take_shutdown_handle(&mut self) -> Option<oneshot::Sender<()>> {
self.shutdown.take()
}
pub fn local_addr(&self) -> SocketAddr {
self.bound_addr
}
pub fn serve(addr: SocketAddr) -> ZeroToAxum {
let app = routes::build();
let (shutdown_sender, shutdown_receiver) = oneshot::channel();
let server = axum::Server::bind(&addr).serve(app.into_make_service());
let bound_addr = server.local_addr();
info!("server started, listening on {addr:?}");
let server = server.with_graceful_shutdown(async {
shutdown_receiver.await.ok();
});
ZeroToAxum {
server: Box::pin(server),
bound_addr,
shutdown: Some(shutdown_sender),
}
}
}

8
src/server/routes/mod.rs Normal file
View file

@ -0,0 +1,8 @@
use axum::{routing::get, Router};
pub fn build() -> Router {
Router::new().route("/health", get(health_check))
}
// just always returns a 200 OK for now, the server has no state, if it's up, it's working
pub async fn health_check() {}

53
tests/basic.rs Normal file
View file

@ -0,0 +1,53 @@
use anyhow::{Context, Result};
use futures_util::FutureExt;
use std::net::SocketAddr;
use test_log::test;
use tokio::{sync::oneshot, task::JoinHandle};
use tracing::info;
use zero_to_axum::ZeroToAxum;
struct TestServer {
server_task_handle: JoinHandle<()>,
shutdown_handle: oneshot::Sender<()>,
addr: SocketAddr,
}
impl TestServer {
async fn spawn() -> TestServer {
info!("start server");
let mut server = ZeroToAxum::serve("[::]:0".parse().unwrap());
let shutdown_handle = server.take_shutdown_handle().unwrap();
let addr = server.local_addr();
let server_task_handle = tokio::spawn(server.map(|res| res.unwrap()));
info!("server spawned");
TestServer {
server_task_handle,
shutdown_handle,
addr,
}
}
/// format a URL for the given path
fn url(&self, path: &str) -> String {
format!("http://{}{path}", self.addr)
}
/// Request a graceful shutdown and then wait for shutdown to complete
async fn shutdown(self) -> Result<()> {
self.shutdown_handle.send(()).ok();
self.server_task_handle
.await
.context("wait for server shutdown")
}
}
#[test(tokio::test)]
async fn health_check() -> Result<()> {
let server = TestServer::spawn().await;
let status = reqwest::get(server.url("/health")).await?.status();
assert_eq!(status, 200, "health check failed");
server.shutdown().await
}