1.0.0
This commit is contained in:
17
.gitignore
vendored
17
.gitignore
vendored
@@ -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
2119
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal 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
14
Dockerfile
Normal 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
46
Makefile
Normal 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 .
|
||||
76
src/app.rs
Normal file
76
src/app.rs
Normal 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
12
src/docs.rs
Normal 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
28
src/handlers/echo.rs
Normal 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
18
src/handlers/health.rs
Normal 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
8
src/handlers/mod.rs
Normal 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
19
src/handlers/types.rs
Normal 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
5
src/lib.rs
Normal 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
69
src/main.rs
Normal 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
1
src/middleware/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod request_id;
|
||||
83
src/middleware/request_id.rs
Normal file
83
src/middleware/request_id.rs
Normal 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
14
src/state.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user