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/
|
target/
|
||||||
|
debug/
|
||||||
# These are backup files generated by rustfmt
|
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
|
||||||
*.pdb
|
*.pdb
|
||||||
|
.idea/
|
||||||
# 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/
|
|
||||||
|
|||||||
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