diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e3c9650 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +target/ +Cargo.lock +**/*.rs.bk +.idea diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..c398f79 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,59 @@ +--- +kind: pipeline +type: docker +name: mirror-server + +steps: + - name: create-build-image + image: plugins/docker + settings: + username: + from_secret: docker-username + password: + from_secret: docker-password + registry: code.birch-tree.net + repo: code.birch-tree.net/dorian/mirror-server + target: BUILD + tags: + - build + cache_from: + - code.birch-tree.net/dorian/mirror-server:build + + - name: test + image: code.birch-tree.net/dorian/mirror-server:build + commands: + - cargo test + depends_on: + - create-build-image + + - name: create-release-image + image: plugins/docker + settings: + username: + from_secret: docker-username + password: + from_secret: docker-password + registry: code.birch-tree.net + repo: code.birch-tree.net/dorian/mirror-server + tags: + - 0.3.0 + - latest + cache_from: + - code.birch-tree.net/dorian/mirror-server:latest + - code.birch-tree.net/dorian/mirror-server:build + depends_on: + - test + + - name: create-debian-package + image: code.birch-tree.net/dorian/mirror-server:build + commands: + - ./publish-deb.sh + environment: + USERNAME: dorian + PASSWORD: + from_secret: gitea-release-password + depends_on: + - test + +image_pull_secrets: + - docker-config diff --git a/Cargo.toml b/Cargo.toml index afabaa1..b41e7eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,7 @@ tower = "0.4" tower-http = { version = "0.5", features = ["trace"] } tracing = "0.1" tracing-subscriber = "0.3" + +[dev-dependencies] +axum-test = "14.0" +rstest = "0.18" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..097a0da --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM rust:1.76 AS BUILD + +ENV APP_NAME=mirror-server +ENV APP_HOME=/srv/${APP_NAME} +RUN apt update \ + && apt install -y curl \ + && cargo install cargo-deb + +# Setup working env. +RUN mkdir -p ${APP_HOME} +WORKDIR ${APP_HOME} + +# Create build target cache. +RUN USER=root cargo init --bin . +COPY ["Cargo.toml", "./"] +RUN cargo build \ + && cargo build --tests \ + && cargo build --release \ + && rm src/*.rs + +# Bring in the source +COPY ["src", "./src/"] + +# Build the example API. +RUN cargo build --release + +# Create the minimal server image. +FROM debian:buster-slim AS SERVER + +ENV APP_NAME=mirror-server +ENV APP_HOME=/srv/${APP_NAME} +RUN apt update + +RUN mkdir -p ${APP_HOME} +WORKDIR ${APP_HOME} + +COPY --from=BUILD ${APP_HOME}/target/release/${APP_NAME} /usr/local/bin/ +CMD ${APP_NAME} \ No newline at end of file diff --git a/README.md b/README.md index 813c531..c165ec7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A simple server for mirroring HTTP requests for testing. +[![Build Status](https://ci.birch-tree.net/api/badges/dorian/mirror-server/status.svg)](https://ci.birch-tree.net/dorian/mirror-server) + ## Getting Started * Use the latest stable version of Rust using rustup. @@ -29,5 +31,7 @@ sudo dpkg -i "mirror-server-${VERSION}_amd64.deb" * [x] Migrate actix to axum for easier maintainability. * [x] Add mirroring of JSON request. * [x] Add logging to server. -* [ ] Add convenience path to access logging for a certain date / records. -* [ ] Dockerize mirror-server for easier distribution. +* [x] Create Docker image for mirror-server for easier distribution. +* [x] Add publishing of DEB and Docker image to DroneCI. +* [ ] Add documentation to the API. +* [ ] Publish crate on . diff --git a/publish-deb.sh b/publish-deb.sh index aee0fff..b5b23aa 100755 --- a/publish-deb.sh +++ b/publish-deb.sh @@ -1,22 +1,28 @@ -#!/usr/bin/env bash +#!/usr/bin/bash -read -r -p "Username: " USERNAME -read -r -s -p "Password: " PASSWORD - -_pkg_name=mirror-server -_pkg_version=0.2.0 -_deb_file="${_pkg_name}_${_pkg_version}_amd64.deb" -_deb_path="target/debian" -_gitea_server="code.birch-tree.net" - -if [[ ! -f "${_deb_path}/${_deb_file}" ]]; -then - echo "Run cargo deb first!" - exit 1 +if [[ -z "${USERNAME}" ]]; then + echo "Set the USERNAME for Gitea" + exit 1 fi -curl --user "${USERNAME}:${PASSWORD}" \ - --upload-file "${_deb_path}/${_deb_file}" \ - -X PUT \ - "https://${_gitea_server}/api/packages/${USERNAME}/generic/${_pkg_name}/${_pkg_version}/${_deb_file}" +if [[ -z "${PASSWORD}" ]]; then + echo "Set the PASSWORD for Gitea" + exit 1 +fi +echo 'Create the Debian package...' + +pkg_full_path=$(cargo deb) +pkg_filename=$(basename "${pkg_full_path}") +pkg_name=$(echo "${pkg_filename}" | awk -F _ '{print $1}') +pkg_version=$(echo "${pkg_filename}" | awk -F _ '{print $2}') + +gitea_server="code.birch-tree.net" + +curl --user "${USERNAME}:${PASSWORD}" \ + --upload-file "${pkg_full_path}" \ + --silent \ + -X PUT \ + "https://${gitea_server}/api/packages/${USERNAME}/generic/${pkg_name}/${pkg_version}/${pkg_filename}" + +echo "Published ${pkg_name} v${pkg_version} ===> ${pkg_filename}" diff --git a/src/main.rs b/src/main.rs index 950bec6..95c8ff1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,14 @@ use axum::{ - extract::{Host, OriginalUri, Json}, + extract::{Host, Json, OriginalUri}, http::{header::HeaderMap, Method}, Router, }; use clap::Parser; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; use tower_http::trace; -use tracing::{Level, info, warn}; +use tracing::{info, warn, Level}; #[derive(Parser)] #[command(author, version, about, long_about = None)] @@ -22,7 +22,7 @@ struct CliArgs { ips: String, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] struct EchoResponse { method: String, path: String, @@ -35,35 +35,46 @@ struct EchoResponse { async fn echo_request( method: Method, original_uri: OriginalUri, - host: Host, + Host(host): Host, header_map: HeaderMap, - body: Option> + body: Option>, ) -> Json { let method = method.to_string(); - let host = host.0; + let host = host; let path = original_uri.path().to_string(); let headers = header_map .iter() .map(|(name, value)| (name.to_string(), value.to_str().unwrap_or("").to_string())) .collect(); - let json_body = match body { + let body = match body { None => { warn!("Received a non-JSON body."); None - }, - Some(Json(value)) => Some(value), + } + Some(Json(value)) => { + info!("JSON request: {}", value.to_string()); + Some(value) + } }; let response = EchoResponse { method, host, path, headers, - body: json_body, + body, }; Json(response) } +fn app() -> Router { + Router::new().fallback(echo_request).layer( + trace::TraceLayer::new_for_http() + .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) + .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), + ) +} + #[tokio::main] async fn main() { let cli_args = CliArgs::parse(); @@ -71,20 +82,136 @@ async fn main() { let listen_on = format!("{ip}:{port}", ip = listen_on.0, port = listen_on.1); // From https://stackoverflow.com/questions/75009289/how-to-enable-logging-tracing-with-axum - tracing_subscriber::fmt() - .with_max_level(Level::INFO) - .init(); - let app = Router::new().fallback(echo_request).layer( - trace::TraceLayer::new_for_http() - .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) - .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), - ); + tracing_subscriber::fmt().with_max_level(Level::INFO).init(); info!("Starting the mirror-server to listen to {}", listen_on); let listener = tokio::net::TcpListener::bind(&listen_on) .await .unwrap_or_else(|_| panic!("Failed to binding to {}", listen_on)); - axum::serve(listener, app) + axum::serve(listener, app()) .await .expect("Server should start"); } + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::{HeaderName, HeaderValue, Method, StatusCode}; + use axum_test::TestServer; + use rstest::rstest; + + #[tokio::test] + async fn handles_simple_get_request() { + let expected_headers: BTreeMap = BTreeMap::new(); + let expected_response = EchoResponse { + method: "GET".to_string(), + path: "/".to_string(), + host: "localhost".to_string(), + body: None, + headers: expected_headers, + }; + let server = TestServer::new(app()).unwrap(); + let response = server.get("/").await; + response.assert_status(StatusCode::OK); + response.assert_json::(&expected_response); + } + + #[rstest] + #[case::single_level("/test")] + #[case::multiple_level("/test/multiple/levels/")] + #[case::slash_ending("/test/")] + #[tokio::test] + async fn handles_different_urls(#[case] url: String) { + let expected_headers: BTreeMap = BTreeMap::new(); + let expected_response = EchoResponse { + method: "GET".to_string(), + path: url.clone(), + host: "localhost".to_string(), + body: None, + headers: expected_headers, + }; + let server = TestServer::new(app()).unwrap(); + let response = server.get(&url).await; + response.assert_status(StatusCode::OK); + response.assert_json::(&expected_response); + } + + #[rstest] + #[case::post(Method::POST)] + #[case::put(Method::PUT)] + #[case::patch(Method::PATCH)] + #[case::delete(Method::DELETE)] + #[tokio::test] + async fn handles_different_http_methods(#[case] http_method: Method) { + let expected_headers: BTreeMap = BTreeMap::new(); + let expected_response = EchoResponse { + method: http_method.to_string(), + path: "/testing".to_string(), + host: "localhost".to_string(), + body: None, + headers: expected_headers, + }; + let server = TestServer::new(app()).unwrap(); + let response = server.method(http_method, "/testing").await; + response.assert_status(StatusCode::OK); + response.assert_json::(&expected_response); + } + + #[tokio::test] + async fn handle_non_json_request() { + let mut expected_headers: BTreeMap = BTreeMap::new(); + expected_headers.insert("content-type".to_string(), "text/plain".to_string()); + let expected_response = EchoResponse { + method: "POST".to_string(), + path: "/".to_string(), + host: "localhost".to_string(), + body: None, + headers: expected_headers, + }; + let server = TestServer::new(app()).unwrap(); + let response = server.post("/").text("hello world").await; + response.assert_status(StatusCode::OK); + response.assert_json::(&expected_response); + } + + #[tokio::test] + async fn handle_json_request() { + let test_json = serde_json::json!({"hello": "world"}); + let mut expected_headers: BTreeMap = BTreeMap::new(); + expected_headers.insert("content-type".to_string(), "application/json".to_string()); + let expected_response = EchoResponse { + method: "POST".to_string(), + path: "/".to_string(), + host: "localhost".to_string(), + body: Some(test_json.clone()), + headers: expected_headers, + }; + let server = TestServer::new(app()).unwrap(); + let response = server.post("/").json(&test_json).await; + response.assert_status(StatusCode::OK); + response.assert_json::(&expected_response); + } + + #[tokio::test] + async fn handle_extra_headers() { + let mut expected_headers: BTreeMap = BTreeMap::new(); + expected_headers.insert("x-test-message".to_string(), "Howdy!".to_string()); + let expected_response = EchoResponse { + method: "GET".to_string(), + path: "/".to_string(), + host: "localhost".to_string(), + body: None, + headers: expected_headers, + }; + let server = TestServer::new(app()).unwrap(); + let response = server + .get("/") + .add_header( + HeaderName::from_static("x-test-message"), + HeaderValue::from_static("Howdy!"), + ) + .await; + response.assert_status(StatusCode::OK); + response.assert_json::(&expected_response); + } +}