feat: Implement a new layered architecture for the QR generator, integrating Axum, Maud, and HTMX, and updating documentation.
This commit is contained in:
75
README.md
75
README.md
@@ -1,17 +1,80 @@
|
|||||||
# QR Code generator
|
# K-QR - Rust QR Code Generator
|
||||||
|
|
||||||
Simple qr code generator written in Rust
|
A lightweight, high-performance QR code generator built with Rust, Axum, Maud, and HTMX.
|
||||||
|
|
||||||
# Build
|
## Features
|
||||||
|
|
||||||
|
- **Blazing Fast**: Built with Rust for maximum performance.
|
||||||
|
- **Embedded UI**: The web interface is completely embedded in the binary.
|
||||||
|
- **Interactive**: Uses HTMX for seamless, single-page-like experience without complex JS frameworks.
|
||||||
|
- **Configurable**: Easily customize host, port, and default styles via environment variables.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend**: Rust / Axum
|
||||||
|
- **Templating**: Maud (Type-safe HTML)
|
||||||
|
- **Frontend Interactivity**: HTMX
|
||||||
|
- **QR Generation**: `qrcode` crate
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The application can be configured using environment variables:
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `SERVER_HOST` | Network interface to bind to | `0.0.0.0` |
|
||||||
|
| `SERVER_PORT` | Port to listen on | `3000` |
|
||||||
|
| `QR_DEFAULT_COLOR` | Default color for generated QR codes (HEX) | `#000000` |
|
||||||
|
| `RUST_LOG` | Logging level (`debug`, `info`, `warn`, `error`) | `qr_generator=debug` |
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Rust](https://rustup.rs/) (latest stable)
|
||||||
|
- [Docker](https://www.docker.com/) (optional, for containerized deployment)
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
```bash
|
```bash
|
||||||
cargo build --release
|
git clone <repository-url>
|
||||||
|
cd qr-generator
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2. Run the backend:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Open your browser at `http://localhost:3000`.
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
Use Docker Compose to spin up the application:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up --build -d
|
docker-compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
# License
|
Or pull the pre-built image:
|
||||||
|
|
||||||
MIT
|
```bash
|
||||||
|
docker run -d -p 3000:3000 --name k-qr registry.gabrielkaszewski.dev/k-qr:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Usage
|
||||||
|
|
||||||
|
You can also generate QR codes directly via the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/qr?data=https://github.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `data` (required): The URL or text to encode in the QR code.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
1
backend/.dockerignore
Normal file
1
backend/.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
2299
backend/Cargo.lock
generated
2299
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,24 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "qr-generator"
|
name = "k-qr"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7.5", features = ["macros"] }
|
axum = { version = "0.8.8", features = ["macros"] }
|
||||||
tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
qrcode = "0.12.0"
|
maud = { version = "0.27", features = ["axum"] }
|
||||||
image = "0.23.14"
|
qrcode = "0.14"
|
||||||
maud = { version = "0.26.0", features = ["axum"] }
|
image = "0.25"
|
||||||
tower-http = { version = "0.5.2", features = ["cors"] }
|
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"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
FROM rust:1 AS chef
|
FROM rust:1-slim AS chef
|
||||||
# We only pay the installation cost once,
|
|
||||||
# it will be cached from the second build onwards
|
|
||||||
RUN cargo install cargo-chef
|
RUN cargo install cargo-chef
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -10,14 +9,32 @@ RUN cargo chef prepare --recipe-path recipe.json
|
|||||||
|
|
||||||
FROM chef AS builder
|
FROM chef AS builder
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
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
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
# Build application
|
# Build application
|
||||||
COPY . .
|
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
|
FROM debian:bookworm-slim AS runtime
|
||||||
WORKDIR /app
|
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"]
|
||||||
53
backend/src/application/generate_qr.rs
Normal file
53
backend/src/application/generate_qr.rs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
backend/src/application/mod.rs
Normal file
1
backend/src/application/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod generate_qr;
|
||||||
23
backend/src/domain/error.rs
Normal file
23
backend/src/domain/error.rs
Normal 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),
|
||||||
|
}
|
||||||
2
backend/src/domain/mod.rs
Normal file
2
backend/src/domain/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod error;
|
||||||
|
pub mod qr;
|
||||||
125
backend/src/domain/qr.rs
Normal file
125
backend/src/domain/qr.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/src/infrastructure/config.rs
Normal file
29
backend/src/infrastructure/config.rs
Normal 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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
backend/src/infrastructure/http/favicon.ico
Normal file
BIN
backend/src/infrastructure/http/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
28
backend/src/infrastructure/http/handlers.rs
Normal file
28
backend/src/infrastructure/http/handlers.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
backend/src/infrastructure/http/logo.png
Normal file
BIN
backend/src/infrastructure/http/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
3
backend/src/infrastructure/http/mod.rs
Normal file
3
backend/src/infrastructure/http/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod handlers;
|
||||||
|
pub mod server;
|
||||||
|
pub mod views;
|
||||||
36
backend/src/infrastructure/http/server.rs
Normal file
36
backend/src/infrastructure/http/server.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
250
backend/src/infrastructure/http/style.css
Normal file
250
backend/src/infrastructure/http/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
backend/src/infrastructure/http/views.rs
Normal file
67
backend/src/infrastructure/http/views.rs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
backend/src/infrastructure/mod.rs
Normal file
3
backend/src/infrastructure/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod http;
|
||||||
|
pub mod qr_adapter;
|
||||||
28
backend/src/infrastructure/qr_adapter.rs
Normal file
28
backend/src/infrastructure/qr_adapter.rs
Normal 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
4
backend/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod application;
|
||||||
|
pub mod domain;
|
||||||
|
pub mod infrastructure;
|
||||||
|
pub mod ports;
|
||||||
@@ -1,92 +1,30 @@
|
|||||||
use std::collections::HashMap;
|
use std::sync::Arc;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use axum::{
|
use k_qr::application::generate_qr::GenerateQrUseCase;
|
||||||
debug_handler,
|
use k_qr::infrastructure::{
|
||||||
extract::Query,
|
config::AppConfig, http::server::AxumServer, qr_adapter::QrCodeAdapter,
|
||||||
http::{header::CONTENT_TYPE, Method, StatusCode},
|
|
||||||
response::IntoResponse,
|
|
||||||
routing::get,
|
|
||||||
Router,
|
|
||||||
};
|
};
|
||||||
use image::{png::PngEncoder, ColorType, Luma};
|
use k_qr::ports::HttpServer;
|
||||||
use maud::{html, Markup};
|
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let app = Router::new()
|
tracing_subscriber::registry()
|
||||||
.route("/", get(index))
|
.with(tracing_subscriber::EnvFilter::new(
|
||||||
.route("/qr", get(get_qr_code))
|
std::env::var("RUST_LOG").unwrap_or_else(|_| "k_qr=debug".into()),
|
||||||
.layer(
|
))
|
||||||
CorsLayer::new()
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.allow_origin(Any)
|
.init();
|
||||||
.allow_methods([Method::GET]),
|
|
||||||
);
|
|
||||||
|
|
||||||
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();
|
let qr_adapter = Arc::new(QrCodeAdapter);
|
||||||
}
|
let generate_use_case = Arc::new(GenerateQrUseCase::new(qr_adapter));
|
||||||
|
|
||||||
#[debug_handler]
|
let server: Box<dyn HttpServer> = Box::new(AxumServer::new(generate_use_case));
|
||||||
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 {
|
server.run(&config.server_host, config.server_port).await?;
|
||||||
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(())
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
18
backend/src/ports/mod.rs
Normal file
18
backend/src/ports/mod.rs
Normal 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<()>;
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build:
|
||||||
nginx:
|
context: ./backend
|
||||||
image: nginx:latest
|
dockerfile: Dockerfile
|
||||||
|
image: registry.gabrielkaszewski.dev/k-qr:latest
|
||||||
|
container_name: k-qr
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- '8000:80'
|
- "${SERVER_PORT:-3000}:3000"
|
||||||
volumes:
|
environment:
|
||||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
|
- SERVER_HOST=0.0.0.0
|
||||||
|
- SERVER_PORT=3000
|
||||||
|
- QR_DEFAULT_COLOR=${QR_DEFAULT_COLOR:-#000000}
|
||||||
|
- RUST_LOG=info
|
||||||
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://backend:3000/;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user