feat: Implement a new layered architecture for the QR generator, integrating Axum, Maud, and HTMX, and updating documentation.

This commit is contained in:
2025-12-30 03:28:03 +01:00
parent 286e10160f
commit 9f12d44489
25 changed files with 2713 additions and 536 deletions

1
backend/.dockerignore Normal file
View File

@@ -0,0 +1 @@
/target

2299
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,24 @@
[package]
name = "qr-generator"
version = "0.1.0"
edition = "2021"
name = "k-qr"
version = "0.2.0"
edition = "2024"
[dependencies]
axum = { version = "0.7.5", features = ["macros"] }
tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] }
qrcode = "0.12.0"
image = "0.23.14"
maud = { version = "0.26.0", features = ["axum"] }
tower-http = { version = "0.5.2", features = ["cors"] }
axum = { version = "0.8.8", features = ["macros"] }
tokio = { version = "1.0", features = ["full"] }
maud = { version = "0.27", features = ["axum"] }
qrcode = "0.14"
image = "0.25"
tower-http = { version = "0.6.8", features = ["trace"] }
serde = { version = "1.0", features = ["derive"] }
thiserror = "2.0.17"
anyhow = "1.0"
config = "0.15.19"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2.5"
base64 = "0.22.1"
async-trait = "0.1.89"
[dev-dependencies]
mockall = "0.14.0"

View File

@@ -1,23 +1,40 @@
FROM rust:1 AS chef
# We only pay the installation cost once,
# it will be cached from the second build onwards
FROM rust:1-slim AS chef
RUN cargo install cargo-chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Build dependencies - this is the caching Docker layer!
# Build dependencies - this is the caching layer
RUN cargo chef cook --release --recipe-path recipe.json
# Build application
COPY . .
RUN cargo build --release --bin qr-generator
RUN cargo build --release --bin k-qr
# We do not need the Rust toolchain to run the binary!
# Runtime stage
FROM debian:bookworm-slim AS runtime
WORKDIR /app
COPY --from=builder /app/target/release/qr-generator /usr/local/bin
ENTRYPOINT ["/usr/local/bin/qr-generator"]
# Install necessary runtime dependencies (if any, usually none for static rust binaries)
# Create a non-root user
RUN groupadd -g 10001 appgroup && \
useradd -u 10001 -g appgroup appuser
# Copy the binary from the builder stage
COPY --from=builder /app/target/release/k-qr /usr/local/bin/k-qr
# Set default environment variables
ENV SERVER_HOST=0.0.0.0
ENV SERVER_PORT=3000
ENV RUST_LOG=k_qr=info
# Use the non-root user
USER appuser
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/k-qr"]

View File

@@ -0,0 +1,53 @@
use crate::domain::{
error::QrError,
qr::{QrData, QrOptions},
};
use crate::ports::QrCodeGenerator;
use std::sync::Arc;
pub struct GenerateQrUseCase {
generator: Arc<dyn QrCodeGenerator>,
}
impl GenerateQrUseCase {
pub fn new(generator: Arc<dyn QrCodeGenerator>) -> Self {
Self { generator }
}
pub async fn execute(&self, data: String, options: QrOptions) -> Result<Vec<u8>, QrError> {
let qr_data = QrData::new(data)?;
self.generator.generate(&qr_data, &options).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ports::MockQrCodeGenerator;
#[tokio::test]
async fn test_generate_qr_success() {
let mut mock_generator = MockQrCodeGenerator::new();
mock_generator
.expect_generate()
.times(1)
.returning(|_, _| Ok(vec![1, 2, 3]));
let use_case = GenerateQrUseCase::new(Arc::new(mock_generator));
let result = use_case
.execute("https://example.com".to_string(), QrOptions::default())
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), vec![1, 2, 3]);
}
#[tokio::test]
async fn test_generate_qr_invalid_data() {
let mock_generator = MockQrCodeGenerator::new();
let use_case = GenerateQrUseCase::new(Arc::new(mock_generator));
let result = use_case.execute("".to_string(), QrOptions::default()).await;
assert!(matches!(result, Err(QrError::EmptyData)));
}
}

View File

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

View File

@@ -0,0 +1,23 @@
use thiserror::Error;
#[derive(Error, Debug, PartialEq)]
pub enum ConfigError {
#[error("Environment variable not found: {0}")]
EnvVarMissing(String),
#[error("Invalid integer configuration: {0}")]
InvalidInteger(String),
#[error("Invalid option: {0}")]
InvalidOption(String),
}
#[derive(Error, Debug, PartialEq)]
pub enum QrError {
#[error("QR Code data cannot be empty")]
EmptyData,
#[error("QR Code data is too long: {0} characters")]
DataTooLong(usize),
#[error("Failed to generate QR code: {0}")]
GenerationFailed(String),
#[error("Invalid option: {0}")]
InvalidOption(String),
}

View File

@@ -0,0 +1,2 @@
pub mod error;
pub mod qr;

125
backend/src/domain/qr.rs Normal file
View File

@@ -0,0 +1,125 @@
use super::error::QrError;
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct QrData(String);
impl QrData {
pub fn new(data: impl Into<String>) -> Result<Self, QrError> {
let data = data.into();
if data.trim().is_empty() {
return Err(QrError::EmptyData);
}
if data.len() > 2048 {
return Err(QrError::DataTooLong(data.len()));
}
Ok(Self(data))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Newtype for validated hex color codes.
#[derive(Debug, Clone, PartialEq)]
pub struct HexColor(String);
impl HexColor {
pub fn new(color: impl Into<String>) -> Result<Self, QrError> {
let color = color.into();
let trimmed = color.trim();
// Must start with # and be 7 chars (#RRGGBB) or 4 chars (#RGB)
if !trimmed.starts_with('#') {
return Err(QrError::InvalidOption(format!(
"Color must start with #: {}",
color
)));
}
let hex_part = &trimmed[1..];
if hex_part.len() != 6 && hex_part.len() != 3 {
return Err(QrError::InvalidOption(format!(
"Invalid color length: {}",
color
)));
}
if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(QrError::InvalidOption(format!(
"Invalid hex characters: {}",
color
)));
}
Ok(Self(trimmed.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for HexColor {
fn default() -> Self {
Self("#000000".to_string())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct QrOptions {
pub color: HexColor,
}
impl Default for QrOptions {
fn default() -> Self {
Self {
color: HexColor::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_qr_data_valid() {
let input = "https://example.com";
let data = QrData::new(input);
assert!(data.is_ok());
assert_eq!(data.unwrap().as_str(), input);
}
#[test]
fn test_qr_data_empty() {
let input = " ";
let data = QrData::new(input);
assert_eq!(data, Err(QrError::EmptyData));
}
#[test]
fn test_qr_data_too_long() {
let input = "a".repeat(2049);
let data = QrData::new(&input);
match data {
Err(QrError::DataTooLong(len)) => assert_eq!(len, 2049),
_ => panic!("Expected DataTooLong error"),
}
}
#[test]
fn test_hex_color_valid() {
assert!(HexColor::new("#000000").is_ok());
assert!(HexColor::new("#fff").is_ok());
assert!(HexColor::new("#E57E1D").is_ok());
}
#[test]
fn test_hex_color_invalid() {
assert!(HexColor::new("000000").is_err()); // Missing #
assert!(HexColor::new("#gggggg").is_err()); // Invalid chars
assert!(HexColor::new("#12345").is_err()); // Wrong length
}
}

View File

@@ -0,0 +1,29 @@
use crate::domain::error::ConfigError;
use config::Config as Configuration;
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
pub server_host: String,
pub server_port: u16,
pub qr_default_color: String,
}
impl AppConfig {
pub fn load() -> Result<Self, ConfigError> {
let builder = Configuration::builder()
.set_default("server_host", "0.0.0.0")
.map_err(|e| ConfigError::InvalidOption(e.to_string()))?
.set_default("server_port", 3000)
.map_err(|e| ConfigError::InvalidInteger(e.to_string()))?
.set_default("qr_default_color", "#000000")
.map_err(|e| ConfigError::InvalidOption(e.to_string()))?
.add_source(config::Environment::default());
builder
.build()
.map_err(|e| ConfigError::EnvVarMissing(e.to_string()))?
.try_deserialize()
.map_err(|e| ConfigError::InvalidOption(e.to_string()))
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,28 @@
use crate::application::generate_qr::GenerateQrUseCase;
use crate::domain::qr::QrOptions;
use crate::infrastructure::http::views::{index_page, qr_component};
use axum::{
extract::{Query, State},
response::IntoResponse,
};
use std::collections::HashMap;
use std::sync::Arc;
pub async fn index() -> impl IntoResponse {
index_page()
}
pub async fn generate(
State(use_case): State<Arc<GenerateQrUseCase>>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let data = params.get("data").cloned().unwrap_or_default();
// Simple options for now, can be extended to parse from params
let options = QrOptions::default();
match use_case.execute(data, options).await {
Ok(image_data) => qr_component(&image_data).into_response(),
Err(e) => format!("<div class='error'>Error: {}</div>", e).into_response(),
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -0,0 +1,3 @@
pub mod handlers;
pub mod server;
pub mod views;

View File

@@ -0,0 +1,36 @@
use crate::application::generate_qr::GenerateQrUseCase;
use crate::ports::HttpServer;
use async_trait::async_trait;
use axum::{routing::get, Router};
use std::net::SocketAddr;
use std::sync::Arc;
use super::handlers::{generate, index};
pub struct AxumServer {
use_case: Arc<GenerateQrUseCase>,
}
impl AxumServer {
pub fn new(use_case: Arc<GenerateQrUseCase>) -> Self {
Self { use_case }
}
}
#[async_trait]
impl HttpServer for AxumServer {
async fn run(&self, host: &str, port: u16) -> anyhow::Result<()> {
let app = Router::new()
.route("/", get(index))
.route("/api/qr", get(generate))
.with_state(self.use_case.clone());
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
tracing::info!("Server listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
}

View File

@@ -0,0 +1,250 @@
:root {
/* K-QR Color Palette */
--carbon-black: #1D1D1D;
--dim-grey: #656565;
--black: #000000;
--tiger-orange: #E57E1D;
/* Semantic mapping */
--primary: var(--tiger-orange);
--primary-hover: #cc6e18;
--bg: var(--black);
--card-bg: var(--carbon-black);
--text: #ffffff;
--text-muted: #a0a0a0;
--border: #3a3a3a;
--input-bg: #0a0a0a;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
}
.container {
background: var(--card-bg);
padding: 2.5rem;
border-radius: 20px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8);
width: 100%;
max-width: 480px;
text-align: center;
border: 1px solid var(--border);
}
.logo {
width: 100px;
height: 100px;
margin: 0 auto 1.25rem;
border-radius: 20px;
display: block;
}
h1 {
font-weight: 700;
font-size: 2.25rem;
margin: 0 0 0.5rem;
color: var(--tiger-orange);
}
.subtitle {
color: var(--text-muted);
font-size: 1rem;
margin: 0 0 2rem;
font-weight: 400;
}
.input-group {
text-align: center;
margin-bottom: 1.5rem;
}
label {
display: block;
font-size: 1rem;
font-weight: 500;
margin-bottom: 0.75rem;
color: var(--text);
}
/* Input styling - targeted specifically */
#data,
input[type="text"],
.input-group input {
width: 100%;
padding: 1rem 1.25rem;
border: 2px solid var(--tiger-orange);
border-radius: 14px;
font-size: 1.125rem;
font-family: inherit;
background-color: var(--input-bg);
color: var(--text);
transition: all 0.2s ease;
text-align: center;
outline: none;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
#data::placeholder,
input[type="text"]::placeholder {
color: var(--text-muted);
opacity: 1;
}
#data:focus,
input[type="text"]:focus {
border-color: var(--tiger-orange);
box-shadow: 0 0 0 4px rgba(229, 126, 29, 0.3);
background-color: #111;
}
button {
width: 100%;
background: var(--tiger-orange);
color: white;
padding: 1rem;
border: none;
border-radius: 14px;
font-size: 1.125rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 0.5rem;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
button:hover {
background: var(--primary-hover);
transform: translateY(-2px);
box-shadow: 0 12px 24px -8px rgba(229, 126, 29, 0.5);
}
button:active {
transform: translateY(0);
}
.htmx-indicator {
display: none;
margin-top: 1.25rem;
font-size: 1rem;
color: var(--text-muted);
}
.htmx-request .htmx-indicator {
display: block;
}
.htmx-request button {
opacity: 0.7;
pointer-events: none;
}
.qr-result {
margin-top: 2rem;
animation: fadeIn 0.4s ease-out;
}
.qr-result img {
max-width: 100%;
width: 220px;
height: 220px;
border-radius: 16px;
border: 2px solid var(--border);
background: white;
padding: 12px;
}
.error {
color: #ff6b6b;
padding: 1.25rem;
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
border-radius: 14px;
margin-top: 1.25rem;
font-size: 1rem;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive Design */
@media (max-width: 480px) {
body {
padding: 0.75rem;
align-items: flex-start;
padding-top: 2rem;
}
.container {
padding: 1.75rem;
border-radius: 16px;
}
.logo {
width: 72px;
height: 72px;
}
h1 {
font-size: 1.75rem;
}
.subtitle {
font-size: 0.9rem;
}
#data,
input[type="text"],
button {
padding: 0.875rem;
font-size: 1rem;
}
.qr-result img {
width: 180px;
height: 180px;
}
}
@media (min-width: 768px) {
.container {
padding: 3rem;
}
.logo {
width: 120px;
height: 120px;
}
h1 {
font-size: 2.5rem;
}
}

View File

@@ -0,0 +1,67 @@
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use maud::{html, Markup, PreEscaped, DOCTYPE};
const LOGO_BYTES: &[u8] = include_bytes!("logo.png");
const FAVICON_BYTES: &[u8] = include_bytes!("favicon.ico");
pub fn layout(title: &str, content: Markup) -> Markup {
let favicon = STANDARD.encode(FAVICON_BYTES);
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
title { (title) }
link rel="icon" type="image/x-icon" href=(format!("data:image/x-icon;base64,{}", favicon));
link rel="preconnect" href="https://fonts.googleapis.com";
link rel="preconnect" href="https://fonts.gstatic.com" crossorigin;
link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet";
script src="https://unpkg.com/htmx.org@1.9.10" {}
style {
(PreEscaped(include_str!("style.css")))
}
}
body {
(content)
}
}
}
}
fn logo_base64() -> String {
STANDARD.encode(LOGO_BYTES)
}
pub fn index_page() -> Markup {
let logo = logo_base64();
layout(
"K-QR - QR Code Generator",
html! {
div.container {
img.logo src=(format!("data:image/png;base64,{}", logo)) alt="K-QR Logo";
h1 { "K-QR" }
p.subtitle { "Generate QR codes instantly" }
form hx-get="/api/qr" hx-target="#result" hx-indicator=".container" {
div.input-group {
label for="data" { "Enter URL or text" }
input type="text" name="data" id="data" placeholder="https://example.com" required autocomplete="off";
}
button type="submit" { "Generate QR Code" }
}
div id="loading" class="htmx-indicator" { "Generating..." }
div id="result" {}
}
},
)
}
pub fn qr_component(image_data: &[u8]) -> Markup {
let base64_str = STANDARD.encode(image_data);
html! {
div.qr-result {
img src=(format!("data:image/png;base64,{}", base64_str)) alt="Generated QR Code";
}
}
}

View File

@@ -0,0 +1,3 @@
pub mod config;
pub mod http;
pub mod qr_adapter;

View File

@@ -0,0 +1,28 @@
use crate::domain::{
error::QrError,
qr::{QrData, QrOptions},
};
use crate::ports::QrCodeGenerator;
use async_trait::async_trait;
use image::Luma;
use qrcode::QrCode;
use std::io::Cursor;
pub struct QrCodeAdapter;
#[async_trait]
impl QrCodeGenerator for QrCodeAdapter {
async fn generate(&self, data: &QrData, _options: &QrOptions) -> Result<Vec<u8>, QrError> {
let qr =
QrCode::new(data.as_str()).map_err(|e| QrError::GenerationFailed(e.to_string()))?;
let qr_image = qr.render::<Luma<u8>>().build();
let mut buffer: Vec<u8> = Vec::new();
qr_image
.write_to(&mut Cursor::new(&mut buffer), image::ImageFormat::Png)
.map_err(|e| QrError::GenerationFailed(e.to_string()))?;
Ok(buffer)
}
}

4
backend/src/lib.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub mod ports;

View File

@@ -1,92 +1,30 @@
use std::collections::HashMap;
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use axum::{
debug_handler,
extract::Query,
http::{header::CONTENT_TYPE, Method, StatusCode},
response::IntoResponse,
routing::get,
Router,
use k_qr::application::generate_qr::GenerateQrUseCase;
use k_qr::infrastructure::{
config::AppConfig, http::server::AxumServer, qr_adapter::QrCodeAdapter,
};
use image::{png::PngEncoder, ColorType, Luma};
use maud::{html, Markup};
use tower_http::cors::{Any, CorsLayer};
use k_qr::ports::HttpServer;
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(index))
.route("/qr", get(get_qr_code))
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET]),
);
async fn main() -> anyhow::Result<()> {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "k_qr=debug".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
let config = AppConfig::load()?;
tracing::info!("Configuration loaded: {:?}", config);
axum::serve(listener, app).await.unwrap();
}
#[debug_handler]
async fn index() -> Markup {
html! {
html {
head {
title { "QR Code Generator" }
}
body {
h1 { "QR Code Generator" }
form action="/qr" method="get" {
label for="link" { "Value: " }
input type="text" name="link" id="link" required;
input type="submit" value="Generate QR Code";
}
}
}
}
}
async fn get_qr_code(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
let link = match params.get("link") {
Some(l) => l,
None => {
return (
StatusCode::BAD_REQUEST,
[(CONTENT_TYPE, "text/plain")],
"Missing link",
)
.into_response();
}
};
let qr_code = match qrcode::QrCode::new(link) {
Ok(qr) => qr,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
[(CONTENT_TYPE, "text/plain")],
"Invalid link",
)
.into_response()
}
};
let qr_image = qr_code.render::<Luma<u8>>().build();
let mut buffer: Vec<u8> = Vec::new();
let width = qr_image.width();
let height = qr_image.height();
let encoder = PngEncoder::new(&mut buffer);
match encoder.encode(&qr_image.into_raw(), width, height, ColorType::L8) {
Ok(_) => (),
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
[(CONTENT_TYPE, "text/plain")],
"Failed to encode QR code",
)
.into_response()
}
}
(StatusCode::OK, [(CONTENT_TYPE, "image/png")], buffer).into_response()
let qr_adapter = Arc::new(QrCodeAdapter);
let generate_use_case = Arc::new(GenerateQrUseCase::new(qr_adapter));
let server: Box<dyn HttpServer> = Box::new(AxumServer::new(generate_use_case));
server.run(&config.server_host, config.server_port).await?;
Ok(())
}

18
backend/src/ports/mod.rs Normal file
View File

@@ -0,0 +1,18 @@
use crate::domain::{
error::QrError,
qr::{QrData, QrOptions},
};
use async_trait::async_trait;
/// Port for QR code generation
#[cfg_attr(test, mockall::automock)]
#[async_trait]
pub trait QrCodeGenerator: Send + Sync {
async fn generate(&self, data: &QrData, options: &QrOptions) -> Result<Vec<u8>, QrError>;
}
/// Port for HTTP server - abstracts the web framework
#[async_trait]
pub trait HttpServer: Send + Sync {
async fn run(&self, host: &str, port: u16) -> anyhow::Result<()>;
}