Compare commits
13 Commits
c10b850982
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 11e75f9bb4 | |||
| 3fa46a4d58 | |||
| 6dc9b26dfc | |||
| 6183262ed5 | |||
| e3552fd50d | |||
| 5b0d5bf15d | |||
| 1c5ae5d239 | |||
| 15e81c88d1 | |||
| 5d926e0f61 | |||
| 4cab050ee8 | |||
| 531b8f6eae | |||
| ed5e238a9c | |||
| fa867a837f |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,3 +1,10 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
.env
|
||||
*.db
|
||||
data.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.idea/
|
||||
.vscode/
|
||||
**/dev.db
|
||||
docs/
|
||||
|
||||
1638
Cargo.lock
generated
1638
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
41
Cargo.toml
41
Cargo.toml
@@ -1,3 +1,42 @@
|
||||
[workspace]
|
||||
members = ["domain", "infra", "api"]
|
||||
members = [
|
||||
"crates/domain",
|
||||
"crates/application",
|
||||
"crates/api-types",
|
||||
"crates/adapters/sqlite",
|
||||
"crates/adapters/postgres",
|
||||
"crates/adapters/auth",
|
||||
"crates/adapters/storage",
|
||||
"crates/presentation",
|
||||
"crates/bootstrap",
|
||||
"crates/worker",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net", "time", "sync"] }
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
bytes = "1.0"
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dotenvy = "0.15"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "uuid", "chrono", "macros"] }
|
||||
jsonwebtoken = "9.3"
|
||||
bcrypt = "0.15"
|
||||
utoipa = { version = "5.3", features = ["axum_extras", "uuid", "chrono"] }
|
||||
utoipa-scalar = { version = "0.3", features = ["axum"] }
|
||||
domain = { path = "crates/domain" }
|
||||
application = { path = "crates/application" }
|
||||
api-types = { path = "crates/api-types" }
|
||||
adapters-auth = { path = "crates/adapters/auth" }
|
||||
adapters-storage = { path = "crates/adapters/storage" }
|
||||
presentation = { path = "crates/presentation" }
|
||||
|
||||
42
Cargo.toml.liquid
Normal file
42
Cargo.toml.liquid
Normal file
@@ -0,0 +1,42 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/domain",
|
||||
"crates/application",
|
||||
"crates/api-types",
|
||||
{% if database == "sqlite" %}"crates/adapters/sqlite",{% endif %}
|
||||
{% if database == "postgres" %}"crates/adapters/postgres",{% endif %}
|
||||
"crates/adapters/auth",
|
||||
{% if storage %}"crates/adapters/storage",{% endif %}
|
||||
"crates/presentation",
|
||||
"crates/bootstrap",
|
||||
{% if worker %}"crates/worker",{% endif %}
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net", "time", "sync"] }
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
bytes = "1.0"
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dotenvy = "0.15"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "uuid", "chrono", "macros"] }
|
||||
jsonwebtoken = "9.3"
|
||||
bcrypt = "0.15"
|
||||
utoipa = { version = "5.3", features = ["axum_extras", "uuid", "chrono"] }
|
||||
utoipa-scalar = { version = "0.3", features = ["axum"] }
|
||||
domain = { path = "crates/domain" }
|
||||
application = { path = "crates/application" }
|
||||
api-types = { path = "crates/api-types" }
|
||||
adapters-auth = { path = "crates/adapters/auth" }
|
||||
{% if storage %}adapters-storage = { path = "crates/adapters/storage" }{% endif %}
|
||||
presentation = { path = "crates/presentation" }
|
||||
61
Dockerfile
61
Dockerfile
@@ -1,27 +1,54 @@
|
||||
FROM rust:1.92 AS builder
|
||||
# ----- build -----
|
||||
FROM rust:slim-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
WORKDIR /build
|
||||
|
||||
# Build the release binary
|
||||
RUN cargo build --release -p api
|
||||
# Copy manifests + lockfile first so cargo can fetch deps as a cached layer.
|
||||
# Source changes below won't invalidate this layer.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
|
||||
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
||||
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
|
||||
COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml
|
||||
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
|
||||
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
||||
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
|
||||
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
|
||||
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
|
||||
|
||||
# Stub every crate so cargo can resolve and fetch deps without real source
|
||||
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
|
||||
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && printf "" > {}/src/lib.rs'
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN cargo fetch
|
||||
|
||||
# Copy sqlx offline query cache — no live database needed at compile time
|
||||
COPY crates/adapters/sqlite/.sqlx ./crates/adapters/sqlite/.sqlx
|
||||
|
||||
# Now copy real source — only invalidates cache on source changes
|
||||
COPY crates ./crates
|
||||
|
||||
ENV SQLX_OFFLINE=true
|
||||
RUN cargo build --release -p bootstrap -p worker
|
||||
|
||||
# ----- runtime -----
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL (required for many Rust networking crates) and CA certificates
|
||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/api .
|
||||
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
ENV DATABASE_URL=sqlite:///app/data/template.db
|
||||
ENV SESSION_SECRET=supersecretchangeinproduction
|
||||
COPY --from=builder /build/target/release/server ./server
|
||||
COPY --from=builder /build/target/release/worker ./worker
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["./api"]
|
||||
ENV RUST_LOG=bootstrap=info,tower_http=info
|
||||
|
||||
CMD ["./server"]
|
||||
|
||||
56
Dockerfile.liquid
Normal file
56
Dockerfile.liquid
Normal file
@@ -0,0 +1,56 @@
|
||||
# ----- build -----
|
||||
FROM rust:slim-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy manifests + lockfile first so cargo can fetch deps as a cached layer.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
|
||||
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
||||
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
|
||||
{% if database == "sqlite" %}COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml
|
||||
{% endif %}{% if database == "postgres" %}COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
|
||||
{% endif %}COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
||||
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
|
||||
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
|
||||
{% if worker %}COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
|
||||
{% endif %}
|
||||
# Stub every crate so cargo can resolve and fetch deps without real source
|
||||
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
|
||||
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && printf "" > {}/src/lib.rs'
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN cargo fetch
|
||||
|
||||
# For sqlx compile-time query verification run `cargo sqlx prepare` locally first,
|
||||
# then commit the .sqlx/ cache. Or pass DATABASE_URL as a build arg:
|
||||
# docker build --build-arg DATABASE_URL=<url> .
|
||||
ARG DATABASE_URL
|
||||
ENV SQLX_OFFLINE=${DATABASE_URL:+false}
|
||||
ENV SQLX_OFFLINE=${SQLX_OFFLINE:-true}
|
||||
|
||||
# Now copy real source — only invalidates cache on source changes
|
||||
COPY crates ./crates
|
||||
|
||||
RUN cargo build --release -p bootstrap{% if worker %} -p worker{% endif %}
|
||||
|
||||
# ----- runtime -----
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/target/release/{{project_name}} ./server
|
||||
{% if worker %}COPY --from=builder /build/target/release/{{project_name}}-worker ./worker
|
||||
{% endif %}
|
||||
EXPOSE 3000
|
||||
|
||||
ENV RUST_LOG={{project_name}}=info,tower_http=info
|
||||
|
||||
CMD ["./server"]
|
||||
260
README.md
260
README.md
@@ -1,209 +1,137 @@
|
||||
# k-template
|
||||
|
||||
A production-ready, modular Rust API template for K-Suite applications, following Hexagonal Architecture principles.
|
||||
A cargo-generate template for personal Rust web services. Gives you auth, persistence, logging, CORS, and API docs out of the box so you can start writing domain code immediately.
|
||||
|
||||
## Features
|
||||
Follows the same hexagonal/ports-and-adapters architecture used in [thoughts](https://git.gabrielkaszewski.dev/GKaszewski/thoughts) and [movies-diary](https://git.gabrielkaszewski.dev/GKaszewski/movies-diary).
|
||||
|
||||
- **Hexagonal Architecture**: Clear separation between Domain, Infrastructure, and API layers
|
||||
- **JWT-Only Authentication**: Stateless Bearer token auth — no server-side sessions
|
||||
- **OIDC Integration**: Connect to any OpenID Connect provider (Keycloak, Auth0, Zitadel, etc.) with stateless cookie-based flow state
|
||||
- **Database Flexibility**: SQLite (default) or PostgreSQL via feature flags
|
||||
- **Type-Safe Domain**: Newtypes with built-in validation for emails, passwords, secrets, and OIDC values
|
||||
- **Cargo Generate Ready**: Pre-configured for scaffolding new services
|
||||
## What you get
|
||||
|
||||
## Quick Start
|
||||
- **Full hexagonal architecture** — `domain` → `application` → `adapters` → `presentation` → `bootstrap`, each as a separate crate with clear boundaries
|
||||
- **JWT auth wired end-to-end** — register, login, and `GET /auth/me` working from day one
|
||||
- **SQLite or PostgreSQL** — chosen at generation time, migrations included
|
||||
- **CORS + structured logging** — tower-http middleware configured in bootstrap
|
||||
- **Scalar API docs** at `/scalar`, OpenAPI JSON at `/api-docs/openapi.json`
|
||||
- **Optional worker binary** — tokio-based background job runner with an example job
|
||||
- **Optional OIDC stub** — placeholder adapter for OAuth2/OpenID Connect flows
|
||||
- **Docker-ready** — multi-stage Dockerfile with dependency layer caching, no live DB needed at build time
|
||||
|
||||
### Option 1: Use cargo-generate (Recommended)
|
||||
## Generate a new project
|
||||
|
||||
```bash
|
||||
cargo generate --git https://github.com/GKaszewski/k-template.git
|
||||
cargo generate --git https://git.gabrielkaszewski.dev/GKaszewski/k-template.git
|
||||
```
|
||||
|
||||
You'll be prompted to choose:
|
||||
- **Project name**: Your new service name
|
||||
- **Database**: `sqlite` or `postgres`
|
||||
- **JWT auth**: Enable Bearer token authentication
|
||||
- **OIDC**: Enable OpenID Connect integration
|
||||
You'll be prompted for:
|
||||
|
||||
### Option 2: Clone directly
|
||||
| Option | Choices | Default |
|
||||
|--------|---------|---------|
|
||||
| `project_name` | any snake_case string | — |
|
||||
| `database` | `sqlite`, `postgres` | `sqlite` |
|
||||
| `worker` | bool | false |
|
||||
| `auth_oidc` | bool | false |
|
||||
|
||||
## Generated project structure
|
||||
|
||||
```
|
||||
crates/
|
||||
domain/ pure Rust — entities, value objects, port traits, errors
|
||||
application/ use cases (RegisterUser, LoginUser, GetProfile) + test fakes
|
||||
api-types/ shared request/response DTOs with OpenAPI derives
|
||||
adapters/
|
||||
sqlite/ sqlx SQLite UserRepository + migrations
|
||||
postgres/ sqlx PostgreSQL UserRepository + migrations
|
||||
auth/ BcryptPasswordHasher, JwtTokenIssuer, OidcAdapter stub
|
||||
presentation/ axum handlers, JwtClaims extractor, routes, Scalar mount
|
||||
bootstrap/ Config from env, factory wiring, main entry point
|
||||
worker/ (optional) Job trait, JobRunner, ExampleJob, WorkerConfig
|
||||
```
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
git clone https://github.com/GKaszewski/k-template.git my-api
|
||||
cd my-api
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
cargo run
|
||||
cargo run -p bootstrap
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:3000/api/v1/`.
|
||||
The server starts at `http://localhost:3000`.
|
||||
|
||||
## Configuration
|
||||
## Endpoints (out of the box)
|
||||
|
||||
All configuration is done via environment variables. See [.env.example](.env.example) for all options.
|
||||
|
||||
### Key Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `sqlite:data.db?mode=rwc` | Database connection string |
|
||||
| `COOKIE_SECRET` | *(insecure dev default)* | Secret for encrypting OIDC state cookie (≥64 bytes in production) |
|
||||
| `JWT_SECRET` | *(insecure dev default)* | Secret for signing JWT tokens (≥32 bytes in production) |
|
||||
| `JWT_EXPIRY_HOURS` | `24` | Token lifetime in hours |
|
||||
| `CORS_ALLOWED_ORIGINS` | `http://localhost:5173` | Comma-separated allowed origins |
|
||||
| `SECURE_COOKIE` | `false` | Set `true` when serving over HTTPS |
|
||||
| `PRODUCTION` | `false` | Enforces minimum secret lengths |
|
||||
|
||||
### OIDC Integration
|
||||
|
||||
To enable "Login with Google/Keycloak/etc.":
|
||||
|
||||
1. Enable the `auth-oidc` feature (on by default in cargo-generate)
|
||||
2. Set these environment variables:
|
||||
```env
|
||||
OIDC_ISSUER=https://your-provider.com
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_CLIENT_SECRET=your-secret
|
||||
OIDC_REDIRECT_URL=http://localhost:3000/api/v1/auth/callback
|
||||
```
|
||||
3. Users start the flow at `GET /api/v1/auth/login/oidc`
|
||||
|
||||
OIDC state (CSRF token, PKCE verifier, nonce) is stored in a short-lived encrypted cookie — no database session table required.
|
||||
|
||||
## Feature Flags
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["sqlite", "auth-jwt"]
|
||||
```
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| `sqlite` | SQLite database (default) |
|
||||
| `postgres` | PostgreSQL database |
|
||||
| `auth-jwt` | JWT Bearer token authentication |
|
||||
| `auth-oidc` | OpenID Connect integration |
|
||||
|
||||
### Common Configurations
|
||||
|
||||
**JWT-only (minimal, default)**:
|
||||
```toml
|
||||
default = ["sqlite", "auth-jwt"]
|
||||
```
|
||||
|
||||
**OIDC + JWT (typical SPA backend)**:
|
||||
```toml
|
||||
default = ["sqlite", "auth-oidc", "auth-jwt"]
|
||||
```
|
||||
|
||||
**PostgreSQL + OIDC + JWT**:
|
||||
```toml
|
||||
default = ["postgres", "auth-oidc", "auth-jwt"]
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| `POST` | `/api/v1/auth/register` | — | Register with email + password → JWT |
|
||||
| `POST` | `/api/v1/auth/login` | — | Login with email + password → JWT |
|
||||
| `POST` | `/api/v1/auth/logout` | — | Returns 200; client drops the token |
|
||||
| `GET` | `/api/v1/auth/me` | Bearer | Current user info |
|
||||
| `POST` | `/api/v1/auth/token` | Bearer | Issue a fresh JWT (`auth-jwt`) |
|
||||
| `GET` | `/api/v1/auth/login/oidc` | — | Start OIDC flow, sets encrypted state cookie (`auth-oidc`) |
|
||||
| `GET` | `/api/v1/auth/callback` | — | Complete OIDC flow → JWT, clears cookie (`auth-oidc`) |
|
||||
|
||||
### Example: Register and use a token
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `POST` | `/api/v1/auth/register` | — | Create account → `AuthResponse` |
|
||||
| `POST` | `/api/v1/auth/login` | — | Login → `AuthResponse` |
|
||||
| `GET` | `/api/v1/auth/me` | Bearer | Current user profile |
|
||||
| `GET` | `/health` | — | `{"status":"ok"}` |
|
||||
| `GET` | `/scalar` | — | Interactive API docs |
|
||||
| `GET` | `/api-docs/openapi.json` | — | OpenAPI spec |
|
||||
|
||||
```bash
|
||||
# Register
|
||||
curl -X POST http://localhost:3000/api/v1/auth/register \
|
||||
curl -s -X POST http://localhost:3000/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com", "password": "mypassword"}'
|
||||
# → {"access_token": "eyJ...", "token_type": "Bearer", "expires_in": 86400}
|
||||
-d '{"email":"me@example.com","password":"password123"}' | jq
|
||||
|
||||
# Use the token
|
||||
curl http://localhost:3000/api/v1/auth/me \
|
||||
-H "Authorization: Bearer eyJ..."
|
||||
# Login and get token
|
||||
TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"me@example.com","password":"password123"}' | jq -r '.token')
|
||||
|
||||
# Profile
|
||||
curl -s http://localhost:3000/api/v1/auth/me \
|
||||
-H "Authorization: Bearer $TOKEN" | jq
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
## Configuration
|
||||
|
||||
```
|
||||
k-template/
|
||||
├── domain/ # Pure business logic — zero I/O dependencies
|
||||
│ └── src/
|
||||
│ ├── entities.rs # User entity
|
||||
│ ├── value_objects.rs # Email, Password, JwtSecret, OIDC newtypes
|
||||
│ ├── repositories.rs # Repository interfaces (ports)
|
||||
│ ├── services.rs # Domain services
|
||||
│ └── errors.rs # DomainError (Unauthenticated 401, Forbidden 403, …)
|
||||
│
|
||||
├── infra/ # Infrastructure adapters
|
||||
│ └── src/
|
||||
│ ├── auth/
|
||||
│ │ ├── jwt.rs # JwtValidator — create + verify tokens
|
||||
│ │ └── oidc.rs # OidcService + OidcState (cookie-serializable)
|
||||
│ ├── user_repository.rs # SQLite / PostgreSQL adapter
|
||||
│ ├── db.rs # DatabasePool re-export
|
||||
│ └── factory.rs # build_user_repository()
|
||||
│
|
||||
├── api/ # HTTP layer
|
||||
│ └── src/
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.rs # Login, register, logout, me, OIDC flow
|
||||
│ │ └── config.rs # /config endpoint
|
||||
│ ├── config.rs # Config::from_env()
|
||||
│ ├── state.rs # AppState (user_service, cookie_key, jwt_validator, …)
|
||||
│ ├── extractors.rs # CurrentUser (JWT Bearer extractor)
|
||||
│ ├── error.rs # ApiError → HTTP status mapping
|
||||
│ └── dto.rs # LoginRequest, RegisterRequest, TokenResponse, …
|
||||
│
|
||||
├── migrations_sqlite/
|
||||
├── migrations_postgres/
|
||||
├── .env.example
|
||||
└── compose.yml # Docker Compose for local dev
|
||||
```
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `sqlite://data.db` | Database connection string |
|
||||
| `JWT_SECRET` | *(required)* | Signing secret — min 32 chars in production |
|
||||
| `HOST` | `0.0.0.0` | Bind address |
|
||||
| `PORT` | `3000` | Listen port |
|
||||
| `CORS_ALLOWED_ORIGINS` | `http://localhost:3000` | Comma-separated allowed origins |
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
cargo test
|
||||
|
||||
# Domain only
|
||||
cargo test -p domain
|
||||
|
||||
# Infra only (SQLite integration tests)
|
||||
cargo test -p infra
|
||||
# Unit tests (no DB required)
|
||||
cargo test -p domain -p application -p adapters-auth
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
13 unit tests cover email validation, use case logic (register/login/get_profile), bcrypt roundtrip, and JWT encode/verify.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# SQLite
|
||||
sqlx migrate run --source migrations_sqlite
|
||||
# Build
|
||||
docker build -t my-app .
|
||||
|
||||
# PostgreSQL
|
||||
sqlx migrate run --source migrations_postgres
|
||||
# Run
|
||||
docker run -p 3000:3000 \
|
||||
-e DATABASE_URL=sqlite:///data/app.db \
|
||||
-e JWT_SECRET=change-me-32-chars-minimum-here \
|
||||
my-app
|
||||
```
|
||||
|
||||
### Building with specific features
|
||||
Or with compose:
|
||||
|
||||
```bash
|
||||
# Minimal: SQLite + JWT only
|
||||
cargo build -F sqlite,auth-jwt
|
||||
|
||||
# Full: SQLite + JWT + OIDC
|
||||
cargo build -F sqlite,auth-jwt,auth-oidc
|
||||
|
||||
# PostgreSQL variant
|
||||
cargo build --no-default-features -F postgres,auth-jwt,auth-oidc
|
||||
docker compose up
|
||||
```
|
||||
|
||||
The Dockerfile uses dependency layer caching (manifests copied and fetched before source) so rebuilds after source-only changes are fast. No live database is needed at compile time — the `.sqlx` offline cache is committed.
|
||||
|
||||
## What to do after generating
|
||||
|
||||
1. Add your domain entities and value objects to `crates/domain/`
|
||||
2. Write use cases in `crates/application/`
|
||||
3. Add DB columns/tables via new migration files in `crates/adapters/sqlite/migrations/`
|
||||
4. Add handlers in `crates/presentation/src/handlers/`
|
||||
5. Wire new use cases in `crates/bootstrap/src/factory.rs`
|
||||
|
||||
Auth, CORS, logging, and docs are already done — focus on what makes your project unique.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
[package]
|
||||
name = "api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
default-run = "api"
|
||||
|
||||
[features]
|
||||
default = ["sqlite", "auth-jwt"]
|
||||
sqlite = ["infra/sqlite"]
|
||||
postgres = ["infra/postgres"]
|
||||
auth-oidc = ["infra/auth-oidc"]
|
||||
auth-jwt = ["infra/auth-jwt"]
|
||||
|
||||
[dependencies]
|
||||
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
|
||||
"logging",
|
||||
"db-sqlx",
|
||||
"sqlite",
|
||||
"http",
|
||||
] }
|
||||
domain = { path = "../domain" }
|
||||
infra = { path = "../infra", default-features = false, features = ["sqlite"] }
|
||||
|
||||
# Web framework
|
||||
axum = { version = "0.8.8", features = ["macros"] }
|
||||
axum-extra = { version = "0.10", features = ["cookie-private", "cookie-key-expansion"] }
|
||||
tower = "0.5.2"
|
||||
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2.0.17"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Utilities
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
|
||||
dotenvy = "0.15.7"
|
||||
time = "0.3"
|
||||
@@ -1,49 +0,0 @@
|
||||
[package]
|
||||
name = "{{project_name}}"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
default-run = "{{project_name}}"
|
||||
|
||||
[features]
|
||||
default = ["{{database}}"{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}]
|
||||
sqlite = ["infra/sqlite"]
|
||||
postgres = ["infra/postgres"]
|
||||
auth-oidc = ["infra/auth-oidc"]
|
||||
auth-jwt = ["infra/auth-jwt"]
|
||||
|
||||
[dependencies]
|
||||
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
|
||||
"logging",
|
||||
"db-sqlx",
|
||||
"{{database}}",
|
||||
"http",
|
||||
] }
|
||||
domain = { path = "../domain" }
|
||||
infra = { path = "../infra", default-features = false, features = ["{{database}}"] }
|
||||
|
||||
# Web framework
|
||||
axum = { version = "0.8.8", features = ["macros"] }
|
||||
axum-extra = { version = "0.10", features = ["cookie-private", "cookie-key-expansion"] }
|
||||
tower = "0.5.2"
|
||||
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2.0.17"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Utilities
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
|
||||
dotenvy = "0.15.7"
|
||||
time = "0.3"
|
||||
@@ -1,119 +0,0 @@
|
||||
//! Application Configuration
|
||||
//!
|
||||
//! Loads configuration from environment variables.
|
||||
|
||||
use std::env;
|
||||
|
||||
/// Application configuration loaded from environment variables
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub cookie_secret: String,
|
||||
pub cors_allowed_origins: Vec<String>,
|
||||
pub port: u16,
|
||||
pub host: String,
|
||||
pub secure_cookie: bool,
|
||||
pub db_max_connections: u32,
|
||||
pub db_min_connections: u32,
|
||||
|
||||
// OIDC configuration
|
||||
pub oidc_issuer: Option<String>,
|
||||
pub oidc_client_id: Option<String>,
|
||||
pub oidc_client_secret: Option<String>,
|
||||
pub oidc_redirect_url: Option<String>,
|
||||
pub oidc_resource_id: Option<String>,
|
||||
|
||||
// JWT configuration
|
||||
pub jwt_secret: Option<String>,
|
||||
pub jwt_issuer: Option<String>,
|
||||
pub jwt_audience: Option<String>,
|
||||
pub jwt_expiry_hours: u64,
|
||||
|
||||
/// Whether the application is running in production mode
|
||||
pub is_production: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port = env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(3000);
|
||||
|
||||
let database_url =
|
||||
env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:data.db?mode=rwc".to_string());
|
||||
|
||||
// Cookie secret for PrivateCookieJar (OIDC state encryption).
|
||||
// Must be at least 64 bytes in production.
|
||||
let cookie_secret = env::var("COOKIE_SECRET").unwrap_or_else(|_| {
|
||||
"k-template-cookie-secret-key-must-be-at-least-64-bytes-long!!".to_string()
|
||||
});
|
||||
|
||||
let cors_origins_str = env::var("CORS_ALLOWED_ORIGINS")
|
||||
.unwrap_or_else(|_| "http://localhost:5173".to_string());
|
||||
|
||||
let cors_allowed_origins = cors_origins_str
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
let secure_cookie = env::var("SECURE_COOKIE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(false);
|
||||
|
||||
let db_max_connections = env::var("DB_MAX_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(5);
|
||||
|
||||
let db_min_connections = env::var("DB_MIN_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1);
|
||||
|
||||
let oidc_issuer = env::var("OIDC_ISSUER").ok();
|
||||
let oidc_client_id = env::var("OIDC_CLIENT_ID").ok();
|
||||
let oidc_client_secret = env::var("OIDC_CLIENT_SECRET").ok();
|
||||
let oidc_redirect_url = env::var("OIDC_REDIRECT_URL").ok();
|
||||
let oidc_resource_id = env::var("OIDC_RESOURCE_ID").ok();
|
||||
|
||||
let jwt_secret = env::var("JWT_SECRET").ok();
|
||||
let jwt_issuer = env::var("JWT_ISSUER").ok();
|
||||
let jwt_audience = env::var("JWT_AUDIENCE").ok();
|
||||
let jwt_expiry_hours = env::var("JWT_EXPIRY_HOURS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(24);
|
||||
|
||||
let is_production = env::var("PRODUCTION")
|
||||
.or_else(|_| env::var("RUST_ENV"))
|
||||
.map(|v| v.to_lowercase() == "production" || v == "1" || v == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
Self {
|
||||
host,
|
||||
port,
|
||||
database_url,
|
||||
cookie_secret,
|
||||
cors_allowed_origins,
|
||||
secure_cookie,
|
||||
db_max_connections,
|
||||
db_min_connections,
|
||||
oidc_issuer,
|
||||
oidc_client_id,
|
||||
oidc_client_secret,
|
||||
oidc_redirect_url,
|
||||
oidc_resource_id,
|
||||
jwt_secret,
|
||||
jwt_issuer,
|
||||
jwt_audience,
|
||||
jwt_expiry_hours,
|
||||
is_production,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//! Request and Response DTOs
|
||||
//!
|
||||
//! Data Transfer Objects for the API.
|
||||
//! Uses domain newtypes for validation instead of the validator crate.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{Email, Password};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Login request with validated email and password newtypes
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
/// Email is validated on deserialization
|
||||
pub email: Email,
|
||||
/// Password is validated on deserialization (min 8 chars)
|
||||
pub password: Password,
|
||||
}
|
||||
|
||||
/// Register request with validated email and password newtypes
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
/// Email is validated on deserialization
|
||||
pub email: Email,
|
||||
/// Password is validated on deserialization (min 8 chars)
|
||||
pub password: Password,
|
||||
}
|
||||
|
||||
/// User response DTO
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// JWT token response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TokenResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
/// System configuration response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ConfigResponse {
|
||||
pub allow_registration: bool,
|
||||
}
|
||||
126
api/src/error.rs
126
api/src/error.rs
@@ -1,126 +0,0 @@
|
||||
//! API error handling
|
||||
//!
|
||||
//! Maps domain errors to HTTP responses
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use domain::DomainError;
|
||||
|
||||
/// API-level errors
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("{0}")]
|
||||
Domain(#[from] DomainError),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("Internal server error")]
|
||||
Internal(String),
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
}
|
||||
|
||||
/// Error response body
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error_response) = match &self {
|
||||
ApiError::Domain(domain_error) => {
|
||||
let status = match domain_error {
|
||||
DomainError::UserNotFound(_) => StatusCode::NOT_FOUND,
|
||||
|
||||
DomainError::UserAlreadyExists(_) => StatusCode::CONFLICT,
|
||||
|
||||
DomainError::ValidationError(_) => StatusCode::BAD_REQUEST,
|
||||
|
||||
// Unauthenticated = not logged in → 401
|
||||
DomainError::Unauthenticated(_) => StatusCode::UNAUTHORIZED,
|
||||
|
||||
// Forbidden = not allowed to perform action → 403
|
||||
DomainError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||
|
||||
DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
(
|
||||
status,
|
||||
ErrorResponse {
|
||||
error: domain_error.to_string(),
|
||||
details: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ApiError::Validation(msg) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
ErrorResponse {
|
||||
error: "Validation error".to_string(),
|
||||
details: Some(msg.clone()),
|
||||
},
|
||||
),
|
||||
|
||||
ApiError::Internal(msg) => {
|
||||
tracing::error!("Internal error: {}", msg);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
details: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ApiError::Forbidden(msg) => (
|
||||
StatusCode::FORBIDDEN,
|
||||
ErrorResponse {
|
||||
error: "Forbidden".to_string(),
|
||||
details: Some(msg.clone()),
|
||||
},
|
||||
),
|
||||
|
||||
ApiError::Unauthorized(msg) => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
ErrorResponse {
|
||||
error: "Unauthorized".to_string(),
|
||||
details: Some(msg.clone()),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
(status, Json(error_response)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn validation(msg: impl Into<String>) -> Self {
|
||||
Self::Validation(msg.into())
|
||||
}
|
||||
|
||||
pub fn internal(msg: impl Into<String>) -> Self {
|
||||
Self::Internal(msg.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type alias for API handlers
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
@@ -1,89 +0,0 @@
|
||||
//! Auth extractors for API handlers
|
||||
//!
|
||||
//! Provides the `CurrentUser` extractor that validates JWT Bearer tokens.
|
||||
|
||||
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||
use domain::User;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Extracted current user from the request.
|
||||
///
|
||||
/// Validates a JWT Bearer token from the `Authorization` header.
|
||||
pub struct CurrentUser(pub User);
|
||||
|
||||
impl FromRequestParts<AppState> for CurrentUser {
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
{
|
||||
return match try_jwt_auth(parts, state).await {
|
||||
Ok(user) => Ok(CurrentUser(user)),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "auth-jwt"))]
|
||||
{
|
||||
let _ = (parts, state);
|
||||
Err(ApiError::Unauthorized(
|
||||
"No authentication backend configured".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate using JWT Bearer token
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> {
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get(AUTHORIZATION)
|
||||
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
||||
|
||||
let auth_str = auth_header
|
||||
.to_str()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid Authorization header encoding".to_string()))?;
|
||||
|
||||
let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| {
|
||||
ApiError::Unauthorized("Authorization header must use Bearer scheme".to_string())
|
||||
})?;
|
||||
|
||||
let validator = state
|
||||
.jwt_validator
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::Internal("JWT validator not configured".to_string()))?;
|
||||
|
||||
let claims = validator.validate_token(token).map_err(|e| {
|
||||
tracing::debug!("JWT validation failed: {:?}", e);
|
||||
match e {
|
||||
infra::auth::jwt::JwtError::Expired => {
|
||||
ApiError::Unauthorized("Token expired".to_string())
|
||||
}
|
||||
infra::auth::jwt::JwtError::InvalidFormat => {
|
||||
ApiError::Unauthorized("Invalid token format".to_string())
|
||||
}
|
||||
_ => ApiError::Unauthorized("Token validation failed".to_string()),
|
||||
}
|
||||
})?;
|
||||
|
||||
let user_id: uuid::Uuid = claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?;
|
||||
|
||||
let user = state
|
||||
.user_service
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
//! API Server Entry Point
|
||||
//!
|
||||
//! Configures and starts the HTTP server with JWT-based authentication.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use axum::Router;
|
||||
use domain::UserService;
|
||||
use infra::factory::build_user_repository;
|
||||
use infra::run_migrations;
|
||||
use k_core::http::server::{ServerConfig, apply_standard_middleware};
|
||||
use k_core::logging;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
mod config;
|
||||
mod dto;
|
||||
mod error;
|
||||
mod extractors;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
logging::init("api");
|
||||
|
||||
let config = Config::from_env();
|
||||
|
||||
info!("Starting server on {}:{}", config.host, config.port);
|
||||
|
||||
// Setup database
|
||||
tracing::info!("Connecting to database: {}", config.database_url);
|
||||
|
||||
#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
|
||||
let db_type = k_core::db::DbType::Sqlite;
|
||||
|
||||
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
|
||||
let db_type = k_core::db::DbType::Postgres;
|
||||
|
||||
// Both features enabled: fall back to URL inspection at runtime
|
||||
#[cfg(all(feature = "sqlite", feature = "postgres"))]
|
||||
let db_type = if config.database_url.starts_with("postgres") {
|
||||
k_core::db::DbType::Postgres
|
||||
} else {
|
||||
k_core::db::DbType::Sqlite
|
||||
};
|
||||
|
||||
let db_config = k_core::db::DatabaseConfig {
|
||||
db_type,
|
||||
url: config.database_url.clone(),
|
||||
max_connections: config.db_max_connections,
|
||||
min_connections: config.db_min_connections,
|
||||
acquire_timeout: StdDuration::from_secs(30),
|
||||
};
|
||||
|
||||
let db_pool = k_core::db::connect(&db_config).await?;
|
||||
run_migrations(&db_pool).await?;
|
||||
|
||||
let user_repo = build_user_repository(&db_pool).await?;
|
||||
let user_service = UserService::new(user_repo);
|
||||
|
||||
let state = AppState::new(user_service, config.clone()).await?;
|
||||
|
||||
let server_config = ServerConfig {
|
||||
cors_origins: config.cors_allowed_origins.clone(),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api/v1", routes::api_v1_router())
|
||||
.with_state(state);
|
||||
|
||||
let app = apply_standard_middleware(app, &server_config);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
|
||||
tracing::info!("🚀 API server running at http://{}", addr);
|
||||
tracing::info!("🔒 Authentication mode: JWT (Bearer token)");
|
||||
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
tracing::info!(" ✓ JWT auth enabled");
|
||||
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
tracing::info!(" ✓ OIDC integration enabled (stateless cookie state)");
|
||||
|
||||
tracing::info!("📝 API endpoints available at /api/v1/...");
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
//! Authentication routes
|
||||
//!
|
||||
//! Provides login, register, logout, token, and OIDC endpoints.
|
||||
//! All authentication is JWT-based. OIDC state is stored in an encrypted cookie.
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Json, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dto::{LoginRequest, RegisterRequest, TokenResponse, UserResponse},
|
||||
error::ApiError,
|
||||
extractors::CurrentUser,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
let r = Router::new()
|
||||
.route("/login", post(login))
|
||||
.route("/register", post(register))
|
||||
.route("/logout", post(logout))
|
||||
.route("/me", get(me));
|
||||
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
let r = r.route("/token", post(get_token));
|
||||
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
let r = r
|
||||
.route("/login/oidc", get(oidc_login))
|
||||
.route("/callback", get(oidc_callback));
|
||||
|
||||
r
|
||||
}
|
||||
|
||||
/// Login with email + password → JWT token
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let user = state
|
||||
.user_service
|
||||
.find_by_email(payload.email.as_ref())
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?;
|
||||
|
||||
let hash = user
|
||||
.password_hash
|
||||
.as_deref()
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?;
|
||||
|
||||
if !infra::auth::verify_password(payload.password.as_ref(), hash) {
|
||||
return Err(ApiError::Unauthorized("Invalid credentials".to_string()));
|
||||
}
|
||||
|
||||
let token = create_jwt(&user, &state)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Register a new local user → JWT token
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let password_hash = infra::auth::hash_password(payload.password.as_ref());
|
||||
|
||||
let user = state
|
||||
.user_service
|
||||
.create_local(payload.email.as_ref(), &password_hash)
|
||||
.await?;
|
||||
|
||||
let token = create_jwt(&user, &state)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Logout — JWT is stateless; instruct the client to drop the token
|
||||
async fn logout() -> impl IntoResponse {
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
/// Get current user info from JWT
|
||||
async fn me(CurrentUser(user): CurrentUser) -> Result<impl IntoResponse, ApiError> {
|
||||
Ok(Json(UserResponse {
|
||||
id: user.id,
|
||||
email: user.email.into_inner(),
|
||||
created_at: user.created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Issue a new JWT for the currently authenticated user (OIDC→JWT exchange or token refresh)
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
async fn get_token(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let token = create_jwt(&user, &state)?;
|
||||
|
||||
Ok(Json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Helper: create JWT for a user
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
fn create_jwt(user: &domain::User, state: &AppState) -> Result<String, ApiError> {
|
||||
let validator = state
|
||||
.jwt_validator
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?;
|
||||
|
||||
validator
|
||||
.create_token(user)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create token: {}", e)))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "auth-jwt"))]
|
||||
fn create_jwt(_user: &domain::User, _state: &AppState) -> Result<String, ApiError> {
|
||||
Err(ApiError::Internal("JWT feature not enabled".to_string()))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Routes
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CallbackParams {
|
||||
code: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
/// Start OIDC login: generate authorization URL and store state in encrypted cookie
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
async fn oidc_login(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::PrivateCookieJar,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
use axum::http::header;
|
||||
use axum::response::Response;
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
|
||||
let service = state
|
||||
.oidc_service
|
||||
.as_ref()
|
||||
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
|
||||
|
||||
let (auth_data, oidc_state) = service.get_authorization_url();
|
||||
|
||||
let state_json = serde_json::to_string(&oidc_state)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to serialize OIDC state: {}", e)))?;
|
||||
|
||||
let cookie = Cookie::build(("oidc_state", state_json))
|
||||
.max_age(time::Duration::minutes(5))
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.secure(state.config.secure_cookie)
|
||||
.path("/")
|
||||
.build();
|
||||
|
||||
let updated_jar = jar.add(cookie);
|
||||
|
||||
let redirect = axum::response::Redirect::to(auth_data.url.as_str()).into_response();
|
||||
let (mut parts, body) = redirect.into_parts();
|
||||
parts.headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
"no-cache, no-store, must-revalidate".parse().unwrap(),
|
||||
);
|
||||
parts
|
||||
.headers
|
||||
.insert(header::PRAGMA, "no-cache".parse().unwrap());
|
||||
parts.headers.insert(header::EXPIRES, "0".parse().unwrap());
|
||||
|
||||
Ok((updated_jar, Response::from_parts(parts, body)))
|
||||
}
|
||||
|
||||
/// Handle OIDC callback: verify state cookie, complete exchange, issue JWT, clear cookie
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
async fn oidc_callback(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::PrivateCookieJar,
|
||||
axum::extract::Query(params): axum::extract::Query<CallbackParams>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
use infra::auth::oidc::OidcState;
|
||||
|
||||
let service = state
|
||||
.oidc_service
|
||||
.as_ref()
|
||||
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
|
||||
|
||||
// Read and decrypt OIDC state from cookie
|
||||
let cookie = jar
|
||||
.get("oidc_state")
|
||||
.ok_or(ApiError::Validation("Missing OIDC state cookie".into()))?;
|
||||
|
||||
let oidc_state: OidcState = serde_json::from_str(cookie.value())
|
||||
.map_err(|_| ApiError::Validation("Invalid OIDC state cookie".into()))?;
|
||||
|
||||
// Verify CSRF token
|
||||
if params.state != oidc_state.csrf_token.as_ref() {
|
||||
return Err(ApiError::Validation("Invalid CSRF token".into()));
|
||||
}
|
||||
|
||||
// Complete OIDC exchange
|
||||
let oidc_user = service
|
||||
.resolve_callback(
|
||||
domain::AuthorizationCode::new(params.code),
|
||||
oidc_state.nonce,
|
||||
oidc_state.pkce_verifier,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
let user = state
|
||||
.user_service
|
||||
.find_or_create(&oidc_user.subject, &oidc_user.email)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
// Clear the OIDC state cookie
|
||||
let cleared_jar = jar.remove(axum_extra::extract::cookie::Cookie::from("oidc_state"));
|
||||
|
||||
let token = create_jwt(&user, &state)?;
|
||||
|
||||
Ok((
|
||||
cleared_jar,
|
||||
Json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
use axum::{Json, Router, routing::get};
|
||||
use crate::dto::ConfigResponse;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/", get(get_config))
|
||||
}
|
||||
|
||||
async fn get_config() -> Json<ConfigResponse> {
|
||||
Json(ConfigResponse {
|
||||
allow_registration: true, // Default to true for template
|
||||
})
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//! API Routes
|
||||
//!
|
||||
//! Defines the API endpoints and maps them to handler functions.
|
||||
|
||||
use crate::state::AppState;
|
||||
use axum::Router;
|
||||
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
|
||||
/// Construct the API v1 router
|
||||
pub fn api_v1_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.nest("/auth", auth::router())
|
||||
.nest("/config", config::router())
|
||||
}
|
||||
116
api/src/state.rs
116
api/src/state.rs
@@ -1,116 +0,0 @@
|
||||
//! Application State
|
||||
//!
|
||||
//! Holds shared state for the application.
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use axum_extra::extract::cookie::Key;
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
use infra::auth::jwt::{JwtConfig, JwtValidator};
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
use infra::auth::oidc::OidcService;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::Config;
|
||||
use domain::UserService;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub user_service: Arc<UserService>,
|
||||
pub cookie_key: Key,
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
pub oidc_service: Option<Arc<OidcService>>,
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
pub jwt_validator: Option<Arc<JwtValidator>>,
|
||||
pub config: Arc<Config>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new(user_service: UserService, config: Config) -> anyhow::Result<Self> {
|
||||
let cookie_key = Key::derive_from(config.cookie_secret.as_bytes());
|
||||
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
let oidc_service = if let (Some(issuer), Some(id), secret, Some(redirect), resource_id) = (
|
||||
&config.oidc_issuer,
|
||||
&config.oidc_client_id,
|
||||
&config.oidc_client_secret,
|
||||
&config.oidc_redirect_url,
|
||||
&config.oidc_resource_id,
|
||||
) {
|
||||
tracing::info!("Initializing OIDC service with issuer: {}", issuer);
|
||||
|
||||
let issuer_url = domain::IssuerUrl::new(issuer)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid OIDC issuer URL: {}", e))?;
|
||||
let client_id = domain::ClientId::new(id)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid OIDC client ID: {}", e))?;
|
||||
let client_secret = secret.as_ref().map(|s| domain::ClientSecret::new(s));
|
||||
let redirect_url = domain::RedirectUrl::new(redirect)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid OIDC redirect URL: {}", e))?;
|
||||
let resource = resource_id
|
||||
.as_ref()
|
||||
.map(|r| domain::ResourceId::new(r))
|
||||
.transpose()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid OIDC resource ID: {}", e))?;
|
||||
|
||||
Some(Arc::new(
|
||||
OidcService::new(issuer_url, client_id, client_secret, redirect_url, resource)
|
||||
.await?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
let jwt_validator = {
|
||||
let secret = match &config.jwt_secret {
|
||||
Some(s) if !s.is_empty() => s.clone(),
|
||||
_ => {
|
||||
if config.is_production {
|
||||
anyhow::bail!("JWT_SECRET is required in production");
|
||||
}
|
||||
tracing::warn!(
|
||||
"⚠️ JWT_SECRET not set — using insecure development secret. DO NOT USE IN PRODUCTION!"
|
||||
);
|
||||
"k-template-dev-secret-not-for-production-use-only".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("Initializing JWT validator");
|
||||
let jwt_config = JwtConfig::new(
|
||||
secret,
|
||||
config.jwt_issuer.clone(),
|
||||
config.jwt_audience.clone(),
|
||||
Some(config.jwt_expiry_hours),
|
||||
config.is_production,
|
||||
)?;
|
||||
Some(Arc::new(JwtValidator::new(jwt_config)))
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
user_service: Arc::new(user_service),
|
||||
cookie_key,
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
oidc_service,
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
jwt_validator,
|
||||
config: Arc::new(config),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Arc<UserService> {
|
||||
fn from_ref(input: &AppState) -> Self {
|
||||
input.user_service.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Arc<Config> {
|
||||
fn from_ref(input: &AppState) -> Self {
|
||||
input.config.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Key {
|
||||
fn from_ref(input: &AppState) -> Self {
|
||||
input.cookie_key.clone()
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,59 @@
|
||||
[template]
|
||||
cargo_generate_version = ">=0.21.0"
|
||||
ignore = [".git", "target", ".idea", ".vscode", "data.db"]
|
||||
|
||||
[filenames]
|
||||
"api/Cargo.toml.template" = "api/Cargo.toml"
|
||||
"infra/Cargo.toml.template" = "infra/Cargo.toml"
|
||||
ignore = [".git", "target", ".idea", ".vscode", "data.db", "*.liquid", "**/.sqlx", "**/dev.db", "Dockerfile", "compose.yml"]
|
||||
|
||||
[placeholders.project_name]
|
||||
type = "string"
|
||||
prompt = "Project name"
|
||||
prompt = "Project name (snake_case)"
|
||||
|
||||
[placeholders.database]
|
||||
type = "string"
|
||||
prompt = "Database type"
|
||||
prompt = "Database backend"
|
||||
choices = ["sqlite", "postgres"]
|
||||
default = "sqlite"
|
||||
|
||||
[placeholders.auth_jwt]
|
||||
[placeholders.worker]
|
||||
type = "bool"
|
||||
prompt = "Enable JWT authentication (Bearer tokens)?"
|
||||
default = true
|
||||
prompt = "Include background worker binary?"
|
||||
default = false
|
||||
|
||||
[placeholders.auth_oidc]
|
||||
type = "bool"
|
||||
prompt = "Enable OIDC integration (Login with Google, etc.)?"
|
||||
default = true
|
||||
prompt = "Include OIDC/OAuth2 adapter stub?"
|
||||
default = false
|
||||
|
||||
[conditional.'database == "sqlite"']
|
||||
ignore = ["migrations_postgres"]
|
||||
ignore = ["crates/adapters/postgres"]
|
||||
|
||||
[conditional.'database == "postgres"']
|
||||
ignore = ["migrations_sqlite"]
|
||||
ignore = ["crates/adapters/sqlite"]
|
||||
|
||||
[conditional.'!worker']
|
||||
ignore = ["crates/worker"]
|
||||
|
||||
[conditional.'!auth_oidc']
|
||||
ignore = ["crates/adapters/auth/src/oidc.rs"]
|
||||
|
||||
[placeholders.storage]
|
||||
type = "bool"
|
||||
prompt = "Include object storage adapter (local/S3/GCS)?"
|
||||
default = false
|
||||
|
||||
[placeholders.storage_s3]
|
||||
type = "bool"
|
||||
prompt = "Include S3/MinIO backend?"
|
||||
default = false
|
||||
if = "storage"
|
||||
|
||||
[placeholders.storage_gcs]
|
||||
type = "bool"
|
||||
prompt = "Include GCS backend?"
|
||||
default = false
|
||||
if = "storage"
|
||||
|
||||
[conditional.'!storage']
|
||||
ignore = [
|
||||
"crates/adapters/storage",
|
||||
"crates/domain/src/ports/storage.rs",
|
||||
"crates/presentation/src/handlers/storage_example.rs",
|
||||
]
|
||||
|
||||
93
compose.yml
93
compose.yml
@@ -1,89 +1,34 @@
|
||||
services:
|
||||
backend:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- SESSION_SECRET=dev_secret_key_12345
|
||||
- DATABASE_URL=sqlite:///app/data/notes.db
|
||||
- CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5173
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DB_MAX_CONNECTIONS=5
|
||||
- DB_MIN_CONNECTIONS=1
|
||||
- SECURE_COOKIE=true
|
||||
DATABASE_URL: sqlite:///data/app.db
|
||||
JWT_SECRET: change-me-in-production-min-32-chars
|
||||
HOST: 0.0.0.0
|
||||
PORT: "3000"
|
||||
RUST_LOG: bootstrap=info,tower_http=info
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
||||
# nats:
|
||||
# image: nats:alpine
|
||||
# ports:
|
||||
# - "4222:4222"
|
||||
# - "6222:6222"
|
||||
# - "8222:8222"
|
||||
# restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: k_template_db
|
||||
ports:
|
||||
- "5439:5432"
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
|
||||
zitadel-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: zitadel_db
|
||||
environment:
|
||||
POSTGRES_USER: zitadel
|
||||
POSTGRES_PASSWORD: zitadel_password
|
||||
POSTGRES_DB: zitadel
|
||||
- db_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U zitadel -d zitadel"]
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- zitadel_db_data:/var/lib/postgresql/data
|
||||
start_period: 10s
|
||||
|
||||
zitadel:
|
||||
image: ghcr.io/zitadel/zitadel:latest
|
||||
container_name: zitadel_local
|
||||
depends_on:
|
||||
zitadel-db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8086:8080"
|
||||
# USE start-from-init (Fixes the "relation does not exist" bug)
|
||||
command: 'start-from-init --masterkey "MasterkeyNeedsToBeExactly32Bytes"'
|
||||
worker:
|
||||
build: .
|
||||
entrypoint: ["./worker"]
|
||||
environment:
|
||||
# Database Connection
|
||||
ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
|
||||
ZITADEL_DATABASE_POSTGRES_PORT: 5432
|
||||
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
|
||||
|
||||
# APPLICATION USER (Zitadel uses this to run)
|
||||
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
|
||||
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_password
|
||||
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
|
||||
|
||||
# ADMIN USER (Zitadel uses this to create tables/migrations)
|
||||
# We use 'zitadel' because it is the owner of the DB in your postgres container.
|
||||
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: zitadel
|
||||
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: zitadel_password
|
||||
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
|
||||
|
||||
# General Config
|
||||
ZITADEL_EXTERNALDOMAIN: localhost
|
||||
ZITADEL_EXTERNALPORT: 8086
|
||||
ZITADEL_EXTERNALSECURE: "false"
|
||||
ZITADEL_TLS_ENABLED: "false"
|
||||
|
||||
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false"
|
||||
DATABASE_URL: sqlite:///data/app.db
|
||||
RUST_LOG: worker=info
|
||||
volumes:
|
||||
- db_data:/data
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
zitadel_db_data:
|
||||
57
compose.yml.liquid
Normal file
57
compose.yml.liquid
Normal file
@@ -0,0 +1,57 @@
|
||||
services:
|
||||
{% if database == "postgres" %} postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: {{project_name}}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
{% endif %} app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
{% if database == "sqlite" %} DATABASE_URL: sqlite:///data/app.db
|
||||
{% endif %}{% if database == "postgres" %} DATABASE_URL: postgres://postgres:postgres@postgres:5432/{{project_name}}
|
||||
{% endif %} JWT_SECRET: change-me-in-production-min-32-chars
|
||||
HOST: 0.0.0.0
|
||||
PORT: "3000"
|
||||
RUST_LOG: {{project_name}}=info,tower_http=info
|
||||
{% if database == "sqlite" %} volumes:
|
||||
- db_data:/data
|
||||
{% endif %}{% if database == "postgres" %} depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
{% endif %} healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
{% if worker %}
|
||||
worker:
|
||||
build: .
|
||||
entrypoint: ["./worker"]
|
||||
environment:
|
||||
{% if database == "sqlite" %} DATABASE_URL: sqlite:///data/app.db
|
||||
{% endif %}{% if database == "postgres" %} DATABASE_URL: postgres://postgres:postgres@postgres:5432/{{project_name}}
|
||||
{% endif %} RUST_LOG: worker=info
|
||||
{% if database == "sqlite" %} volumes:
|
||||
- db_data:/data
|
||||
{% endif %}{% if database == "postgres" %} depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
{% endif %}
|
||||
{% endif %}volumes:
|
||||
{% if database == "sqlite" %} db_data:
|
||||
{% endif %}{% if database == "postgres" %} postgres_data:
|
||||
{% endif %}
|
||||
15
crates/adapters/auth/Cargo.toml
Normal file
15
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "adapters-auth"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
bcrypt = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
19
crates/adapters/auth/Cargo.toml.liquid
Normal file
19
crates/adapters/auth/Cargo.toml.liquid
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "adapters-auth"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
bcrypt = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
{% if auth_oidc %}
|
||||
openidconnect = "3"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
{% endif %}
|
||||
74
crates/adapters/auth/src/jwt.rs
Normal file
74
crates/adapters/auth/src/jwt.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{errors::DomainError, ports::TokenIssuer, value_objects::{Role, UserId}};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub role: String,
|
||||
pub exp: i64,
|
||||
}
|
||||
|
||||
pub struct JwtTokenIssuer {
|
||||
encoding_key: EncodingKey,
|
||||
decoding_key: DecodingKey,
|
||||
expiry_hours: i64,
|
||||
}
|
||||
|
||||
impl JwtTokenIssuer {
|
||||
pub fn new(secret: &str) -> Self {
|
||||
Self {
|
||||
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
|
||||
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
|
||||
expiry_hours: 24,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TokenIssuer for JwtTokenIssuer {
|
||||
async fn issue(&self, user_id: &UserId, role: &Role) -> Result<String, DomainError> {
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
role: role.to_string(),
|
||||
exp: (Utc::now() + chrono::Duration::hours(self.expiry_hours)).timestamp(),
|
||||
};
|
||||
encode(&Header::default(), &claims, &self.encoding_key)
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError> {
|
||||
let data = decode::<Claims>(token, &self.decoding_key, &Validation::default())
|
||||
.map_err(|_| DomainError::Unauthorized("Invalid or expired token".to_string()))?;
|
||||
let uuid = uuid::Uuid::parse_str(&data.claims.sub)
|
||||
.map_err(|_| DomainError::Unauthorized("Invalid token subject".to_string()))?;
|
||||
let role = Role::from_str(&data.claims.role)
|
||||
.map_err(|_| DomainError::Unauthorized("Invalid role in token".to_string()))?;
|
||||
Ok((UserId::from_uuid(uuid), role))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_and_verify_roundtrip() {
|
||||
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
|
||||
let user_id = UserId::new();
|
||||
let token = issuer.issue(&user_id, &Role::User).await.unwrap();
|
||||
let (verified_id, verified_role) = issuer.verify(&token).await.unwrap();
|
||||
assert_eq!(verified_id, user_id);
|
||||
assert_eq!(verified_role, Role::User);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_token() {
|
||||
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
|
||||
let result = issuer.verify("not.a.valid.jwt").await;
|
||||
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
|
||||
}
|
||||
}
|
||||
7
crates/adapters/auth/src/lib.rs
Normal file
7
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod jwt;
|
||||
pub mod oidc;
|
||||
pub mod password;
|
||||
|
||||
pub use jwt::JwtTokenIssuer;
|
||||
pub use oidc::OidcAdapter;
|
||||
pub use password::BcryptPasswordHasher;
|
||||
7
crates/adapters/auth/src/lib.rs.liquid
Normal file
7
crates/adapters/auth/src/lib.rs.liquid
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod jwt;
|
||||
{% if auth_oidc %}pub mod oidc;{% endif %}
|
||||
pub mod password;
|
||||
|
||||
pub use jwt::JwtTokenIssuer;
|
||||
{% if auth_oidc %}pub use oidc::OidcAdapter;{% endif %}
|
||||
pub use password::BcryptPasswordHasher;
|
||||
10
crates/adapters/auth/src/oidc.rs
Normal file
10
crates/adapters/auth/src/oidc.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// Stub: extend this when auth_oidc = true.
|
||||
pub struct OidcAdapter;
|
||||
|
||||
impl OidcAdapter {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Default for OidcAdapter {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
38
crates/adapters/auth/src/password.rs
Normal file
38
crates/adapters/auth/src/password.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, ports::PasswordHasher, value_objects::PasswordHash};
|
||||
|
||||
pub struct BcryptPasswordHasher;
|
||||
|
||||
#[async_trait]
|
||||
impl PasswordHasher for BcryptPasswordHasher {
|
||||
async fn hash(&self, password: &str) -> Result<PasswordHash, DomainError> {
|
||||
let password = password.to_owned();
|
||||
let hash = tokio::task::spawn_blocking(move || bcrypt::hash(&password, 12))
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(PasswordHash::from_hash(hash))
|
||||
}
|
||||
|
||||
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
let password = password.to_owned();
|
||||
let hash = hash.as_str().to_owned();
|
||||
tokio::task::spawn_blocking(move || bcrypt::verify(&password, &hash))
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_and_verify_roundtrip() {
|
||||
let h = BcryptPasswordHasher;
|
||||
let hash = h.hash("mysecretpassword").await.unwrap();
|
||||
assert!(h.verify("mysecretpassword", &hash).await.unwrap());
|
||||
assert!(!h.verify("wrongpassword", &hash).await.unwrap());
|
||||
}
|
||||
}
|
||||
12
crates/adapters/postgres/Cargo.toml
Normal file
12
crates/adapters/postgres/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "adapters-postgres"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
sqlx = { workspace = true, features = ["postgres"] }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
7
crates/adapters/postgres/migrations/001_init_users.sql
Normal file
7
crates/adapters/postgres/migrations/001_init_users.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
14
crates/adapters/postgres/src/db.rs
Normal file
14
crates/adapters/postgres/src/db.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
pub type PgPool = sqlx::PgPool;
|
||||
|
||||
pub async fn connect(url: &str) -> anyhow::Result<PgPool> {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect(url)
|
||||
.await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn run_migrations(pool: &PgPool) -> anyhow::Result<()> {
|
||||
sqlx::migrate!("./migrations").run(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
5
crates/adapters/postgres/src/lib.rs
Normal file
5
crates/adapters/postgres/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod db;
|
||||
pub mod user_repository;
|
||||
|
||||
pub use db::{connect, run_migrations, PgPool};
|
||||
pub use user_repository::PostgresUserRepository;
|
||||
86
crates/adapters/postgres/src/user_repository.rs
Normal file
86
crates/adapters/postgres/src/user_repository.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
entities::User,
|
||||
errors::DomainError,
|
||||
ports::UserRepository,
|
||||
value_objects::{Email, PasswordHash, Role, UserId},
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use crate::db::PgPool;
|
||||
|
||||
pub struct PostgresUserRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresUserRepository {
|
||||
pub fn new(pool: PgPool) -> Self { Self { pool } }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserRepository for PostgresUserRepository {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, email, password_hash, role, created_at FROM users WHERE id = $1",
|
||||
*id.as_uuid()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
row.map(|r| Ok(User {
|
||||
id: UserId::from_uuid(r.id),
|
||||
email: Email::new(r.email)?,
|
||||
password_hash: PasswordHash::from_hash(r.password_hash),
|
||||
role: Role::from_str(&r.role).map_err(DomainError::Internal)?,
|
||||
created_at: r.created_at,
|
||||
}))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, email, password_hash, role, created_at FROM users WHERE email = $1",
|
||||
email.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
row.map(|r| Ok(User {
|
||||
id: UserId::from_uuid(r.id),
|
||||
email: Email::new(r.email)?,
|
||||
password_hash: PasswordHash::from_hash(r.password_hash),
|
||||
role: Role::from_str(&r.role).map_err(DomainError::Internal)?,
|
||||
created_at: r.created_at,
|
||||
}))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO users (id, email, password_hash, role, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role",
|
||||
*user.id.as_uuid(),
|
||||
user.email.as_str(),
|
||||
user.password_hash.as_str(),
|
||||
user.role.to_string(),
|
||||
user.created_at
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query!("DELETE FROM users WHERE id = $1", *id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, email, password_hash, role, created_at FROM users WHERE email = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "password_hash",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "role",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "32a9f3382874860eb5382c5bb6ef08dbfb4ff01e052d88812ae65bb30600388c"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM users WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "73ffdf5be39aa5c4c160c2f77d6634a6970eeb4e1d3395f045ded747f0ce9d2a"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, email, password_hash, role, created_at FROM users WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "password_hash",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "role",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "99f8b694a94f0e01b788cc1c3a2e2ee54ba6e843139de462c8327b084abf151f"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO users (id, email, password_hash, role, created_at)\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT(id) DO UPDATE SET\n email = excluded.email,\n password_hash = excluded.password_hash,\n role = excluded.role",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d20be9ee2cb28025aaa1fd644cda14d209c76269538686dd6e0818922c386dc1"
|
||||
}
|
||||
12
crates/adapters/sqlite/Cargo.toml
Normal file
12
crates/adapters/sqlite/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "adapters-sqlite"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
sqlx = { workspace = true, features = ["sqlite"] }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
7
crates/adapters/sqlite/migrations/001_init_users.sql
Normal file
7
crates/adapters/sqlite/migrations/001_init_users.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
14
crates/adapters/sqlite/src/db.rs
Normal file
14
crates/adapters/sqlite/src/db.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
pub type SqlitePool = sqlx::SqlitePool;
|
||||
|
||||
pub async fn connect(url: &str) -> anyhow::Result<SqlitePool> {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(url)
|
||||
.await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn run_migrations(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||
sqlx::migrate!("./migrations").run(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
5
crates/adapters/sqlite/src/lib.rs
Normal file
5
crates/adapters/sqlite/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod db;
|
||||
pub mod user_repository;
|
||||
|
||||
pub use db::{connect, run_migrations, SqlitePool};
|
||||
pub use user_repository::SqliteUserRepository;
|
||||
95
crates/adapters/sqlite/src/user_repository.rs
Normal file
95
crates/adapters/sqlite/src/user_repository.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
entities::User,
|
||||
errors::DomainError,
|
||||
ports::UserRepository,
|
||||
value_objects::{Email, PasswordHash, Role, UserId},
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use crate::db::SqlitePool;
|
||||
|
||||
pub struct SqliteUserRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteUserRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self { Self { pool } }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserRepository for SqliteUserRepository {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
let id_str = id.to_string();
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, email, password_hash, role, created_at FROM users WHERE id = ?",
|
||||
id_str
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
row.map(|r| row_to_user(r.id, r.email, r.password_hash, r.role, r.created_at))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
let email_str = email.as_str().to_owned();
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, email, password_hash, role, created_at FROM users WHERE email = ?",
|
||||
email_str
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
row.map(|r| row_to_user(r.id, r.email, r.password_hash, r.role, r.created_at))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||
let id = user.id.to_string();
|
||||
let email = user.email.as_str().to_owned();
|
||||
let hash = user.password_hash.as_str().to_owned();
|
||||
let role = user.role.to_string();
|
||||
let created_at = user.created_at.to_rfc3339();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO users (id, email, password_hash, role, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
email = excluded.email,
|
||||
password_hash = excluded.password_hash,
|
||||
role = excluded.role",
|
||||
id, email, hash, role, created_at
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
|
||||
let id_str = id.to_string();
|
||||
sqlx::query!("DELETE FROM users WHERE id = ?", id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_user(
|
||||
id: String,
|
||||
email: String,
|
||||
password_hash: String,
|
||||
role: String,
|
||||
created_at: String,
|
||||
) -> Result<User, DomainError> {
|
||||
let uuid = uuid::Uuid::parse_str(&id).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
let email = Email::new(email)?;
|
||||
let role = Role::from_str(&role).map_err(DomainError::Internal)?;
|
||||
let created_at = chrono::DateTime::parse_from_rfc3339(&created_at)
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?
|
||||
.with_timezone(&chrono::Utc);
|
||||
Ok(User { id: UserId::from_uuid(uuid), email, password_hash: PasswordHash::from_hash(password_hash), role, created_at })
|
||||
}
|
||||
21
crates/adapters/storage/Cargo.toml
Normal file
21
crates/adapters/storage/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "adapters-storage"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
s3 = ["object_store/aws"]
|
||||
gcs = ["object_store/gcp"]
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
object_store = { version = "0.11" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
310
crates/adapters/storage/src/adapter.rs
Normal file
310
crates/adapters/storage/src/adapter.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::StreamExt;
|
||||
use object_store::{ObjectStore, path::Path, Error as OsError};
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{DataStream, StorageReader, StorageWriter};
|
||||
|
||||
pub struct ObjectStorageAdapter {
|
||||
store: Arc<dyn ObjectStore>,
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
impl ObjectStorageAdapter {
|
||||
pub fn new(store: Arc<dyn ObjectStore>, prefix: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let prefix = prefix.into();
|
||||
if !prefix.is_empty() {
|
||||
validate_key(&prefix)?;
|
||||
}
|
||||
Ok(Self { store, prefix })
|
||||
}
|
||||
|
||||
fn path(&self, key: &str) -> Path {
|
||||
if self.prefix.is_empty() {
|
||||
Path::from(key)
|
||||
} else {
|
||||
Path::from(format!("{}/{key}", self.prefix))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_err(e: OsError, key: &str) -> DomainError {
|
||||
match e {
|
||||
OsError::NotFound { .. } => DomainError::NotFound(key.to_string()),
|
||||
e => DomainError::Internal(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_key(key: &str) -> Result<(), DomainError> {
|
||||
if key.is_empty() {
|
||||
return Err(DomainError::Validation("storage key must not be empty".into()));
|
||||
}
|
||||
if key.starts_with('/') {
|
||||
return Err(DomainError::Validation(
|
||||
format!("storage key must not start with '/': {key}"),
|
||||
));
|
||||
}
|
||||
if key.split('/').any(|seg| seg == ".." || seg == ".") {
|
||||
return Err(DomainError::Validation(
|
||||
format!("storage key contains invalid path segment: {key}"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StorageWriter for ObjectStorageAdapter {
|
||||
async fn put(&self, key: &str, data: DataStream) -> Result<(), DomainError> {
|
||||
validate_key(key)?;
|
||||
let path = self.path(key);
|
||||
let mut upload = self
|
||||
.store
|
||||
.put_multipart(&path)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let mut stream = data;
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = upload.put_part(bytes.into()).await {
|
||||
let _ = upload.abort().await;
|
||||
return Err(DomainError::Internal(e.to_string()));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = upload.abort().await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
upload.complete().await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<(), DomainError> {
|
||||
validate_key(key)?;
|
||||
let path = self.path(key);
|
||||
match self.store.delete(&path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(OsError::NotFound { .. }) => Ok(()),
|
||||
Err(e) => Err(DomainError::Internal(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StorageReader for ObjectStorageAdapter {
|
||||
async fn get(&self, key: &str) -> Result<DataStream, DomainError> {
|
||||
validate_key(key)?;
|
||||
let path = self.path(key);
|
||||
let result = self
|
||||
.store
|
||||
.get(&path)
|
||||
.await
|
||||
.map_err(|e| map_err(e, key))?;
|
||||
let s = result
|
||||
.into_stream()
|
||||
.map(|r| r.map_err(|e| DomainError::Internal(e.to_string())));
|
||||
Ok(Box::pin(s))
|
||||
}
|
||||
|
||||
async fn list(&self, prefix: Option<&str>) -> Result<Vec<String>, DomainError> {
|
||||
if let Some(p) = prefix {
|
||||
validate_key(p)?;
|
||||
}
|
||||
let list_prefix = match (prefix, self.prefix.is_empty()) {
|
||||
(Some(p), false) => Some(Path::from(format!("{}/{p}", self.prefix))),
|
||||
(Some(p), true) => Some(Path::from(p)),
|
||||
(None, false) => Some(Path::from(self.prefix.as_str())),
|
||||
(None, true) => None,
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut stream = self.store.list(list_prefix.as_ref());
|
||||
while let Some(meta) = stream.next().await {
|
||||
let meta = meta.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
let key = meta.location.to_string();
|
||||
let stripped = if !self.prefix.is_empty() {
|
||||
key.strip_prefix(&format!("{}/", self.prefix))
|
||||
.ok_or_else(|| DomainError::Internal(format!(
|
||||
"listed key '{key}' does not start with expected prefix '{}'",
|
||||
self.prefix
|
||||
)))?
|
||||
.to_string()
|
||||
} else {
|
||||
key
|
||||
};
|
||||
result.push(stripped);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::ports::{StorageReader, StorageWriter};
|
||||
use futures::stream;
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
fn make_adapter() -> ObjectStorageAdapter {
|
||||
ObjectStorageAdapter::new(Arc::new(InMemory::new()), "test").unwrap()
|
||||
}
|
||||
|
||||
fn one_shot(data: &'static [u8]) -> DataStream {
|
||||
Box::pin(stream::once(async move { Ok(Bytes::from(data)) }))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_get_roundtrip() {
|
||||
let a = make_adapter();
|
||||
a.put("hello.txt", one_shot(b"world")).await.unwrap();
|
||||
let mut s = a.get("hello.txt").await.unwrap();
|
||||
let mut out = Vec::new();
|
||||
while let Some(chunk) = s.next().await {
|
||||
out.extend_from_slice(&chunk.unwrap());
|
||||
}
|
||||
assert_eq!(out, b"world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_missing_is_not_found() {
|
||||
let a = make_adapter();
|
||||
assert!(matches!(a.get("nope.txt").await, Err(DomainError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_is_idempotent() {
|
||||
let a = make_adapter();
|
||||
a.delete("nope.txt").await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_key() {
|
||||
let a = make_adapter();
|
||||
a.put("file.txt", one_shot(b"data")).await.unwrap();
|
||||
a.delete("file.txt").await.unwrap();
|
||||
assert!(matches!(a.get("file.txt").await, Err(DomainError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_returns_keys_under_prefix() {
|
||||
let a = make_adapter();
|
||||
a.put("docs/readme.txt", one_shot(b"x")).await.unwrap();
|
||||
a.put("docs/guide.txt", one_shot(b"y")).await.unwrap();
|
||||
a.put("other/file.txt", one_shot(b"z")).await.unwrap();
|
||||
let keys = a.list(Some("docs")).await.unwrap();
|
||||
assert_eq!(keys.len(), 2);
|
||||
assert!(keys.iter().any(|k| k.ends_with("readme.txt")));
|
||||
assert!(keys.iter().any(|k| k.ends_with("guide.txt")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_none_returns_all() {
|
||||
let a = make_adapter();
|
||||
a.put("a.txt", one_shot(b"1")).await.unwrap();
|
||||
a.put("b.txt", one_shot(b"2")).await.unwrap();
|
||||
let keys = a.list(None).await.unwrap();
|
||||
assert_eq!(keys.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_empty_key() {
|
||||
let a = make_adapter();
|
||||
assert!(matches!(a.put("", one_shot(b"x")).await, Err(DomainError::Validation(_))));
|
||||
assert!(matches!(a.get("").await, Err(DomainError::Validation(_))));
|
||||
assert!(matches!(a.delete("").await, Err(DomainError::Validation(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_absolute_key() {
|
||||
let a = make_adapter();
|
||||
assert!(matches!(
|
||||
a.put("/etc/passwd", one_shot(b"x")).await,
|
||||
Err(DomainError::Validation(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_path_traversal() {
|
||||
let a = make_adapter();
|
||||
assert!(matches!(a.get("../escape").await, Err(DomainError::Validation(_))));
|
||||
assert!(matches!(a.get("a/../../../etc").await, Err(DomainError::Validation(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_dot_segment() {
|
||||
let a = make_adapter();
|
||||
assert!(matches!(
|
||||
a.put("./file.txt", one_shot(b"x")).await,
|
||||
Err(DomainError::Validation(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_list_prefix() {
|
||||
let a = make_adapter();
|
||||
assert!(matches!(a.list(Some("")).await, Err(DomainError::Validation(_))));
|
||||
assert!(matches!(a.list(Some("../escape")).await, Err(DomainError::Validation(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_overwrites_existing() {
|
||||
let a = make_adapter();
|
||||
a.put("file.txt", one_shot(b"version1")).await.unwrap();
|
||||
a.put("file.txt", one_shot(b"version2")).await.unwrap();
|
||||
let mut s = a.get("file.txt").await.unwrap();
|
||||
let mut out = Vec::new();
|
||||
while let Some(chunk) = s.next().await {
|
||||
out.extend_from_slice(&chunk.unwrap());
|
||||
}
|
||||
assert_eq!(out, b"version2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_returns_exact_key_paths() {
|
||||
let a = make_adapter();
|
||||
a.put("docs/readme.txt", one_shot(b"x")).await.unwrap();
|
||||
let mut keys = a.list(Some("docs")).await.unwrap();
|
||||
keys.sort();
|
||||
assert_eq!(keys, vec!["docs/readme.txt"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_bytes_get_bytes_roundtrip() {
|
||||
let a = make_adapter();
|
||||
a.put_bytes("data.bin", Bytes::from("hello bytes")).await.unwrap();
|
||||
let got = a.get_bytes("data.bin").await.unwrap();
|
||||
assert_eq!(got.as_ref(), b"hello bytes");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_bytes_missing_is_not_found() {
|
||||
let a = make_adapter();
|
||||
assert!(matches!(a.get_bytes("nope.bin").await, Err(DomainError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_traversal_prefix() {
|
||||
let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "../evil");
|
||||
assert!(matches!(result, Err(DomainError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_absolute_prefix() {
|
||||
let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "/root");
|
||||
assert!(matches!(result, Err(DomainError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_accepts_empty_prefix() {
|
||||
assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_accepts_valid_prefix() {
|
||||
assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "my-bucket/data").is_ok());
|
||||
}
|
||||
}
|
||||
90
crates/adapters/storage/src/config.rs
Normal file
90
crates/adapters/storage/src/config.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use std::sync::Arc;
|
||||
use anyhow::{Context, Result};
|
||||
use object_store::ObjectStore;
|
||||
use object_store::local::LocalFileSystem;
|
||||
|
||||
/// All storage configuration. Populate once via `from_env()` and pass
|
||||
/// explicitly to `build_store` and `ObjectStorageAdapter::new`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StorageConfig {
|
||||
pub backend: String,
|
||||
pub prefix: String,
|
||||
// local backend:
|
||||
pub local_path: Option<String>,
|
||||
// s3/minio backend:
|
||||
pub s3_endpoint: Option<String>,
|
||||
pub s3_access_key_id: Option<String>,
|
||||
pub s3_secret_access_key: Option<String>,
|
||||
pub s3_bucket: Option<String>,
|
||||
pub s3_region: Option<String>,
|
||||
// gcs backend:
|
||||
pub gcs_bucket: Option<String>,
|
||||
}
|
||||
|
||||
impl StorageConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
Ok(Self {
|
||||
backend: std::env::var("STORAGE_BACKEND")
|
||||
.context("STORAGE_BACKEND must be set (local, s3, gcs)")?,
|
||||
prefix: std::env::var("STORAGE_PREFIX").unwrap_or_default(),
|
||||
local_path: std::env::var("STORAGE_PATH").ok(),
|
||||
s3_endpoint: std::env::var("S3_ENDPOINT").ok(),
|
||||
s3_access_key_id: std::env::var("S3_ACCESS_KEY_ID").ok(),
|
||||
s3_secret_access_key: std::env::var("S3_SECRET_ACCESS_KEY").ok(),
|
||||
s3_bucket: std::env::var("S3_BUCKET").ok(),
|
||||
s3_region: std::env::var("S3_REGION").ok(),
|
||||
gcs_bucket: std::env::var("GCS_BUCKET").ok(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_store(config: &StorageConfig) -> Result<Arc<dyn ObjectStore>> {
|
||||
match config.backend.as_str() {
|
||||
"local" => {
|
||||
let path = config.local_path.as_deref()
|
||||
.context("STORAGE_PATH must be set when STORAGE_BACKEND=local")?;
|
||||
std::fs::create_dir_all(path)
|
||||
.with_context(|| format!("failed to create storage dir: {path}"))?;
|
||||
let store = LocalFileSystem::new_with_prefix(path)?;
|
||||
Ok(Arc::new(store))
|
||||
}
|
||||
#[cfg(feature = "s3")]
|
||||
"s3" => {
|
||||
use object_store::aws::AmazonS3Builder;
|
||||
let store = AmazonS3Builder::new()
|
||||
.with_endpoint(
|
||||
config.s3_endpoint.as_deref().context("S3_ENDPOINT must be set")?,
|
||||
)
|
||||
.with_access_key_id(
|
||||
config.s3_access_key_id.as_deref()
|
||||
.context("S3_ACCESS_KEY_ID must be set")?,
|
||||
)
|
||||
.with_secret_access_key(
|
||||
config.s3_secret_access_key.as_deref()
|
||||
.context("S3_SECRET_ACCESS_KEY must be set")?,
|
||||
)
|
||||
.with_bucket_name(
|
||||
config.s3_bucket.as_deref().context("S3_BUCKET must be set")?,
|
||||
)
|
||||
.with_region(config.s3_region.as_deref().unwrap_or("us-east-1"))
|
||||
.with_allow_http(true)
|
||||
.build()?;
|
||||
Ok(Arc::new(store))
|
||||
}
|
||||
#[cfg(feature = "gcs")]
|
||||
"gcs" => {
|
||||
use object_store::gcp::GoogleCloudStorageBuilder;
|
||||
let store = GoogleCloudStorageBuilder::new()
|
||||
.with_bucket_name(
|
||||
config.gcs_bucket.as_deref().context("GCS_BUCKET must be set")?,
|
||||
)
|
||||
.build()?;
|
||||
Ok(Arc::new(store))
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"unknown STORAGE_BACKEND={other:?}; compiled features: local{}{}",
|
||||
if cfg!(feature = "s3") { ", s3" } else { "" },
|
||||
if cfg!(feature = "gcs") { ", gcs" } else { "" },
|
||||
),
|
||||
}
|
||||
}
|
||||
5
crates/adapters/storage/src/lib.rs
Normal file
5
crates/adapters/storage/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod adapter;
|
||||
pub mod config;
|
||||
|
||||
pub use adapter::ObjectStorageAdapter;
|
||||
pub use config::{build_store, StorageConfig};
|
||||
11
crates/api-types/Cargo.toml
Normal file
11
crates/api-types/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "api-types"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
utoipa = { workspace = true }
|
||||
2
crates/api-types/src/lib.rs
Normal file
2
crates/api-types/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod requests;
|
||||
pub mod responses;
|
||||
11
crates/api-types/src/requests.rs
Normal file
11
crates/api-types/src/requests.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct RegisterRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
27
crates/api-types/src/responses.rs
Normal file
27
crates/api-types/src/responses.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct UserResponse {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub user: UserResponse,
|
||||
}
|
||||
|
||||
impl UserResponse {
|
||||
pub fn from_domain(user: &domain::entities::User) -> Self {
|
||||
Self {
|
||||
id: *user.id.as_uuid(),
|
||||
email: user.email.to_string(),
|
||||
role: user.role.to_string(),
|
||||
created_at: user.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
12
crates/application/Cargo.toml
Normal file
12
crates/application/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "application"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
2
crates/application/src/lib.rs
Normal file
2
crates/application/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod testing;
|
||||
pub mod use_cases;
|
||||
79
crates/application/src/testing.rs
Normal file
79
crates/application/src/testing.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use std::collections::HashMap;
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::Mutex;
|
||||
use domain::{
|
||||
entities::User,
|
||||
errors::DomainError,
|
||||
ports::{PasswordHasher, TokenIssuer, UserRepository},
|
||||
value_objects::{Email, PasswordHash, Role, UserId},
|
||||
};
|
||||
|
||||
pub struct InMemoryUserRepository {
|
||||
users: Mutex<HashMap<String, User>>,
|
||||
}
|
||||
|
||||
impl InMemoryUserRepository {
|
||||
pub fn new() -> Self {
|
||||
Self { users: Mutex::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
pub async fn all(&self) -> Vec<User> {
|
||||
self.users.lock().await.values().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryUserRepository {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserRepository for InMemoryUserRepository {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
Ok(self.users.lock().await.get(&id.to_string()).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
Ok(self.users.lock().await.values()
|
||||
.find(|u| u.email.as_str() == email.as_str())
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||
self.users.lock().await.insert(user.id.to_string(), user.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
|
||||
self.users.lock().await.remove(&id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StubPasswordHasher;
|
||||
|
||||
#[async_trait]
|
||||
impl PasswordHasher for StubPasswordHasher {
|
||||
async fn hash(&self, password: &str) -> Result<PasswordHash, DomainError> {
|
||||
Ok(PasswordHash::from_hash(format!("hashed:{password}")))
|
||||
}
|
||||
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
Ok(hash.as_str() == format!("hashed:{password}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StubTokenIssuer;
|
||||
|
||||
#[async_trait]
|
||||
impl TokenIssuer for StubTokenIssuer {
|
||||
async fn issue(&self, user_id: &UserId, _role: &Role) -> Result<String, DomainError> {
|
||||
Ok(format!("token:{user_id}"))
|
||||
}
|
||||
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError> {
|
||||
let id_str = token.strip_prefix("token:").ok_or_else(|| {
|
||||
DomainError::Unauthorized("Invalid stub token".to_string())
|
||||
})?;
|
||||
let uuid = uuid::Uuid::parse_str(id_str)
|
||||
.map_err(|_| DomainError::Unauthorized("Bad UUID in stub token".to_string()))?;
|
||||
Ok((UserId::from_uuid(uuid), Role::User))
|
||||
}
|
||||
}
|
||||
40
crates/application/src/use_cases/get_profile.rs
Normal file
40
crates/application/src/use_cases/get_profile.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::sync::Arc;
|
||||
use domain::{entities::User, errors::DomainError, ports::UserRepository, value_objects::UserId};
|
||||
|
||||
pub struct GetProfile {
|
||||
repo: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
impl GetProfile {
|
||||
pub fn new(repo: Arc<dyn UserRepository>) -> Self { Self { repo } }
|
||||
|
||||
pub async fn execute(&self, user_id: &UserId) -> Result<User, DomainError> {
|
||||
self.repo.find_by_id(user_id).await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("User {user_id} not found")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::{InMemoryUserRepository, StubPasswordHasher};
|
||||
use crate::use_cases::register::RegisterUser;
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_profile_returns_existing_user() {
|
||||
let repo = Arc::new(InMemoryUserRepository::new());
|
||||
let r = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
|
||||
let user = r.execute("user@example.com", "password123").await.unwrap();
|
||||
let uc = GetProfile::new(repo);
|
||||
let found = uc.execute(&user.id).await.unwrap();
|
||||
assert_eq!(found.id, user.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_profile_returns_not_found() {
|
||||
let repo = Arc::new(InMemoryUserRepository::new());
|
||||
let uc = GetProfile::new(repo);
|
||||
let result = uc.execute(&UserId::new()).await;
|
||||
assert!(matches!(result, Err(DomainError::NotFound(_))));
|
||||
}
|
||||
}
|
||||
74
crates/application/src/use_cases/login.rs
Normal file
74
crates/application/src/use_cases/login.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use std::sync::Arc;
|
||||
use domain::{
|
||||
entities::User,
|
||||
errors::DomainError,
|
||||
ports::{PasswordHasher, TokenIssuer, UserRepository},
|
||||
value_objects::Email,
|
||||
};
|
||||
|
||||
pub struct LoginUser {
|
||||
repo: Arc<dyn UserRepository>,
|
||||
hasher: Arc<dyn PasswordHasher>,
|
||||
issuer: Arc<dyn TokenIssuer>,
|
||||
}
|
||||
|
||||
impl LoginUser {
|
||||
pub fn new(
|
||||
repo: Arc<dyn UserRepository>,
|
||||
hasher: Arc<dyn PasswordHasher>,
|
||||
issuer: Arc<dyn TokenIssuer>,
|
||||
) -> Self {
|
||||
Self { repo, hasher, issuer }
|
||||
}
|
||||
|
||||
pub async fn execute(&self, email: &str, password: &str) -> Result<(User, String), DomainError> {
|
||||
let email = Email::new(email)?;
|
||||
let user = self.repo.find_by_email(&email).await?
|
||||
.ok_or_else(|| DomainError::Unauthorized("Invalid credentials".to_string()))?;
|
||||
let valid = self.hasher.verify(password, &user.password_hash).await?;
|
||||
if !valid {
|
||||
return Err(DomainError::Unauthorized("Invalid credentials".to_string()));
|
||||
}
|
||||
let token = self.issuer.issue(&user.id, &user.role).await?;
|
||||
Ok((user, token))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::{InMemoryUserRepository, StubPasswordHasher, StubTokenIssuer};
|
||||
use crate::use_cases::register::RegisterUser;
|
||||
|
||||
async fn seeded_repo() -> Arc<InMemoryUserRepository> {
|
||||
let repo = Arc::new(InMemoryUserRepository::new());
|
||||
let r = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
|
||||
r.execute("user@example.com", "password123").await.unwrap();
|
||||
repo
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_returns_user_and_token() {
|
||||
let repo = seeded_repo().await;
|
||||
let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer));
|
||||
let (user, token) = uc.execute("user@example.com", "password123").await.unwrap();
|
||||
assert_eq!(user.email.as_str(), "user@example.com");
|
||||
assert!(token.starts_with("token:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_wrong_password() {
|
||||
let repo = seeded_repo().await;
|
||||
let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer));
|
||||
let result = uc.execute("user@example.com", "wrongpassword").await;
|
||||
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_unknown_email() {
|
||||
let repo = seeded_repo().await;
|
||||
let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer));
|
||||
let result = uc.execute("nobody@example.com", "password123").await;
|
||||
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
|
||||
}
|
||||
}
|
||||
7
crates/application/src/use_cases/mod.rs
Normal file
7
crates/application/src/use_cases/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod get_profile;
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
|
||||
pub use get_profile::GetProfile;
|
||||
pub use login::LoginUser;
|
||||
pub use register::RegisterUser;
|
||||
72
crates/application/src/use_cases/register.rs
Normal file
72
crates/application/src/use_cases/register.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use std::sync::Arc;
|
||||
use domain::{
|
||||
entities::User,
|
||||
errors::DomainError,
|
||||
ports::{PasswordHasher, UserRepository},
|
||||
value_objects::{Email, UserId},
|
||||
};
|
||||
|
||||
pub struct RegisterUser {
|
||||
repo: Arc<dyn UserRepository>,
|
||||
hasher: Arc<dyn PasswordHasher>,
|
||||
}
|
||||
|
||||
impl RegisterUser {
|
||||
pub fn new(repo: Arc<dyn UserRepository>, hasher: Arc<dyn PasswordHasher>) -> Self {
|
||||
Self { repo, hasher }
|
||||
}
|
||||
|
||||
pub async fn execute(&self, email: &str, password: &str) -> Result<User, DomainError> {
|
||||
if password.len() < 8 {
|
||||
return Err(DomainError::Validation("Password must be at least 8 characters".to_string()));
|
||||
}
|
||||
let email = Email::new(email)?;
|
||||
if self.repo.find_by_email(&email).await?.is_some() {
|
||||
return Err(DomainError::Conflict(format!("Email {} is already registered", email.as_str())));
|
||||
}
|
||||
let hash = self.hasher.hash(password).await?;
|
||||
let user = User::new(UserId::new(), email, hash);
|
||||
self.repo.save(&user).await?;
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::{InMemoryUserRepository, StubPasswordHasher};
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_creates_user() {
|
||||
let repo = Arc::new(InMemoryUserRepository::new());
|
||||
let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
|
||||
let user = uc.execute("test@example.com", "password123").await.unwrap();
|
||||
assert_eq!(user.email.as_str(), "test@example.com");
|
||||
assert_eq!(repo.all().await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_rejects_duplicate_email() {
|
||||
let repo = Arc::new(InMemoryUserRepository::new());
|
||||
let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
|
||||
uc.execute("test@example.com", "password123").await.unwrap();
|
||||
let result = uc.execute("test@example.com", "different1").await;
|
||||
assert!(matches!(result, Err(DomainError::Conflict(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_rejects_short_password() {
|
||||
let repo = Arc::new(InMemoryUserRepository::new());
|
||||
let uc = RegisterUser::new(repo, Arc::new(StubPasswordHasher));
|
||||
let result = uc.execute("test@example.com", "short").await;
|
||||
assert!(matches!(result, Err(DomainError::Validation(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_rejects_invalid_email() {
|
||||
let repo = Arc::new(InMemoryUserRepository::new());
|
||||
let uc = RegisterUser::new(repo, Arc::new(StubPasswordHasher));
|
||||
let result = uc.execute("notanemail", "password123").await;
|
||||
assert!(matches!(result, Err(DomainError::Validation(_))));
|
||||
}
|
||||
}
|
||||
23
crates/bootstrap/Cargo.toml
Normal file
23
crates/bootstrap/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "bootstrap"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
adapters-auth = { workspace = true }
|
||||
adapters-storage = { workspace = true }
|
||||
presentation = { workspace = true }
|
||||
adapters-sqlite = { path = "../adapters/sqlite" }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
36
crates/bootstrap/Cargo.toml.liquid
Normal file
36
crates/bootstrap/Cargo.toml.liquid
Normal file
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "bootstrap"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "{{project_name}}"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
adapters-auth = { workspace = true }
|
||||
{% if storage and storage_s3 and storage_gcs %}
|
||||
adapters-storage = { workspace = true, features = ["s3", "gcs"] }
|
||||
{% elsif storage and storage_s3 %}
|
||||
adapters-storage = { workspace = true, features = ["s3"] }
|
||||
{% elsif storage and storage_gcs %}
|
||||
adapters-storage = { workspace = true, features = ["gcs"] }
|
||||
{% elsif storage %}
|
||||
adapters-storage = { workspace = true }
|
||||
{% endif %}
|
||||
presentation = { workspace = true }
|
||||
{% if database == "sqlite" %}
|
||||
adapters-sqlite = { path = "../adapters/sqlite" }
|
||||
{% endif %}
|
||||
{% if database == "postgres" %}
|
||||
adapters-postgres = { path = "../adapters/postgres" }
|
||||
{% endif %}
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
28
crates/bootstrap/src/config.rs
Normal file
28
crates/bootstrap/src/config.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub database_url: String,
|
||||
pub jwt_secret: String,
|
||||
pub cors_allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
dotenvy::dotenv().ok();
|
||||
Self {
|
||||
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
|
||||
port: std::env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(3000),
|
||||
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"),
|
||||
jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"),
|
||||
cors_allowed_origins: std::env::var("CORS_ALLOWED_ORIGINS")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
28
crates/bootstrap/src/config.rs.liquid
Normal file
28
crates/bootstrap/src/config.rs.liquid
Normal file
@@ -0,0 +1,28 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub database_url: String,
|
||||
pub jwt_secret: String,
|
||||
pub cors_allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
dotenvy::dotenv().ok();
|
||||
Self {
|
||||
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
|
||||
port: std::env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(3000),
|
||||
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"),
|
||||
jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"),
|
||||
cors_allowed_origins: std::env::var("CORS_ALLOWED_ORIGINS")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
48
crates/bootstrap/src/factory.rs
Normal file
48
crates/bootstrap/src/factory.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::http::HeaderValue;
|
||||
use tower_http::{cors::{Any, CorsLayer}, trace::TraceLayer};
|
||||
|
||||
use adapters_auth::{BcryptPasswordHasher, JwtTokenIssuer};
|
||||
use adapters_sqlite::{connect, run_migrations, SqliteUserRepository};
|
||||
use adapters_storage::{ObjectStorageAdapter, StorageConfig, build_store};
|
||||
use application::use_cases::{GetProfile, LoginUser, RegisterUser};
|
||||
use presentation::{routes::app_router, state::AppState};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub async fn build_app(config: &Config) -> Result<Router> {
|
||||
let pool = connect(&config.database_url).await?;
|
||||
run_migrations(&pool).await?;
|
||||
|
||||
let user_repo = Arc::new(SqliteUserRepository::new(pool));
|
||||
let hasher = Arc::new(BcryptPasswordHasher);
|
||||
let issuer = Arc::new(JwtTokenIssuer::new(&config.jwt_secret));
|
||||
|
||||
let register_uc = Arc::new(RegisterUser::new(user_repo.clone(), hasher.clone()));
|
||||
let login_uc = Arc::new(LoginUser::new(user_repo.clone(), hasher, issuer.clone()));
|
||||
let get_profile_uc = Arc::new(GetProfile::new(user_repo));
|
||||
|
||||
let storage_cfg = StorageConfig::from_env()?;
|
||||
let store = build_store(&storage_cfg)?;
|
||||
// To inject storage into a use case, clone it into the constructor:
|
||||
// let my_uc = Arc::new(MyUseCase::new(repo, storage.clone()));
|
||||
let storage = Arc::new(ObjectStorageAdapter::new(store, &storage_cfg.prefix)?);
|
||||
|
||||
let state = AppState::new(register_uc, login_uc, get_profile_uc, issuer, storage);
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(
|
||||
config.cors_allowed_origins.iter()
|
||||
.filter_map(|o| o.parse::<HeaderValue>().ok())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Ok(app_router()
|
||||
.with_state(state)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(cors))
|
||||
}
|
||||
62
crates/bootstrap/src/factory.rs.liquid
Normal file
62
crates/bootstrap/src/factory.rs.liquid
Normal file
@@ -0,0 +1,62 @@
|
||||
use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::http::HeaderValue;
|
||||
use tower_http::{cors::{Any, CorsLayer}, trace::TraceLayer};
|
||||
|
||||
use adapters_auth::{BcryptPasswordHasher, JwtTokenIssuer};
|
||||
{% if database == "sqlite" %}
|
||||
use adapters_sqlite::{connect, run_migrations, SqliteUserRepository};
|
||||
{% endif %}
|
||||
{% if database == "postgres" %}
|
||||
use adapters_postgres::{connect, run_migrations, PostgresUserRepository};
|
||||
{% endif %}
|
||||
{% if storage %}
|
||||
use adapters_storage::{ObjectStorageAdapter, StorageConfig, build_store};
|
||||
{% endif %}
|
||||
use application::use_cases::{GetProfile, LoginUser, RegisterUser};
|
||||
use presentation::{routes::app_router, state::AppState};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub async fn build_app(config: &Config) -> Result<Router> {
|
||||
let pool = connect(&config.database_url).await?;
|
||||
run_migrations(&pool).await?;
|
||||
|
||||
{% if database == "sqlite" %}
|
||||
let user_repo = Arc::new(SqliteUserRepository::new(pool));
|
||||
{% endif %}
|
||||
{% if database == "postgres" %}
|
||||
let user_repo = Arc::new(PostgresUserRepository::new(pool));
|
||||
{% endif %}
|
||||
let hasher = Arc::new(BcryptPasswordHasher);
|
||||
let issuer = Arc::new(JwtTokenIssuer::new(&config.jwt_secret));
|
||||
|
||||
let register_uc = Arc::new(RegisterUser::new(user_repo.clone(), hasher.clone()));
|
||||
let login_uc = Arc::new(LoginUser::new(user_repo.clone(), hasher, issuer.clone()));
|
||||
let get_profile_uc = Arc::new(GetProfile::new(user_repo));
|
||||
|
||||
{% if storage %}
|
||||
let storage_cfg = StorageConfig::from_env()?;
|
||||
let store = build_store(&storage_cfg)?;
|
||||
// To inject storage into a use case, clone it into the constructor:
|
||||
// let my_uc = Arc::new(MyUseCase::new(repo, storage.clone()));
|
||||
let storage = Arc::new(ObjectStorageAdapter::new(store, &storage_cfg.prefix)?);
|
||||
{% endif %}
|
||||
|
||||
let state = AppState::new(register_uc, login_uc, get_profile_uc, issuer{% if storage %}, storage{% endif %});
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(
|
||||
config.cors_allowed_origins.iter()
|
||||
.filter_map(|o| o.parse::<HeaderValue>().ok())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Ok(app_router()
|
||||
.with_state(state)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(cors))
|
||||
}
|
||||
0
crates/bootstrap/src/lib.rs
Normal file
0
crates/bootstrap/src/lib.rs
Normal file
28
crates/bootstrap/src/main.rs
Normal file
28
crates/bootstrap/src/main.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use std::net::SocketAddr;
|
||||
use tracing::info;
|
||||
|
||||
mod config;
|
||||
mod factory;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive("bootstrap=info".parse()?)
|
||||
.add_directive("tower_http=debug".parse()?),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = config::Config::from_env();
|
||||
let app = factory::build_app(&config).await?;
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
|
||||
info!("🚀 Server running at http://{addr}");
|
||||
info!("📖 Scalar docs at http://{addr}/scalar");
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
13
crates/domain/Cargo.toml
Normal file
13
crates/domain/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "domain"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
2
crates/domain/src/entities/mod.rs
Normal file
2
crates/domain/src/entities/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod user;
|
||||
pub use user::User;
|
||||
17
crates/domain/src/entities/user.rs
Normal file
17
crates/domain/src/entities/user.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use crate::value_objects::{Email, PasswordHash, Role, UserId};
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
pub email: Email,
|
||||
pub password_hash: PasswordHash,
|
||||
pub role: Role,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn new(id: UserId, email: Email, password_hash: PasswordHash) -> Self {
|
||||
Self { id, email, password_hash, role: Role::User, created_at: Utc::now() }
|
||||
}
|
||||
}
|
||||
13
crates/domain/src/errors.rs
Normal file
13
crates/domain/src/errors.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DomainError {
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
7
crates/domain/src/events.rs
Normal file
7
crates/domain/src/events.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DomainEvent {
|
||||
UserRegistered { user_id: Uuid },
|
||||
UserLoggedIn { user_id: Uuid },
|
||||
}
|
||||
5
crates/domain/src/lib.rs
Normal file
5
crates/domain/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod entities;
|
||||
pub mod errors;
|
||||
pub mod events;
|
||||
pub mod ports;
|
||||
pub mod value_objects;
|
||||
14
crates/domain/src/ports/auth.rs
Normal file
14
crates/domain/src/ports/auth.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use async_trait::async_trait;
|
||||
use crate::{errors::DomainError, value_objects::{PasswordHash, Role, UserId}};
|
||||
|
||||
#[async_trait]
|
||||
pub trait PasswordHasher: Send + Sync {
|
||||
async fn hash(&self, password: &str) -> Result<PasswordHash, DomainError>;
|
||||
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait TokenIssuer: Send + Sync {
|
||||
async fn issue(&self, user_id: &UserId, role: &Role) -> Result<String, DomainError>;
|
||||
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError>;
|
||||
}
|
||||
7
crates/domain/src/ports/mod.rs
Normal file
7
crates/domain/src/ports/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod auth;
|
||||
mod storage;
|
||||
mod user_repo;
|
||||
|
||||
pub use auth::{PasswordHasher, TokenIssuer};
|
||||
pub use storage::{DataStream, StoragePort, StorageReader, StorageWriter};
|
||||
pub use user_repo::UserRepository;
|
||||
7
crates/domain/src/ports/mod.rs.liquid
Normal file
7
crates/domain/src/ports/mod.rs.liquid
Normal file
@@ -0,0 +1,7 @@
|
||||
mod auth;
|
||||
{% if storage %}mod storage;{% endif %}
|
||||
mod user_repo;
|
||||
|
||||
pub use auth::{PasswordHasher, TokenIssuer};
|
||||
{% if storage %}pub use storage::{DataStream, StoragePort, StorageReader, StorageWriter};{% endif %}
|
||||
pub use user_repo::UserRepository;
|
||||
52
crates/domain/src/ports/storage.rs
Normal file
52
crates/domain/src/ports/storage.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{self, BoxStream, StreamExt};
|
||||
use crate::errors::DomainError;
|
||||
|
||||
pub type DataStream = BoxStream<'static, Result<Bytes, DomainError>>;
|
||||
|
||||
/// Read operations on object storage. Keys are full paths relative to the adapter root.
|
||||
#[async_trait]
|
||||
pub trait StorageReader: Send + Sync {
|
||||
/// Returns the content of `key` as a stream. Returns `DomainError::NotFound` if absent.
|
||||
async fn get(&self, key: &str) -> Result<DataStream, DomainError>;
|
||||
|
||||
/// Lists all keys whose path begins with `prefix`, or all keys when `prefix` is `None`.
|
||||
/// Returned keys are **full paths from the adapter root**, not relative to `prefix`.
|
||||
/// Example: `list(Some("docs"))` returns `["docs/readme.txt"]`, not `["readme.txt"]`.
|
||||
async fn list(&self, prefix: Option<&str>) -> Result<Vec<String>, DomainError>;
|
||||
|
||||
/// Convenience: reads the entire content of `key` into memory. Wraps `get`.
|
||||
async fn get_bytes(&self, key: &str) -> Result<Bytes, DomainError> {
|
||||
let mut stream = self.get(key).await?;
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
buf.extend_from_slice(&chunk?);
|
||||
}
|
||||
Ok(Bytes::from(buf))
|
||||
}
|
||||
}
|
||||
|
||||
/// Write operations on object storage.
|
||||
#[async_trait]
|
||||
pub trait StorageWriter: Send + Sync {
|
||||
/// Stores `data` at `key`. Overwrites any existing content at that key silently.
|
||||
async fn put(&self, key: &str, data: DataStream) -> Result<(), DomainError>;
|
||||
|
||||
/// Deletes `key`. Returns `Ok(())` even if the key does not exist (idempotent).
|
||||
async fn delete(&self, key: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Convenience: stores an in-memory buffer at `key`. Wraps `put`.
|
||||
async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), DomainError> {
|
||||
self.put(key, Box::pin(stream::once(async move { Ok(data) }))).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined read + write storage interface.
|
||||
///
|
||||
/// **Usage note:** `Arc<dyn StoragePort>` is the intended DI type everywhere.
|
||||
/// `StorageReader` and `StorageWriter` exist for implementation clarity, but Rust does not
|
||||
/// support narrowing `Arc<dyn StoragePort>` to `Arc<dyn StorageReader>` at runtime.
|
||||
/// Inject `Arc<dyn StoragePort>` into constructors and pass `.clone()` from the factory.
|
||||
pub trait StoragePort: StorageReader + StorageWriter {}
|
||||
impl<T: StorageReader + StorageWriter> StoragePort for T {}
|
||||
10
crates/domain/src/ports/user_repo.rs
Normal file
10
crates/domain/src/ports/user_repo.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use async_trait::async_trait;
|
||||
use crate::{entities::User, errors::DomainError, value_objects::{Email, UserId}};
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserRepository: Send + Sync {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||
async fn delete(&self, id: &UserId) -> Result<(), DomainError>;
|
||||
}
|
||||
42
crates/domain/src/value_objects/email.rs
Normal file
42
crates/domain/src/value_objects/email.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Email(String);
|
||||
|
||||
impl Email {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let value = value.into().trim().to_lowercase();
|
||||
if value.is_empty() || !value.contains('@') {
|
||||
return Err(DomainError::Validation("Invalid email address".to_string()));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Email {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_empty() { assert!(Email::new("").is_err()); }
|
||||
|
||||
#[test]
|
||||
fn rejects_no_at() { assert!(Email::new("notanemail").is_err()); }
|
||||
|
||||
#[test]
|
||||
fn accepts_valid() { assert!(Email::new("user@example.com").is_ok()); }
|
||||
|
||||
#[test]
|
||||
fn lowercases_and_trims() {
|
||||
let email = Email::new(" User@Example.Com ").unwrap();
|
||||
assert_eq!(email.as_str(), "user@example.com");
|
||||
}
|
||||
}
|
||||
9
crates/domain/src/value_objects/mod.rs
Normal file
9
crates/domain/src/value_objects/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod email;
|
||||
mod password;
|
||||
mod role;
|
||||
mod user_id;
|
||||
|
||||
pub use email::Email;
|
||||
pub use password::PasswordHash;
|
||||
pub use role::Role;
|
||||
pub use user_id::UserId;
|
||||
14
crates/domain/src/value_objects/password.rs
Normal file
14
crates/domain/src/value_objects/password.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
// Manual Debug — redacts hash to prevent it appearing in logs
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PasswordHash(String);
|
||||
|
||||
impl std::fmt::Debug for PasswordHash {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("PasswordHash").field(&"[redacted]").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PasswordHash {
|
||||
pub fn from_hash(hash: String) -> Self { Self(hash) }
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
}
|
||||
23
crates/domain/src/value_objects/role.rs
Normal file
23
crates/domain/src/value_objects/role.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role { User, Admin }
|
||||
|
||||
impl std::fmt::Display for Role {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Role::User => write!(f, "user"),
|
||||
Role::Admin => write!(f, "admin"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Role {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"user" => Ok(Role::User),
|
||||
"admin" => Ok(Role::Admin),
|
||||
other => Err(format!("Unknown role: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
22
crates/domain/src/value_objects/user_id.rs
Normal file
22
crates/domain/src/value_objects/user_id.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UserId(uuid::Uuid);
|
||||
|
||||
impl UserId {
|
||||
pub fn new() -> Self { Self(uuid::Uuid::new_v4()) }
|
||||
pub fn from_uuid(id: uuid::Uuid) -> Self { Self(id) }
|
||||
pub fn as_uuid(&self) -> &uuid::Uuid { &self.0 }
|
||||
}
|
||||
|
||||
impl Default for UserId {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UserId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<uuid::Uuid> for UserId {
|
||||
fn from(id: uuid::Uuid) -> Self { Self(id) }
|
||||
}
|
||||
19
crates/presentation/Cargo.toml
Normal file
19
crates/presentation/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "presentation"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
api-types = { path = "../api-types" }
|
||||
axum = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
utoipa = { workspace = true }
|
||||
utoipa-scalar = { workspace = true }
|
||||
25
crates/presentation/src/errors.rs
Normal file
25
crates/presentation/src/errors.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
|
||||
use domain::errors::DomainError;
|
||||
use serde_json::json;
|
||||
|
||||
pub struct AppError(DomainError);
|
||||
|
||||
impl From<DomainError> for AppError {
|
||||
fn from(e: DomainError) -> Self { Self(e) }
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self.0 {
|
||||
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
DomainError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
|
||||
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
|
||||
DomainError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
|
||||
DomainError::Internal(msg) => {
|
||||
tracing::error!("Internal error: {msg}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
}
|
||||
38
crates/presentation/src/extractors/auth.rs
Normal file
38
crates/presentation/src/extractors/auth.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use domain::value_objects::{Role, UserId};
|
||||
use serde_json::json;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub struct JwtClaims {
|
||||
pub user_id: UserId,
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for JwtClaims {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "Missing Authorization header" }))).into_response()
|
||||
})?;
|
||||
|
||||
let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
|
||||
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid Authorization format" }))).into_response()
|
||||
})?;
|
||||
|
||||
let (user_id, role) = state.token_issuer.verify(token).await.map_err(|_| {
|
||||
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid or expired token" }))).into_response()
|
||||
})?;
|
||||
|
||||
Ok(JwtClaims { user_id, role })
|
||||
}
|
||||
}
|
||||
28
crates/presentation/src/extractors/json.rs
Normal file
28
crates/presentation/src/extractors/json.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use axum::{
|
||||
extract::{rejection::JsonRejection, FromRequest, Request},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
|
||||
pub struct ValidatedJson<T>(pub T);
|
||||
|
||||
impl<T, S> FromRequest<S> for ValidatedJson<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
S: Send + Sync,
|
||||
Json<T>: FromRequest<S, Rejection = JsonRejection>,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
Json::<T>::from_request(req, state)
|
||||
.await
|
||||
.map(|Json(value)| ValidatedJson(value))
|
||||
.map_err(|rejection| {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": rejection.body_text() }))).into_response()
|
||||
})
|
||||
}
|
||||
}
|
||||
5
crates/presentation/src/extractors/mod.rs
Normal file
5
crates/presentation/src/extractors/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod json;
|
||||
|
||||
pub use auth::JwtClaims;
|
||||
pub use json::ValidatedJson;
|
||||
56
crates/presentation/src/handlers/auth.rs
Normal file
56
crates/presentation/src/handlers/auth.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use api_types::{
|
||||
requests::{LoginRequest, RegisterRequest},
|
||||
responses::{AuthResponse, UserResponse},
|
||||
};
|
||||
use crate::{errors::AppError, extractors::{JwtClaims, ValidatedJson}, state::AppState};
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/register",
|
||||
request_body = RegisterRequest,
|
||||
responses(
|
||||
(status = 201, description = "User registered", body = AuthResponse),
|
||||
(status = 409, description = "Email already taken"),
|
||||
(status = 422, description = "Validation error")
|
||||
)
|
||||
)]
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
ValidatedJson(req): ValidatedJson<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<AuthResponse>), AppError> {
|
||||
let user = state.register_uc.execute(&req.email, &req.password).await?;
|
||||
let token = state.token_issuer.issue(&user.id, &user.role).await.map_err(AppError::from)?;
|
||||
Ok((StatusCode::CREATED, Json(AuthResponse { token, user: UserResponse::from_domain(&user) })))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/login",
|
||||
request_body = LoginRequest,
|
||||
responses(
|
||||
(status = 200, description = "Login successful", body = AuthResponse),
|
||||
(status = 401, description = "Invalid credentials")
|
||||
)
|
||||
)]
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
ValidatedJson(req): ValidatedJson<LoginRequest>,
|
||||
) -> Result<Json<AuthResponse>, AppError> {
|
||||
let (user, token) = state.login_uc.execute(&req.email, &req.password).await?;
|
||||
Ok(Json(AuthResponse { token, user: UserResponse::from_domain(&user) }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/auth/me",
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Current user profile", body = UserResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn me(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
) -> Result<Json<UserResponse>, AppError> {
|
||||
let user = state.get_profile_uc.execute(&claims.user_id).await?;
|
||||
Ok(Json(UserResponse::from_domain(&user)))
|
||||
}
|
||||
7
crates/presentation/src/handlers/health.rs
Normal file
7
crates/presentation/src/handlers/health.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use axum::{http::StatusCode, Json};
|
||||
use serde_json::json;
|
||||
|
||||
#[utoipa::path(get, path = "/health", responses((status = 200, description = "Service is healthy")))]
|
||||
pub async fn health() -> (StatusCode, Json<serde_json::Value>) {
|
||||
(StatusCode::OK, Json(json!({ "status": "ok" })))
|
||||
}
|
||||
2
crates/presentation/src/handlers/mod.rs
Normal file
2
crates/presentation/src/handlers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod health;
|
||||
27
crates/presentation/src/handlers/storage_example.rs
Normal file
27
crates/presentation/src/handlers/storage_example.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
// Example: stream a stored file as an HTTP response.
|
||||
// Remove this file or replace with your own handlers.
|
||||
//
|
||||
// To use, add to your router:
|
||||
// .route("/files/*key", get(storage_example::get_file))
|
||||
//
|
||||
// use axum::{
|
||||
// body::Body,
|
||||
// extract::{Path, State},
|
||||
// http::StatusCode,
|
||||
// response::IntoResponse,
|
||||
// };
|
||||
// use futures::StreamExt;
|
||||
// use crate::state::AppState;
|
||||
//
|
||||
// pub async fn get_file(
|
||||
// Path(key): Path<String>,
|
||||
// State(state): State<AppState>,
|
||||
// ) -> Result<impl IntoResponse, StatusCode> {
|
||||
// let stream = state
|
||||
// .storage
|
||||
// .get(&key)
|
||||
// .await
|
||||
// .map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
// let body = Body::from_stream(stream.map(|r| r.map_err(|e| e.to_string())));
|
||||
// Ok(body)
|
||||
// }
|
||||
6
crates/presentation/src/lib.rs
Normal file
6
crates/presentation/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod errors;
|
||||
pub mod extractors;
|
||||
pub mod handlers;
|
||||
pub mod openapi;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
41
crates/presentation/src/openapi/mod.rs
Normal file
41
crates/presentation/src/openapi/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use utoipa::{openapi::security::{Http, HttpAuthScheme, SecurityScheme}, Modify, OpenApi};
|
||||
use utoipa_scalar::{Scalar, Servable};
|
||||
use axum::Router;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::health::health,
|
||||
crate::handlers::auth::register,
|
||||
crate::handlers::auth::login,
|
||||
crate::handlers::auth::me,
|
||||
),
|
||||
components(schemas(
|
||||
api_types::requests::RegisterRequest,
|
||||
api_types::requests::LoginRequest,
|
||||
api_types::responses::AuthResponse,
|
||||
api_types::responses::UserResponse,
|
||||
)),
|
||||
modifiers(&SecurityAddon),
|
||||
info(title = "k-template", version = "0.1.0")
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
struct SecurityAddon;
|
||||
impl Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
if let Some(components) = openapi.components.as_mut() {
|
||||
components.add_security_scheme(
|
||||
"bearer_token",
|
||||
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn openapi_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
||||
.route("/api-docs/openapi.json", axum::routing::get(|| async { axum::Json(ApiDoc::openapi()) }))
|
||||
}
|
||||
16
crates/presentation/src/routes.rs
Normal file
16
crates/presentation/src/routes.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use axum::{routing::{get, post}, Router};
|
||||
use crate::{handlers::{auth, health}, openapi::openapi_router, state::AppState};
|
||||
|
||||
pub fn api_v1_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/auth/register", post(auth::register))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/me", get(auth::me))
|
||||
}
|
||||
|
||||
pub fn app_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/health", get(health::health))
|
||||
.nest("/api/v1", api_v1_router())
|
||||
.merge(openapi_router())
|
||||
}
|
||||
26
crates/presentation/src/state.rs
Normal file
26
crates/presentation/src/state.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use std::sync::Arc;
|
||||
use application::use_cases::{GetProfile, LoginUser, RegisterUser};
|
||||
use domain::ports::{StoragePort, TokenIssuer};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub register_uc: Arc<RegisterUser>,
|
||||
pub login_uc: Arc<LoginUser>,
|
||||
pub get_profile_uc: Arc<GetProfile>,
|
||||
pub token_issuer: Arc<dyn TokenIssuer>,
|
||||
/// Direct storage access for handlers. Use cases that need storage should receive
|
||||
/// `Arc<dyn StoragePort>` in their own constructor rather than reading it from `AppState`.
|
||||
pub storage: Arc<dyn StoragePort>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(
|
||||
register_uc: Arc<RegisterUser>,
|
||||
login_uc: Arc<LoginUser>,
|
||||
get_profile_uc: Arc<GetProfile>,
|
||||
token_issuer: Arc<dyn TokenIssuer>,
|
||||
storage: Arc<dyn StoragePort>,
|
||||
) -> Self {
|
||||
Self { register_uc, login_uc, get_profile_uc, token_issuer, storage }
|
||||
}
|
||||
}
|
||||
28
crates/presentation/src/state.rs.liquid
Normal file
28
crates/presentation/src/state.rs.liquid
Normal file
@@ -0,0 +1,28 @@
|
||||
use std::sync::Arc;
|
||||
use application::use_cases::{GetProfile, LoginUser, RegisterUser};
|
||||
{% if storage %}
|
||||
use domain::ports::{StoragePort, TokenIssuer};
|
||||
{% else %}
|
||||
use domain::ports::TokenIssuer;
|
||||
{% endif %}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub register_uc: Arc<RegisterUser>,
|
||||
pub login_uc: Arc<LoginUser>,
|
||||
pub get_profile_uc: Arc<GetProfile>,
|
||||
pub token_issuer: Arc<dyn TokenIssuer>,
|
||||
{% if storage %}pub storage: Arc<dyn StoragePort>,{% endif %}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(
|
||||
register_uc: Arc<RegisterUser>,
|
||||
login_uc: Arc<LoginUser>,
|
||||
get_profile_uc: Arc<GetProfile>,
|
||||
token_issuer: Arc<dyn TokenIssuer>,
|
||||
{% if storage %}storage: Arc<dyn StoragePort>,{% endif %}
|
||||
) -> Self {
|
||||
Self { register_uc, login_uc, get_profile_uc, token_issuer{% if storage %}, storage{% endif %} }
|
||||
}
|
||||
}
|
||||
18
crates/worker/Cargo.toml
Normal file
18
crates/worker/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "worker"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "worker"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
adapters-sqlite = { path = "../adapters/sqlite" }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
23
crates/worker/Cargo.toml.liquid
Normal file
23
crates/worker/Cargo.toml.liquid
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "worker"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "{{project_name}}-worker"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
{% if database == "sqlite" %}
|
||||
adapters-sqlite = { path = "../adapters/sqlite" }
|
||||
{% endif %}
|
||||
{% if database == "postgres" %}
|
||||
adapters-postgres = { path = "../adapters/postgres" }
|
||||
{% endif %}
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
18
crates/worker/src/config.rs
Normal file
18
crates/worker/src/config.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerConfig {
|
||||
pub database_url: String,
|
||||
pub example_job_interval_secs: u64,
|
||||
}
|
||||
|
||||
impl WorkerConfig {
|
||||
pub fn from_env() -> Self {
|
||||
dotenvy::dotenv().ok();
|
||||
Self {
|
||||
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"),
|
||||
example_job_interval_secs: std::env::var("EXAMPLE_JOB_INTERVAL_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60),
|
||||
}
|
||||
}
|
||||
}
|
||||
7
crates/worker/src/job.rs
Normal file
7
crates/worker/src/job.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Job: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
async fn run(&self) -> anyhow::Result<()>;
|
||||
}
|
||||
14
crates/worker/src/jobs/example.rs
Normal file
14
crates/worker/src/jobs/example.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use async_trait::async_trait;
|
||||
use tracing::info;
|
||||
use crate::job::Job;
|
||||
|
||||
pub struct ExampleJob;
|
||||
|
||||
#[async_trait]
|
||||
impl Job for ExampleJob {
|
||||
fn name(&self) -> &str { "example" }
|
||||
async fn run(&self) -> anyhow::Result<()> {
|
||||
info!("example job ran — replace with real work");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user