feat: Implement a new layered architecture for the QR generator, integrating Axum, Maud, and HTMX, and updating documentation.
This commit is contained in:
79
README.md
79
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
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
- **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
|
||||
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
|
||||
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]
|
||||
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"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -10,14 +9,32 @@ 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"]
|
||||
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::{
|
||||
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
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:
|
||||
backend:
|
||||
build: ./backend
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
image: registry.gabrielkaszewski.dev/k-qr:latest
|
||||
container_name: k-qr
|
||||
|
||||
ports:
|
||||
- '8000:80'
|
||||
volumes:
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- "${SERVER_PORT:-3000}:3000"
|
||||
environment:
|
||||
- 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