This commit is contained in:
2025-12-12 23:35:17 -07:00
parent 01452011f1
commit cd514627de
17 changed files with 2538 additions and 16 deletions

17
.gitignore vendored
View File

@@ -1,18 +1,5 @@
# ---> Rust
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
debug/
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.idea/

2119
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "rustapi"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1.35", features = ["macros", "rt-multi-thread", "signal"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "normalize-path"] }
http = "0.2"
utoipa = "4"
utoipa-swagger-ui = { version = "7", features = ["axum"] }
[dev-dependencies]
axum = { version = "0.7", features = ["macros"] }
serde_json = "1.0"
tokio = { version = "1.35", features = ["macros", "rt-multi-thread"] }
tower = "0.4"
http-body-util = "0.1"

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM rust:1.75-slim AS builder
WORKDIR /app
# Cache dependencies first
COPY Cargo.toml ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/rustapi /usr/local/bin/rustapi
EXPOSE 8080
CMD ["rustapi"]

46
Makefile Normal file
View File

@@ -0,0 +1,46 @@
.PHONY: help build run dev clean deps fmt lint test docker-build
CARGO ?= cargo
BIN ?= rustapi
help:
@echo "Available targets:"
@echo " make build - Build the application (release)"
@echo " make run - Build and run the application"
@echo " make dev - Run in dev mode (RUST_LOG=debug)"
@echo " make test - Run tests"
@echo " make fmt - Format code"
@echo " make lint - Clippy with warnings as errors"
@echo " make clean - Remove build artifacts"
@echo " make deps - Pre-fetch dependencies"
@echo " make docker-build - Build local container image"
build:
@echo "Building application..."
@$(CARGO) build --release --bin $(BIN)
run: build
@echo "Starting server..."
@./target/release/$(BIN)
dev:
@echo "Running in development mode..."
@RUST_LOG=debug $(CARGO) run --bin $(BIN)
test:
@$(CARGO) test
fmt:
@$(CARGO) fmt
lint:
@$(CARGO) clippy -- -D warnings
clean:
@$(CARGO) clean
deps:
@$(CARGO) fetch
docker-build:
@docker build -t $(BIN):local .

View File

@@ -1,2 +1 @@
# rustapi

76
src/app.rs Normal file
View File

@@ -0,0 +1,76 @@
use axum::{Router, routing::{get, post}};
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::docs::ApiDoc;
use crate::handlers::{echo, health};
use crate::middleware::request_id::RequestIdLayer;
use crate::state::SharedState;
pub fn app_router(state: SharedState) -> Router {
Router::new()
.route("/health", get(health))
.route("/echo", post(echo))
.merge(SwaggerUi::new("/docs").url("/api-doc/openapi.json", ApiDoc::openapi()))
.with_state(state)
.layer(
ServiceBuilder::new()
.layer(RequestIdLayer::default())
.layer(TraceLayer::new_for_http())
.into_inner(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Body, http::{Request, StatusCode}};
use http_body_util::BodyExt;
use tower::ServiceExt;
use crate::handlers::types::{HealthResponse, MessageResponse};
use crate::state::new_state;
fn test_app() -> Router {
app_router(new_state("Test API"))
}
#[tokio::test]
async fn health_returns_ok() {
let app = test_app();
let response = app
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let health: HealthResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(health.status, "ok");
}
#[tokio::test]
async fn echo_round_trips_message() {
let app = test_app();
let payload = serde_json::json!({ "message": "hello" });
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/echo")
.header("content-type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let echoed: MessageResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(echoed.echoed, "hello");
}
}

12
src/docs.rs Normal file
View File

@@ -0,0 +1,12 @@
use utoipa::OpenApi;
use crate::handlers::{__path_echo, __path_health};
use crate::handlers::types::{HealthResponse, MessageRequest, MessageResponse};
#[derive(OpenApi)]
#[openapi(
paths(health, echo),
components(schemas(HealthResponse, MessageRequest, MessageResponse)),
info(title = "Rust API Template", version = "0.1.0")
)]
pub struct ApiDoc;

28
src/handlers/echo.rs Normal file
View File

@@ -0,0 +1,28 @@
use axum::{Json, extract::{Extension, State}};
use crate::handlers::types::{MessageRequest, MessageResponse};
use crate::middleware::request_id::{RequestId, generate_request_id};
use crate::state::SharedState;
#[utoipa::path(
post,
path = "/echo",
request_body = MessageRequest,
responses(
(status = 200, description = "Echo the message back", body = MessageResponse)
)
)]
pub async fn echo(
State(_state): State<SharedState>,
req_id: Option<Extension<RequestId>>,
Json(payload): Json<MessageRequest>,
) -> Json<MessageResponse> {
let id = req_id
.map(|rid| rid.0.0)
.unwrap_or_else(generate_request_id);
Json(MessageResponse {
echoed: payload.message,
id,
})
}

18
src/handlers/health.rs Normal file
View File

@@ -0,0 +1,18 @@
use axum::{Json, extract::State};
use crate::handlers::types::HealthResponse;
use crate::state::SharedState;
#[utoipa::path(
get,
path = "/health",
responses(
(status = 200, description = "Health check", body = HealthResponse)
)
)]
pub async fn health(State(state): State<SharedState>) -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".to_string(),
service: state.app_name.clone(),
})
}

8
src/handlers/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
pub mod echo;
pub mod health;
pub mod types;
pub use echo::echo;
pub use echo::__path_echo;
pub use health::health;
pub use health::__path_health;

19
src/handlers/types.rs Normal file
View File

@@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Serialize, ToSchema)]
pub struct HealthResponse {
pub status: String,
pub service: String,
}
#[derive(Debug, Deserialize, Serialize, ToSchema)]
pub struct MessageRequest {
pub message: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct MessageResponse {
pub echoed: String,
pub id: String,
}

5
src/lib.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod app;
pub mod docs;
pub mod handlers;
pub mod middleware;
pub mod state;

69
src/main.rs Normal file
View File

@@ -0,0 +1,69 @@
use std::net::SocketAddr;
use axum::body::Body;
use axum::http::Request;
use axum::ServiceExt;
use tower::Layer;
use tower_http::normalize_path::NormalizePathLayer;
use tracing::info;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
init_tracing();
let state = rustapi::state::new_state("rustapi");
let addr: SocketAddr = ([0, 0, 0, 0], 8080).into();
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("listening on http://{}", addr);
info!("swagger ui available at http://{}/docs", addr);
let app = NormalizePathLayer::trim_trailing_slash()
.layer(rustapi::app::app_router(state));
axum::serve(listener, ServiceExt::<Request<Body>>::into_make_service(app))
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
fn init_tracing() {
use tracing_subscriber::{fmt, EnvFilter};
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info"))
.unwrap();
fmt()
.with_env_filter(filter)
.with_target(false)
.compact()
.init();
}
async fn shutdown_signal() {
use tokio::signal;
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}

1
src/middleware/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod request_id;

View File

@@ -0,0 +1,83 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::task::{Context, Poll};
use std::time::{SystemTime, UNIX_EPOCH};
use axum::http::{HeaderMap, HeaderValue, Request, Response, header::HeaderName};
use tower::{Layer, Service};
pub const REQUEST_ID_HEADER: &str = "x-request-id";
static NEXT_REQUEST_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Default)]
pub struct RequestIdLayer;
#[derive(Clone, Debug)]
pub struct RequestId(pub String);
#[derive(Clone)]
pub struct RequestIdMiddleware<S> {
inner: S,
}
impl<S> Layer<S> for RequestIdLayer {
type Service = RequestIdMiddleware<S>;
fn layer(&self, inner: S) -> Self::Service {
RequestIdMiddleware { inner }
}
}
impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for RequestIdMiddleware<S>
where
S: Service<Request<ReqBody>, Response = Response<ResBody>> + Clone + Send + 'static,
S::Error: Send + 'static,
S::Future: Send + 'static,
ReqBody: Send + 'static,
ResBody: axum::body::HttpBody + Send + 'static,
ResBody::Data: Send,
{
type Response = Response<ResBody>;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: Request<ReqBody>) -> Self::Future {
let mut inner = self.inner.clone();
let request_id = extract_or_generate_request_id(req.headers());
req.extensions_mut().insert(RequestId(request_id.clone()));
Box::pin(async move {
let mut res = inner.call(req).await?;
res.headers_mut().insert(
HeaderName::from_static(REQUEST_ID_HEADER),
HeaderValue::from_str(&request_id).unwrap_or_else(|_| HeaderValue::from_static("unknown")),
);
Ok(res)
})
}
}
pub fn extract_or_generate_request_id(headers: &HeaderMap) -> String {
if let Some(val) = headers.get(REQUEST_ID_HEADER) {
if let Ok(as_str) = val.to_str() {
return as_str.to_owned();
}
}
generate_request_id()
}
pub fn generate_request_id() -> String {
let counter = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_micros();
format!("{}-{}", now, counter)
}

14
src/state.rs Normal file
View File

@@ -0,0 +1,14 @@
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub app_name: String,
}
pub type SharedState = Arc<AppState>;
pub fn new_state(app_name: impl Into<String>) -> SharedState {
Arc::new(AppState {
app_name: app_name.into(),
})
}