From b8a4f05d73fb3ded27ba26e8b1df202f258e8ddb Mon Sep 17 00:00:00 2001 From: "Kawin.V" Date: Sun, 30 Aug 2026 12:43:18 +0700 Subject: [PATCH 1/2] feat!: restructure to clean architecture (DDD) with exposed inbound contract Mirrors zercle-go-template: each feature carries contract/domain/port/ application/adapter/{driving,driven}/di layers; platform/ replaces shared/ + infrastructure/ + middleware/ + config.rs; a published api::v1 facade exposes the inbound contract types + error codes outward-only. - tests/architecture.rs: executable dependency gates (domain innermost, contract leaf, port->own domain, application->domain+port+contract, driving/driven adapter isolation, platform feature-agnostic, api facade outward-only); dedicated CI Architecture job - platform/server decoupled from features: AppState + grpc_server() builder live in the shell; features hand over pre-mounted axum routers and a tonic Router via di::register - application::Service speaks contract types at the boundary; wire-id parsing unified in the usecase (one validation path for HTTP + gRPC) - sentinel->AppError mapping registered in feature di (RegisterSentinel parity), keeping the domain layer dependency-free - ci: integration job now runs the FULL suite against postgres+valkey service containers (--include-ignored); all actions pinned to commit SHAs; persist-credentials: false on every checkout - docs: README architecture/CI/testing sections rewritten for the new layout; Taskfile gains test-architecture, fixes stale --ignored invocations and a pre-existing YAML plain-scalar bug in the proto task Verified: fmt=0; clippy --locked -D warnings=0; cargo test 104 passed / 0 failed (incl. live postgres+valkey e2e + HTTP integration); architecture mutation probe red->green. BREAKING CHANGE: module paths changed (shared/infrastructure/middleware folded into platform/; feature modules re-layered; config moved to platform::config). --- .github/workflows/ci.yml | 94 +++-- README.md | 170 ++++++--- Taskfile.yml | 15 +- src/api/mod.rs | 9 + src/api/v1.rs | 48 +++ src/app.rs | 84 ++-- src/bin/migrate.rs | 2 +- src/features/example/adapter/driven/mod.rs | 8 + .../driven/postgres.rs} | 8 +- src/features/example/adapter/driving/grpc.rs | 235 ++++++++++++ .../{handler.rs => adapter/driving/http.rs} | 98 ++--- src/features/example/adapter/driving/mod.rs | 9 + src/features/example/adapter/mod.rs | 10 + src/features/example/application/mod.rs | 15 + src/features/example/application/service.rs | 30 ++ src/features/example/application/usecase.rs | 338 +++++++++++++++++ src/features/example/contract/create_item.rs | 27 ++ src/features/example/contract/list_items.rs | 24 ++ src/features/example/contract/mod.rs | 47 +++ src/features/example/di.rs | 81 ++++ src/features/example/domain.rs | 132 ------- src/features/example/domain/error.rs | 40 ++ src/features/example/domain/item.rs | 39 ++ src/features/example/domain/mod.rs | 11 + src/features/example/dto.rs | 121 ------ src/features/example/grpc.rs | 208 ---------- src/features/example/mod.rs | 47 ++- src/features/example/port/mod.rs | 11 + src/features/example/port/repository.rs | 19 + src/features/example/service.rs | 233 ------------ src/infrastructure/mod.rs | 7 - src/lib.rs | 34 +- src/main.rs | 2 +- src/{ => platform}/config.rs | 0 src/{infrastructure => platform}/db.rs | 2 +- src/{shared => platform}/errors.rs | 37 +- src/{shared => platform}/health.rs | 0 src/{ => platform}/middleware/access_log.rs | 2 +- src/{ => platform}/middleware/cors.rs | 4 +- src/{ => platform}/middleware/mod.rs | 0 src/{ => platform}/middleware/recover.rs | 2 +- src/{ => platform}/middleware/request_id.rs | 0 src/platform/mod.rs | 16 + .../server/grpc_interceptor.rs | 0 src/{shared => platform}/server/http.rs | 35 +- src/{shared => platform}/server/mod.rs | 114 +++--- src/{shared => platform}/server/shutdown.rs | 0 src/{shared => platform}/telemetry.rs | 2 +- src/{infrastructure => platform}/valkey.rs | 2 +- src/shared/mod.rs | 6 - tests/architecture.rs | 358 ++++++++++++++++++ tests/e2e.rs | 2 +- tests/example_http.rs | 55 ++- 53 files changed, 1865 insertions(+), 1028 deletions(-) create mode 100644 src/api/mod.rs create mode 100644 src/api/v1.rs create mode 100644 src/features/example/adapter/driven/mod.rs rename src/features/example/{repository.rs => adapter/driven/postgres.rs} (93%) create mode 100644 src/features/example/adapter/driving/grpc.rs rename src/features/example/{handler.rs => adapter/driving/http.rs} (68%) create mode 100644 src/features/example/adapter/driving/mod.rs create mode 100644 src/features/example/adapter/mod.rs create mode 100644 src/features/example/application/mod.rs create mode 100644 src/features/example/application/service.rs create mode 100644 src/features/example/application/usecase.rs create mode 100644 src/features/example/contract/create_item.rs create mode 100644 src/features/example/contract/list_items.rs create mode 100644 src/features/example/contract/mod.rs create mode 100644 src/features/example/di.rs delete mode 100644 src/features/example/domain.rs create mode 100644 src/features/example/domain/error.rs create mode 100644 src/features/example/domain/item.rs create mode 100644 src/features/example/domain/mod.rs delete mode 100644 src/features/example/dto.rs delete mode 100644 src/features/example/grpc.rs create mode 100644 src/features/example/port/mod.rs create mode 100644 src/features/example/port/repository.rs delete mode 100644 src/features/example/service.rs delete mode 100644 src/infrastructure/mod.rs rename src/{ => platform}/config.rs (100%) rename src/{infrastructure => platform}/db.rs (97%) rename src/{shared => platform}/errors.rs (85%) rename src/{shared => platform}/health.rs (100%) rename src/{ => platform}/middleware/access_log.rs (98%) rename src/{ => platform}/middleware/cors.rs (99%) rename src/{ => platform}/middleware/mod.rs (100%) rename src/{ => platform}/middleware/recover.rs (98%) rename src/{ => platform}/middleware/request_id.rs (100%) create mode 100644 src/platform/mod.rs rename src/{shared => platform}/server/grpc_interceptor.rs (100%) rename src/{shared => platform}/server/http.rs (86%) rename src/{shared => platform}/server/mod.rs (69%) rename src/{shared => platform}/server/shutdown.rs (100%) rename src/{shared => platform}/telemetry.rs (99%) rename src/{infrastructure => platform}/valkey.rs (99%) delete mode 100644 src/shared/mod.rs create mode 100644 tests/architecture.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe66726..aa04580 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: components: rustfmt - name: cargo fmt --check @@ -36,41 +38,70 @@ jobs: timeout-minutes: 15 needs: fmt steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install protoc - uses: arduino/setup-protoc@v3 + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 with: version: "27.1" repo-token: ${{ secrets.GITHUB_TOKEN }} - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: ${{ runner.os }}-clippy - name: cargo clippy run: cargo clippy --all-targets --locked -- -D warnings + architecture: + # Executable clean-architecture dependency gates (tests/architecture.rs): + # every dependency must point inward; the api facade is outward-only. + name: Architecture + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: clippy + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + version: "27.1" + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: ${{ runner.os }}-arch + - name: Layering rules (dependencies point inward only) + run: cargo test --test architecture --locked + unit: name: Unit tests runs-on: ubuntu-latest timeout-minutes: 20 needs: clippy steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install protoc - uses: arduino/setup-protoc@v3 + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 with: version: "27.1" repo-token: ${{ secrets.GITHUB_TOKEN }} - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: components: rustfmt, clippy, llvm-tools-preview - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: ${{ runner.os }}-unit - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov + uses: taiki-e/install-action@6a241a1328ca6173d49760e1de6702cd5e52ef94 # cargo-llvm-cov - name: Build test artifacts # Ensures the coverage run below measures binaries too. run: cargo build --all-targets --locked @@ -85,9 +116,9 @@ jobs: run: cargo llvm-cov report --html - name: Coverage gate (60%) env: - THRESHOLD: '60' + THRESHOLD: "60" run: cargo llvm-cov report --fail-under-lines "$THRESHOLD" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: coverage-report path: | @@ -100,7 +131,7 @@ jobs: name: Integration tests runs-on: ubuntu-latest timeout-minutes: 25 - needs: unit + needs: [unit, architecture] services: postgres: image: docker.io/library/postgres:18-alpine @@ -128,51 +159,58 @@ jobs: env: APP_ENVIRONMENT: test DB_HOST: localhost - DB_PORT: '5432' + DB_PORT: "5432" DB_NAME: zercle_template_test DB_USER: postgres DB_PASSWORD: postgres DB_SSL_MODE: disable DATABASE_URL: postgres://postgres:postgres@localhost:5432/zercle_template_test VALKEY_HOST: localhost - VALKEY_PORT: '6379' - VALKEY_DB: '0' + VALKEY_PORT: "6379" + VALKEY_DB: "0" RUST_LOG: info,sqlx=warn steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install protoc - uses: arduino/setup-protoc@v3 + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 with: version: "27.1" repo-token: ${{ secrets.GITHUB_TOKEN }} - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: ${{ runner.os }}-integration - name: Run migrations run: cargo run --locked --bin migrate -- up - name: Run integration tests - # Wave 4 / wave 7 #[ignore]-gated tests live under --ignored. - run: cargo test --all-targets --locked -- --ignored --test-threads=1 + # Full suite against the service containers: the #[ignore]-gated + # live-infra tests (db, valkey, adapter roundtrip) plus the + # self-skipping HTTP integration test and the e2e server test, + # which no longer skip because infra IS reachable here. + run: cargo test --all-targets --locked -- --include-ignored --test-threads=1 build: name: Build runs-on: ubuntu-latest timeout-minutes: 20 - needs: [clippy, unit] + needs: [clippy, unit, architecture] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install protoc - uses: arduino/setup-protoc@v3 + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 with: version: "27.1" repo-token: ${{ secrets.GITHUB_TOKEN }} - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: ${{ runner.os }}-build - name: cargo build --release (both bins) diff --git a/README.md b/README.md index dcd638f..dcaa95a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # zercle-rust-template Opinionated Rust microservice template — axum (HTTP) + tonic (gRPC) + sqlx (PostgreSQL) + -`redis` (Valkey) + `tracing`/`opentelemetry`, organized as clean-architecture-per-feature with a -single composition root (`Arc`). +`redis` (Valkey) + `tracing`/`opentelemetry`, organized as clean architecture (DDD) per feature: +`contract → domain → port → application → adapter/{driving,driven}` with per-feature `di` +wiring, a decoupled platform shell, and a published inbound contract facade (`crate::api::v1`). +All dependencies point inward, and the rule is enforced executably in CI. ## Prerequisites @@ -60,7 +62,7 @@ zercle-rust-template/ ├── .agents/ # harness state (conductor-owned) ├── .github/ │ ├── dependabot.yml # weekly cargo + actions + docker updates -│ └── workflows/ +│ └── workflows/ci.yml # fmt → clippy → architecture → unit → integration → build ├── proto/ │ └── example/v1/example.proto # example feature gRPC contract ├── migrations/ @@ -70,38 +72,53 @@ zercle-rust-template/ │ ├── main.rs # bin `server`: load config → lib::run │ ├── lib.rs # crate root + module declarations │ ├── bin/migrate.rs # bin `migrate`: up / down [N] / force / version -│ ├── config.rs # Config + Load + Validate (decision D5) -│ ├── app.rs # AppState + build() + run() (composition root) -│ ├── shared/ -│ │ ├── errors.rs # AppError + IntoResponse + tonic mapper +│ ├── app.rs # composition root: platform + features::example::di +│ ├── api/ +│ │ ├── mod.rs # published facade namespace (outward-only) +│ │ └── v1.rs # re-exports contract types + errcodes (Go pkg/api/v1) +│ ├── platform/ # cross-cutting shell — never imports features +│ │ ├── config.rs # Config + Load + Validate (decision D5) +│ │ ├── db.rs # PgPool + ping + readiness checker +│ │ ├── valkey.rs # redis client + ping + readiness checker +│ │ ├── errors.rs # AppError + errcodes + IntoResponse + tonic mapper │ │ ├── health.rs # Checker trait + Registry │ │ ├── telemetry.rs # tracing + OTel + Prometheus init +│ │ ├── middleware/ +│ │ │ ├── request_id.rs # X-Request-ID propagate / generate +│ │ │ ├── access_log.rs # one structured access log per request +│ │ │ ├── recover.rs # panic → 500 +│ │ │ └── cors.rs # tower-http CORS from config │ │ └── server/ -│ │ ├── mod.rs # Application orchestrator -│ │ ├── http.rs # axum router, middleware stack +│ │ ├── mod.rs # AppState + run() + grpc_server() builder +│ │ ├── http.rs # axum router, middleware stack, shared routes │ │ ├── grpc_interceptor.rs # tonic unary interceptor (request_id + access log) │ │ └── shutdown.rs # ordered graceful shutdown -│ ├── middleware/ -│ │ ├── request_id.rs # X-Request-ID propagate / generate -│ │ ├── access_log.rs # one structured access log per request -│ │ ├── recover.rs # panic → 500 -│ │ └── cors.rs # tower-http CORS from config -│ ├── infrastructure/ -│ │ ├── db.rs # PgPool + ping + readiness checker -│ │ └── valkey.rs # redis client + ping + readiness checker │ └── features/ │ └── example/ # STUB FEATURE — delete to start your project -│ ├── mod.rs -│ ├── domain.rs # Item entity + Repository/Service traits -│ ├── dto.rs # request / response shapes -│ ├── repository.rs # sqlx impl of Repository -│ ├── service.rs # use-case impl of Service -│ ├── handler.rs # axum HTTP handlers -│ └── grpc.rs # tonic ExampleService server +│ ├── contract/ # inbound wire types (LEAF; published via crate::api::v1) +│ │ ├── create_item.rs # CreateItemRequest + ItemResponse +│ │ └── list_items.rs # ListItemsRequest + ListItemsResponse +│ ├── domain/ # innermost layer (no crate deps) +│ │ ├── item.rs # Item entity +│ │ └── error.rs # domain sentinel errors +│ ├── port/ +│ │ └── repository.rs # outbound Repository trait (mockall) +│ ├── application/ +│ │ ├── service.rs # inbound Service trait — speaks contract types +│ │ └── usecase.rs # Usecase impl: domain ↔ contract mapping +│ ├── adapter/ +│ │ ├── driving/ # driving adapters (call application::Service) +│ │ │ ├── http.rs # axum handlers +│ │ │ └── grpc.rs # tonic ExampleService server +│ │ └── driven/ +│ │ └── postgres.rs # sqlx impl of port::Repository +│ ├── di.rs # wiring + sentinel→AppError registration +│ └── mod.rs # layer map + delete-me notice ├── tests/ │ ├── common/mod.rs # shared helpers for integration + e2e tests -│ ├── example_http.rs # integration: HTTP feature flows (--ignored) -│ └── e2e.rs # e2e: boots the full app (--ignored) +│ ├── architecture.rs # executable dependency gates (layering rules) +│ ├── example_http.rs # integration: feature flows via di (self-skips w/o infra) +│ └── e2e.rs # e2e: boots the full app (self-skips w/o infra) ├── build.rs # tonic-build: compile proto/example/v1/example.proto ├── Cargo.toml # crate manifest ├── Cargo.lock # committed for reproducible builds @@ -121,29 +138,50 @@ zercle-rust-template/ ## Architecture overview -- **Composition root = `Arc`** (no runtime DI container — idiomatic Rust; see - `canvas.md ## Assumptions` row 9 / decision D2). `app::build` constructs every dependency - (config, telemetry, `PgPool`, `ConnectionManager`, health registry, feature services), wraps - the result in `Arc`, and hands it to axum `State` and tonic request extensions. -- **Clean architecture per feature**: `domain` (entities + `Repository`/`Service` traits + error - enum) → `repository` (sqlx adapter) → `service` (use-case impl) → `handler` (axum) / - `grpc.rs` (tonic) → `mod.rs` (`router()` + `grpc_service()`). -- **Trait-based ports + mockall mocks**: handlers and tests inject `MockRepository` / - `MockService`; no real DB required for unit tests. -- **gRPC unary interceptor** (`src/shared/server/grpc_interceptor.rs`) mirrors the HTTP +Clean architecture (DDD) per feature, mirroring `zercle-go-template`'s +`internal/features//{contract,domain,port,application,adapter,di}`. **All dependencies +point inward** — and the rule is executable, not aspirational: `tests/architecture.rs` +(the CI **Architecture** job) scans every `use crate::…` statement and fails on any +layer violation. + +- **`contract/` — the exposed inbound type contract (leaf)**: canonical request/response wire + types (`serde` + `validator` only). Both driving adapters bind these directly; the published + facade `crate::api::v1` re-exports them (plus `errcodes`) so *other services* can construct + payloads without importing server internals. Internal code may never import `crate::api`. +- **`domain/` — innermost**: entities + sentinel errors, no crate-internal dependencies. +- **`port/` — outbound (driven) ports**: the `Repository` trait; references only its own domain. +- **`application/` — use cases**: the inbound `Service` port speaks **contract types** at the + boundary (`create(CreateItemRequest) -> ItemResponse`), so driving adapters never map to or + from domain entities. The `Usecase` impl owns the domain ↔ contract mapping and the single + validation path (e.g. wire-id parsing) shared by HTTP and gRPC. +- **`adapter/driving/`** (axum handlers, tonic server): translate transport ↔ contract, call + `application::Service`. **`adapter/driven/`** (sqlx): implements `port::Repository` — + (`in` is a Rust keyword, hence the hexagonal `driving`/`driven` names). +- **`di.rs` — composition edge**: wires repository → use case → adapters, nests HTTP routes + under `/api/v1`, registers the feature's gRPC service on the platform's tonic builder, and + registers the `domain::Error → AppError` sentinel mapping (Go `RegisterSentinel` parity). +- **`platform/` — cross-cutting shell, feature-agnostic by rule** (`platform-ignores-features` + gate): config, db, valkey, boundary errors (`AppError` + `errcodes`), health, telemetry, + middleware, and the HTTP/gRPC server shell. Feature routers arrive pre-mounted; the shell + adds shared routes (`/healthz`, `/readyz`, `/metrics`) and the middleware stack. +- **`app.rs` — composition root**: builds platform in dependency order (telemetry → postgres → + valkey → health) and calls each feature's `di::register` — the only feature symbol the shell + references. Adding a feature = adding one `di::register` call. +- **Trait-based ports + mockall mocks**: `#[cfg_attr(test, automock)]` generates + `MockRepository` / `MockService`; use-case and adapter unit tests need no real DB. +- **gRPC unary interceptor** (`src/platform/server/grpc_interceptor.rs`) mirrors the HTTP middleware stack's panic-recovery + access-log guarantees — it recovers handler panics into `tonic::Status::internal` and emits one structured access log per unary call. OTel tracing for gRPC (including streams) is provided by `Server::trace_fn`. -- **Typed errors**: each feature defines a `domain::Error` enum (`thiserror`) and a - `From for AppError` impl registered in the feature's `mod.rs`. The shared - `AppError` enum maps to both `StatusCode` (axum) and `tonic::Code` at the boundary — no - string matching. +- **Typed errors**: each feature's `domain::Error` sentinels map to the shared `AppError` + at the `di` composition edge; `AppError` maps to both `StatusCode` (axum) and + `tonic::Code` at the boundary — no string matching. - **Env binding = explicit leaf table** (decision D5): the `config` crate's default `_` separator would collide with SCREAMING_SNAKE names, so we port the Go `leafBindings()` table verbatim and override each leaf from `std::env` after loading the yaml. - **Graceful shutdown**: SIGTERM/SIGINT triggers `axum::serve(...).with_graceful_shutdown` → - tonic `Server::shutdown` → `PgPool::close` → `ConnectionManager` drop → OTel provider flush, - all bounded by `cfg.app.shutdown_timeout`. + tonic `Router::serve_with_shutdown` drain → `PgPool::close` → `ConnectionManager` drop → + OTel provider flush, all bounded by `cfg.app.shutdown_timeout`. ## Removing the example feature @@ -155,8 +193,17 @@ real project: rm -rf src/features/example ``` -Then edit `src/lib.rs` to drop `pub mod features;` (or keep `features` and add your own -sub-module) and update `Cargo.toml` (drop tonic-build if you don't need gRPC). +Then: + +1. Drop `pub mod example;` from `src/features/mod.rs` (or keep `features` and add your own + sub-module). +2. Remove the `example::di::register` call from `src/app.rs` and the `example` section from + `config.yaml` / `Config` (`src/platform/config.rs`). +3. Remove the published facade re-exports in `src/api/v1.rs` (or point them at your new + feature's `contract` module) and drop `tonic-build` from `Cargo.toml` if you don't need gRPC. + +The platform, `api` facade pattern, and `app` shell do not reference the feature from anywhere +else — that is what the architecture test enforces. ## Testing @@ -165,21 +212,27 @@ sub-module) and update `Cargo.toml` (drop tonic-build if you don't need gRPC). cargo test --all-targets # or: task test-unit -# Integration tests (postgres + valkey required; skip cleanly if unreachable) +# Clean-architecture dependency gates (layering rules; no infra needed) +cargo test --test architecture +# or: task test-architecture + +# Full suite against live postgres + valkey (skips cleanly if unreachable) docker compose up -d postgres valkey -cargo test --all-targets -- --ignored --test-threads=1 +cargo test --all-targets -- --include-ignored --test-threads=1 # or: task test-integration # End-to-end test (boots the full app; needs infra + migrations applied) task migrate-up -cargo test --test e2e -- --ignored +cargo test --test e2e # or: task test-e2e ``` The unit suite covers `config` (yaml parse + validate), `errors` (status / code mapping), -`health` (registry semantics), and the `migrate` CLI parser. Both integration (`tests/example_http.rs`) -and e2e (`tests/e2e.rs`) tests are gated behind `--ignored` and skip cleanly when the relevant -infrastructure is unreachable, so a partial local setup never breaks `cargo test`. +`health` (registry semantics), `contract` (wire shapes + validation), `application` +(use-case rules via mocks), and both driving adapters. `tests/architecture.rs` enforces the +layering. The integration (`tests/example_http.rs`) and e2e (`tests/e2e.rs`) tests skip +cleanly when the relevant infrastructure is unreachable, so a partial local setup never +breaks `cargo test`. Other quality gates: @@ -189,6 +242,23 @@ cargo fmt --all -- --check # or: task fmt-check cargo llvm-cov --workspace --all-targets # or: task cover (requires cargo-llvm-cov) ``` +## CI (GitHub Actions) + +`.github/workflows/ci.yml` runs on every push/PR to `main`/`develop`: + +1. **fmt** — `cargo fmt --all -- --check`. +2. **clippy** — `cargo clippy --all-targets --locked -- -D warnings`. +3. **architecture** — the layering gates from `tests/architecture.rs` (fast, dedicated signal). +4. **unit** — full test run with `cargo-llvm-cov` coverage; lcov + HTML artifacts uploaded, + gated at 60% line coverage. +5. **integration** — full suite (`--include-ignored`) against real `postgres:18-alpine` + + `valkey:9-alpine` service containers, migrations applied via the `migrate` binary. +6. **build** — release build of both binaries (version metadata injected) + a + `docker build` of the Containerfile. + +All actions are pinned to commit SHAs and checkouts run with `persist-credentials: false`; +Dependabot keeps the pins, crates, and base images updated weekly. + ## Deployment - **Local containers**: `docker compose up -d` (add `--profile observability` for OTel + diff --git a/Taskfile.yml b/Taskfile.yml index 35cf669..0b399bf 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -43,14 +43,19 @@ tasks: - cargo test --all-targets test-integration: - desc: Run integration tests against live postgres + valkey. Bring infra up first with `docker compose up -d postgres valkey`, then run. + desc: Run the full suite against live postgres + valkey. Bring infra up first with `docker compose up -d postgres valkey`, then run. Includes the #[ignore]-gated live-infra tests. cmds: - - cargo test --all-targets -- --ignored --test-threads=1 + - cargo test --all-targets -- --include-ignored --test-threads=1 + + test-architecture: + desc: Run the clean-architecture dependency gates (tests/architecture.rs; matches the CI Architecture job). + cmds: + - cargo test --test architecture test-e2e: - desc: Run the e2e test that boots the full app (wave 7). Requires `docker compose up -d postgres valkey` and migrations applied first (`task migrate-up`). + desc: Run the e2e test that boots the full app. Requires `docker compose up -d postgres valkey` and migrations applied first (`task migrate-up`). Skips cleanly when infra is unreachable. cmds: - - cargo test --test e2e -- --ignored + - cargo test --test e2e lint: desc: Run clippy with warnings denied (matches CI). @@ -100,7 +105,7 @@ tasks: proto: desc: Regenerate protobuf/gRPC code. Handled automatically by build.rs (tonic-build) on every cargo build; this task is a no-op kept for parity with the Go Taskfile. cmds: - - echo "proto: handled by build.rs (tonic-build). Run \`cargo build\` to regenerate." + - 'echo "proto: handled by build.rs (tonic-build). Run \`cargo build\` to regenerate."' docker-build: desc: Build the server container image (multi-stage, musl static, distroless/static final). diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..9b4f703 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,9 @@ +//! Published API surface of the service (Go `pkg/api` parity): typed inbound +//! contracts and error codes that other services may import to construct +//! payloads and interpret error envelopes without importing server internals. +//! +//! Internal code must not depend on this facade — the dependency is strictly +//! outward-only (enforced by `tests/architecture.rs`: +//! published-contract-is-outward-only). + +pub mod v1; diff --git a/src/api/v1.rs b/src/api/v1.rs new file mode 100644 index 0000000..99371f7 --- /dev/null +++ b/src/api/v1.rs @@ -0,0 +1,48 @@ +//! Published inbound contract of the `/api/v1` endpoints (Go `pkg/api/v1` +//! parity): the request/response wire types plus the error codes that other +//! services may import. +//! +//! Facade of the canonical types in the owning feature's `contract` module — +//! internal code must not import this module. A future v2 contract is a new +//! facade module (`api::v2`), not a change here. + +pub use crate::features::example::contract::{ + CreateItemRequest, ItemResponse, ListItemsRequest, ListItemsResponse, +}; + +/// Error codes carried in the JSON error envelope (`{"error": CODE, ...}`). +pub use crate::platform::errors::errcodes; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contract_aliases_round_trip_json() { + let req = CreateItemRequest { + name: "from-a-consumer".to_string(), + }; + let data = serde_json::to_string(&req).unwrap(); + assert_eq!(data, r#"{"name":"from-a-consumer"}"#); + + let resp = ItemResponse { + id: "id".to_string(), + name: "n".to_string(), + created_at: "t1".to_string(), + updated_at: "t2".to_string(), + }; + let data = serde_json::to_string(&ListItemsResponse { items: vec![resp] }).unwrap(); + assert_eq!( + data, + r#"{"items":[{"id":"id","name":"n","created_at":"t1","updated_at":"t2"}]}"# + ); + } + + #[test] + fn errcode_re_exports() { + use errcodes::*; + assert_eq!(NOT_FOUND, "NOT_FOUND"); + assert_eq!(INVALID_INPUT, "INVALID_INPUT"); + assert_eq!(INTERNAL, "INTERNAL"); + } +} diff --git a/src/app.rs b/src/app.rs index d6175e5..14165ce 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,41 +1,34 @@ -//! Composition root: builds `AppState` and runs the HTTP + gRPC servers (decision D2). -//! -//! Mirrors `internal/app/app.go` (structure.md §13). Build order: +//! Composition root (Go `internal/app/app.go` parity). Wires the platform and +//! every feature in dependency order: //! //! 1. Telemetry init (tracing + OTel + Prometheus). //! 2. PostgreSQL pool + readiness checker. //! 3. Valkey client + readiness checker. -//! 4. Example feature wiring (sqlx repository → service). -//! 5. [`AppState`] assembly. +//! 4. [`AppState`] assembly. +//! 5. Feature wiring — the only feature symbol referenced anywhere outside the +//! feature is `features::example::di`; adding a feature means adding one +//! `di::register` call here. //! -//! [`run`](crate::app::run) then delegates to [`shared::server::run`] which starts axum + -//! tonic and orchestrates the ordered graceful shutdown. +//! [`run`](app::run) then delegates to [`platform::server::run`] which starts +//! axum + tonic and orchestrates the ordered graceful shutdown. use std::sync::Arc; -use crate::{ +use axum::Router; + +use crate::features::example::di as example_di; +use crate::platform::{ config::Config, - features::example::{domain::Repository, repository::PgRepository, service::ServiceImpl}, - infrastructure::{PgChecker, ValkeyChecker, new_client as new_valkey_client, new_pool}, - shared::{ - health::Registry as HealthRegistry, - telemetry::{Telemetry, init as init_telemetry}, - }, + db::{PgChecker, new_pool}, + health::Registry as HealthRegistry, + server::{self, AppState, GrpcRouter}, + telemetry::{Telemetry, init as init_telemetry}, + valkey::{ValkeyChecker, new_client as new_valkey_client}, }; -/// Process-wide application state. Cloned cheaply via [`Arc`] (the underlying -/// pools and registries are already `Arc`-based). -pub struct AppState { - pub cfg: Arc, - pub db: sqlx::PgPool, - pub valkey: redis::aio::ConnectionManager, - pub health: Arc, - pub example_service: Arc, -} - /// Build metadata, populated at compile time via `option_env!` (see -/// `src/main.rs`). Re-exported from `app` so [`run`](crate::app::run) can log -/// them without depending on the binary. +/// `src/main.rs`). Re-exported here so [`build`](app::build) can log them +/// without depending on the binary. pub const VERSION: &str = match option_env!("VERSION") { Some(v) => v, None => "dev", @@ -49,8 +42,21 @@ pub const BUILD_TIME: &str = match option_env!("BUILD_TIME") { None => "unknown", }; -/// Build the application state. Mirrors Go `app.Build`. -pub async fn build(cfg: Config) -> anyhow::Result<(AppState, Telemetry)> { +/// Fully assembled application: server state plus the pre-built feature +/// routers (Go `server.Application` parity). +pub struct Built { + pub state: AppState, + pub telemetry: Telemetry, + /// Raw feature HTTP router(s), pre-nested under their versioned prefixes. + /// The server shell wraps them with shared routes + middleware exactly + /// once, inside [`platform::server::run`]. + pub api: Router, + /// tonic router with every feature's gRPC services. + pub grpc: GrpcRouter, +} + +/// Build the application. Mirrors Go `app.Build`. +pub async fn build(cfg: Config) -> anyhow::Result { let telemetry = init_telemetry(&cfg).map_err(|e| anyhow::anyhow!("init telemetry: {e}"))?; tracing::info!( @@ -73,29 +79,27 @@ pub async fn build(cfg: Config) -> anyhow::Result<(AppState, Telemetry)> { health.add_readiness(Arc::new(PgChecker::new(db.clone()))); health.add_readiness(Arc::new(ValkeyChecker::new(valkey.clone()))); - let repo: Arc = Arc::new(PgRepository::new(db.clone())); - let service = Arc::new(ServiceImpl::new( - repo, - cfg.example.default_page_size as i32, - cfg.example.max_page_size as i32, - cfg.example.max_name_length as i32, - )); - let state = AppState { cfg: Arc::new(cfg), db, valkey, health: Arc::new(health), - example_service: service, }; - Ok((state, telemetry)) + let wired = example_di::register(&state.cfg, state.db.clone()); + + Ok(Built { + state, + telemetry, + api: wired.http, + grpc: wired.grpc, + }) } /// Run the application until SIGTERM/SIGINT, then perform an ordered graceful /// shutdown. This is the top-level orchestrator used by the `server` binary /// and by integration tests. pub async fn run(cfg: Config) -> anyhow::Result<()> { - let (state, telemetry) = build(cfg).await?; - crate::shared::server::run(state, telemetry).await + let built = build(cfg).await?; + server::run(built.state, built.telemetry, built.api, built.grpc).await } diff --git a/src/bin/migrate.rs b/src/bin/migrate.rs index 0f913d3..e69bd1a 100644 --- a/src/bin/migrate.rs +++ b/src/bin/migrate.rs @@ -13,7 +13,7 @@ use std::process::ExitCode; use anyhow::{Context, Result}; use sqlx::{Executor, PgPool, postgres::PgPoolOptions}; -use zercle_rust_template::config::Config; +use zercle_rust_template::platform::config::Config; static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); diff --git a/src/features/example/adapter/driven/mod.rs b/src/features/example/adapter/driven/mod.rs new file mode 100644 index 0000000..7a4e5ce --- /dev/null +++ b/src/features/example/adapter/driven/mod.rs @@ -0,0 +1,8 @@ +//! STUB FEATURE — delete src/features/example to start your project. +//! +//! Driven (outbound) adapters: they implement the feature's outbound ports +//! (`port`) against concrete technologies. They must not know about the +//! application layer or driving adapters +//! (`tests/architecture.rs`: driven-adapters-ignore-application). + +pub mod postgres; diff --git a/src/features/example/repository.rs b/src/features/example/adapter/driven/postgres.rs similarity index 93% rename from src/features/example/repository.rs rename to src/features/example/adapter/driven/postgres.rs index 316fdbe..35c9ad9 100644 --- a/src/features/example/repository.rs +++ b/src/features/example/adapter/driven/postgres.rs @@ -1,6 +1,7 @@ //! STUB FEATURE — delete src/features/example to start your project. //! -//! sqlx implementation of `domain::Repository`. +//! sqlx implementation of the example feature's `port::Repository` (driven +//! adapter; Go `adapter/out/postgres/repository.go` parity). //! Uses runtime-checked `sqlx::query_as` (no live DATABASE_URL required at build time). //! //! Row → domain mapping is unit-tested directly. End-to-end DB tests are gated @@ -10,9 +11,10 @@ use sqlx::{FromRow, PgPool}; use time::OffsetDateTime; use uuid::Uuid; -use crate::features::example::domain::{Error, Item, Repository}; +use crate::features::example::domain::{Error, Item}; +use crate::features::example::port::Repository; -/// sqlx implementation of `domain::Repository`. +/// sqlx implementation of the example feature's `port::Repository`. #[derive(Clone)] pub struct PgRepository { pool: PgPool, diff --git a/src/features/example/adapter/driving/grpc.rs b/src/features/example/adapter/driving/grpc.rs new file mode 100644 index 0000000..0f70a5f --- /dev/null +++ b/src/features/example/adapter/driving/grpc.rs @@ -0,0 +1,235 @@ +//! STUB FEATURE — delete src/features/example to start your project. +//! +//! tonic gRPC driving adapter for the example feature (Go +//! `adapter/in/grpc/server.go` parity). Maps proto payloads to/from the +//! feature's contract types and calls the application's inbound port — never +//! domain entities. +//! +//! Proto module is generated by `build.rs` into `OUT_DIR`. With package +//! `example.v1` and no `rust_package` override, `tonic::include_proto!` emits a +//! module named after the package name with dots replaced by underscores: +//! `example_v1`. + +use std::sync::Arc; + +use tonic::{Request, Response, Status}; + +use crate::features::example::application::Service; +use crate::features::example::contract::{CreateItemRequest, ItemResponse, ListItemsRequest}; +use crate::platform::errors::AppError; + +/// 4 MiB cap on incoming + outgoing gRPC message bodies. Matches Go's +/// `grpc.MaxRecvMsgSize(4*1024*1024)` / `MaxSendMsgSize(4*1024*1024)`; owned +/// by the feature because it protects this service's payloads. +const GRPC_MESSAGE_SIZE_LIMIT: usize = 4 * 1024 * 1024; + +pub mod example_v1 { + tonic::include_proto!("example.v1"); +} + +use example_v1::{ + CreateItemRequest as PbCreateItemRequest, ListItemsRequest as PbListItemsRequest, +}; +use example_v1::{ + GetItemRequest, Item as PbItem, ListItemsResponse as PbListItemsResponse, + example_service_server::{ExampleService, ExampleServiceServer}, +}; + +/// tonic server implementation of the example feature. Generic over the +/// inbound port so tests inject a `MockService` and the `di` wires +/// `Arc`. +pub struct GrpcServer { + service: Arc, +} + +// Manual Clone impl: `Arc` is `Clone` for any `S`, including `?Sized`. +impl Clone for GrpcServer { + fn clone(&self) -> Self { + Self { + service: self.service.clone(), + } + } +} + +impl GrpcServer { + pub fn new(service: Arc) -> Self { + Self { service } + } +} + +#[tonic::async_trait] +impl ExampleService for GrpcServer { + async fn create_item( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let resp = self + .service + .create(CreateItemRequest { name: req.name }) + .await + .map_err(|e| AppError::from(e).to_grpc_status())?; + Ok(Response::new(contract_to_pb(&resp))) + } + + async fn get_item(&self, request: Request) -> Result, Status> { + let id = request.into_inner().id; + let resp = self + .service + .get(id) + .await + .map_err(|e| AppError::from(e).to_grpc_status())?; + Ok(Response::new(contract_to_pb(&resp))) + } + + async fn list_items( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let resp = self + .service + .list(ListItemsRequest { + limit: Some(req.limit), + offset: Some(req.offset), + }) + .await + .map_err(|e| AppError::from(e).to_grpc_status())?; + Ok(Response::new(PbListItemsResponse { + items: resp.items.iter().map(contract_to_pb).collect(), + })) + } +} + +/// Build a tonic service suitable for `Server::add_service`, with the message +/// size limits applied. +pub fn server( + srv: GrpcServer, +) -> ExampleServiceServer> { + ExampleServiceServer::new(srv) + .max_decoding_message_size(GRPC_MESSAGE_SIZE_LIMIT) + .max_encoding_message_size(GRPC_MESSAGE_SIZE_LIMIT) +} + +fn contract_to_pb(resp: &ItemResponse) -> PbItem { + PbItem { + id: resp.id.clone(), + name: resp.name.clone(), + created_at: resp.created_at.clone(), + updated_at: resp.updated_at.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::features::example::application::MockService; + use mockall::predicate::*; + + fn sample(id: &str, name: &str) -> ItemResponse { + ItemResponse { + id: id.to_string(), + name: name.to_string(), + created_at: "1970-01-01T00:00:00Z".to_string(), + updated_at: "1970-01-01T00:00:00Z".to_string(), + } + } + + #[tokio::test] + async fn create_item_ok() { + let mut m = MockService::new(); + m.expect_create() + .withf(|req: &CreateItemRequest| req.name == "alpha") + .returning(|req| Ok(sample("some-id", &req.name))); + let srv = GrpcServer::new(Arc::new(m)); + let resp = srv + .create_item(Request::new(PbCreateItemRequest { + name: "alpha".to_string(), + })) + .await + .unwrap(); + assert_eq!(resp.into_inner().name, "alpha"); + } + + #[tokio::test] + async fn create_item_maps_invalid_name_to_invalid_argument() { + let mut m = MockService::new(); + m.expect_create() + .returning(|_| Err(crate::features::example::domain::Error::InvalidName)); + let srv = GrpcServer::new(Arc::new(m)); + let err = srv + .create_item(Request::new(PbCreateItemRequest { + name: "".to_string(), + })) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + } + + #[tokio::test] + async fn get_item_ok() { + let mut m = MockService::new(); + m.expect_get() + .withf(|id| id == "00000000-0000-0000-0000-000000000000") + .returning(|id| Ok(sample(&id, "alpha"))); + let srv = GrpcServer::new(Arc::new(m)); + let resp = srv + .get_item(Request::new(GetItemRequest { + id: uuid::Uuid::nil().to_string(), + })) + .await + .unwrap(); + assert_eq!(resp.into_inner().id, uuid::Uuid::nil().to_string()); + } + + #[tokio::test] + async fn get_item_bad_uuid_maps_to_invalid_argument() { + let mut m = MockService::new(); + m.expect_get() + .returning(|_| Err(crate::features::example::domain::Error::InvalidId)); + let srv = GrpcServer::new(Arc::new(m)); + let err = srv + .get_item(Request::new(GetItemRequest { + id: "not-a-uuid".to_string(), + })) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + } + + #[tokio::test] + async fn get_item_not_found_maps_to_not_found() { + let mut m = MockService::new(); + m.expect_get() + .returning(|_| Err(crate::features::example::domain::Error::NotFound)); + let srv = GrpcServer::new(Arc::new(m)); + let err = srv + .get_item(Request::new(GetItemRequest { + id: uuid::Uuid::nil().to_string(), + })) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::NotFound); + } + + #[tokio::test] + async fn list_items_ok() { + let mut m = MockService::new(); + m.expect_list() + .withf(|req: &ListItemsRequest| req.limit == Some(10) && req.offset == Some(0)) + .returning(|_| { + Ok(crate::features::example::contract::ListItemsResponse { + items: vec![sample("a", "one"), sample("b", "two")], + }) + }); + let srv = GrpcServer::new(Arc::new(m)); + let resp = srv + .list_items(Request::new(PbListItemsRequest { + limit: 10, + offset: 0, + })) + .await + .unwrap(); + assert_eq!(resp.into_inner().items.len(), 2); + } +} diff --git a/src/features/example/handler.rs b/src/features/example/adapter/driving/http.rs similarity index 68% rename from src/features/example/handler.rs rename to src/features/example/adapter/driving/http.rs index 695572c..43562c2 100644 --- a/src/features/example/handler.rs +++ b/src/features/example/adapter/driving/http.rs @@ -1,13 +1,16 @@ //! STUB FEATURE — delete src/features/example to start your project. //! -//! axum HTTP handlers for the example feature. +//! axum HTTP driving adapter for the example feature (Go +//! `adapter/in/http/handler.go` parity). //! -//! Routes (mounted under `/api/v1` by the server shell, wave 5): +//! Routes (nested under `/api/v1` by the feature's `di`): //! `POST /items` → 201 + `ItemResponse` //! `GET /items` → 200 + `ListItemsResponse` (query: `limit`, `offset`) -//! `GET /items/:id` → 200 + `ItemResponse` +//! `GET /items/{id}` → 200 + `ItemResponse` //! -//! Generic over `S: domain::Service` so tests inject a `MockService`. +//! Handlers bind the feature's contract types directly, validate, and call the +//! application's inbound port — they never map to or from domain entities. +//! Generic over `S: Service` so tests inject a `MockService`. use std::sync::Arc; @@ -18,15 +21,12 @@ use axum::{ response::IntoResponse, routing::{get, post}, }; -use uuid::Uuid; - -use crate::features::example::domain::{Error, Service}; -use crate::features::example::dto::{ - CreateItemRequest, ItemResponse, ListItemsRequest, ListItemsResponse, -}; -use crate::shared::errors::AppError; use validator::Validate; +use crate::features::example::application::Service; +use crate::features::example::contract::{CreateItemRequest, ListItemsRequest, ListItemsResponse}; +use crate::platform::errors::AppError; + /// Handler holds the service as `Arc`; the generic keeps the test seam clean. pub struct Handler { service: Arc, @@ -47,8 +47,8 @@ impl Handler { } } -/// Build the axum router for the example feature. The caller is expected to -/// merge this under `/api/v1` (wave 5). +/// Build the axum router for the example feature. The caller (the feature's +/// `di`) nests this under `/api/v1`. pub fn routes(service: Arc) -> Router where S: Service + ?Sized + Send + Sync + 'static, @@ -71,8 +71,8 @@ where req.validate().map_err(|e| AppError::InvalidInput { cause: Some(anyhow::Error::msg(e.to_string())), })?; - let item = h.service.create(req.name).await.map_err(AppError::from)?; - Ok((StatusCode::CREATED, Json(ItemResponse::from_item(&item)))) + let resp = h.service.create(req).await.map_err(AppError::from)?; + Ok((StatusCode::CREATED, Json(resp))) } async fn list( @@ -85,43 +85,39 @@ where req.validate().map_err(|e| AppError::InvalidInput { cause: Some(anyhow::Error::msg(e.to_string())), })?; - let items = h - .service - .list(req.limit.unwrap_or(0), req.offset.unwrap_or(0)) - .await - .map_err(AppError::from)?; - Ok(Json(ListItemsResponse::from(items))) + let resp = h.service.list(req).await.map_err(AppError::from)?; + Ok(Json(resp)) } async fn get_one( State(h): State>, Path(id): Path, -) -> Result, AppError> +) -> Result, AppError> where S: Service + ?Sized, { - let id = Uuid::parse_str(&id).map_err(|_| AppError::from(Error::InvalidId))?; - let item = h.service.get(id).await.map_err(AppError::from)?; - Ok(Json(ItemResponse::from_item(&item))) + // Malformed ids surface as `domain::Error::InvalidId` from the use case — + // both driving adapters share the one validation path. + let resp = h.service.get(id).await.map_err(AppError::from)?; + Ok(Json(resp)) } #[cfg(test)] mod tests { use super::*; - use crate::features::example::domain::{Item, MockService}; + use crate::features::example::application::MockService; + use crate::features::example::contract::ItemResponse; use axum::body::Body; use axum::http::{Request, StatusCode as SC}; use mockall::predicate::*; - use time::OffsetDateTime; use tower::ServiceExt; - fn sample(id: Uuid, name: &str) -> Item { - let now = OffsetDateTime::now_utc(); - Item { - id, + fn sample_response(id: &str, name: &str) -> ItemResponse { + ItemResponse { + id: id.to_string(), name: name.to_string(), - created_at: now, - updated_at: now, + created_at: "1970-01-01T00:00:00Z".to_string(), + updated_at: "1970-01-01T00:00:00Z".to_string(), } } @@ -133,8 +129,13 @@ mod tests { async fn post_items_returns_201_on_success() { let mut m = MockService::new(); m.expect_create() - .withf(|n| n == "alpha") - .returning(|n| Ok(sample(Uuid::nil(), &n))); + .withf(|req| req.name == "alpha") + .returning(|req| { + Ok(sample_response( + "00000000-0000-0000-0000-000000000000", + &req.name, + )) + }); let app = router_with(m); let resp = app .oneshot( @@ -171,11 +172,15 @@ mod tests { #[tokio::test] async fn get_items_returns_200_with_payload() { let mut m = MockService::new(); - // Handler forwards query params as-is (0,0 here). Defaults are - // applied by the service impl, not exercised in this handler test. + // The handler forwards the raw contract request; defaults/clamping are + // the use case's job and are covered there. m.expect_list() - .withf(|l, o| *l == 0 && *o == 0) - .returning(|_, _| Ok(vec![sample(Uuid::nil(), "alpha")])); + .withf(|req: &ListItemsRequest| req.limit.is_none() && req.offset.is_none()) + .returning(|_| { + Ok(ListItemsResponse { + items: vec![sample_response("id-1", "alpha")], + }) + }); let app = router_with(m); let resp = app .oneshot( @@ -212,13 +217,13 @@ mod tests { async fn get_items_by_id_returns_200_on_hit() { let mut m = MockService::new(); m.expect_get() - .with(eq(Uuid::nil())) - .returning(|_| Ok(sample(Uuid::nil(), "alpha"))); + .withf(|id| id == "00000000-0000-0000-0000-000000000000") + .returning(|id| Ok(sample_response(&id, "alpha"))); let app = router_with(m); let resp = app .oneshot( Request::builder() - .uri(format!("/items/{}", Uuid::nil())) + .uri(format!("/items/{}", uuid::Uuid::nil())) .body(Body::empty()) .unwrap(), ) @@ -231,13 +236,12 @@ mod tests { async fn get_items_by_id_returns_404_on_missing() { let mut m = MockService::new(); m.expect_get() - .with(eq(Uuid::nil())) - .returning(|_| Err(Error::NotFound)); + .returning(|_| Err(crate::features::example::domain::Error::NotFound)); let app = router_with(m); let resp = app .oneshot( Request::builder() - .uri(format!("/items/{}", Uuid::nil())) + .uri(format!("/items/{}", uuid::Uuid::nil())) .body(Body::empty()) .unwrap(), ) @@ -248,7 +252,9 @@ mod tests { #[tokio::test] async fn get_items_by_id_returns_400_on_bad_uuid() { - let m = MockService::new(); + let mut m = MockService::new(); + m.expect_get() + .returning(|_| Err(crate::features::example::domain::Error::InvalidId)); let app = router_with(m); let resp = app .oneshot( diff --git a/src/features/example/adapter/driving/mod.rs b/src/features/example/adapter/driving/mod.rs new file mode 100644 index 0000000..5eba45e --- /dev/null +++ b/src/features/example/adapter/driving/mod.rs @@ -0,0 +1,9 @@ +//! STUB FEATURE — delete src/features/example to start your project. +//! +//! Driving (inbound) adapters: they translate transport payloads to/from the +//! feature's contract types and call the application's inbound port +//! (`application::Service`). They must not touch outbound ports or driven +//! adapters (`tests/architecture.rs`: driving-adapters-ignore-ports-and-driven-adapters). + +pub mod grpc; +pub mod http; diff --git a/src/features/example/adapter/mod.rs b/src/features/example/adapter/mod.rs new file mode 100644 index 0000000..bda0ff7 --- /dev/null +++ b/src/features/example/adapter/mod.rs @@ -0,0 +1,10 @@ +//! STUB FEATURE — delete src/features/example to start your project. +//! +//! Interface adapters (clean-architecture outer ring): driving adapters under +//! `driving` (HTTP, gRPC — Go `adapter/in`) and driven adapters under +//! `driven` (persistence — Go `adapter/out`). (`in` is a reserved keyword in +//! Rust, hence `driving`/`driven` — standard hexagonal terminology for the +//! same halves.) + +pub mod driven; +pub mod driving; diff --git a/src/features/example/application/mod.rs b/src/features/example/application/mod.rs new file mode 100644 index 0000000..820c436 --- /dev/null +++ b/src/features/example/application/mod.rs @@ -0,0 +1,15 @@ +//! STUB FEATURE — delete src/features/example to start your project. +//! +//! Inbound use-case port + implementation (Go +//! `internal/features/example/application/{service,usecase}.go` parity). +//! Driving adapters under `adapter/in` consume the [`Service`] port; the +//! implementation orchestrates the domain and the outbound ports and maps to +//! and from the wire `contract` types at the boundary. + +pub mod service; +pub mod usecase; + +#[cfg(test)] +pub use service::MockService; +pub use service::{Service, SharedService}; +pub use usecase::Usecase; diff --git a/src/features/example/application/service.rs b/src/features/example/application/service.rs new file mode 100644 index 0000000..045f6e9 --- /dev/null +++ b/src/features/example/application/service.rs @@ -0,0 +1,30 @@ +//! STUB FEATURE — delete src/features/example to start your project. +//! +//! Inbound use-case port for Items: it speaks the feature's contract types at +//! the boundary so driving adapters bind responses directly and never map to +//! or from domain entities (Go `application/service.go` parity). + +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::features::example::contract::{ + CreateItemRequest, ItemResponse, ListItemsRequest, ListItemsResponse, +}; +use crate::features::example::domain::Error; + +/// Inbound use-case port for `Item`. +/// +/// Driving adapters (`adapter/in`) call this; the only permitted dependencies +/// are this feature's domain, port, and contract +/// (`tests/architecture.rs`: application-depends-on-domain-port-contract). +#[cfg_attr(test, mockall::automock)] +#[async_trait] +pub trait Service: Send + Sync { + async fn create(&self, req: CreateItemRequest) -> Result; + async fn get(&self, id: String) -> Result; + async fn list(&self, req: ListItemsRequest) -> Result; +} + +/// Type alias for an `Arc`-shared service handle. +pub type SharedService = Arc; diff --git a/src/features/example/application/usecase.rs b/src/features/example/application/usecase.rs new file mode 100644 index 0000000..5cc10dd --- /dev/null +++ b/src/features/example/application/usecase.rs @@ -0,0 +1,338 @@ +//! STUB FEATURE — delete src/features/example to start your project. +//! +//! Implementation of the [`Service`](super::Service) inbound port (Go +//! `application/usecase.go` parity). Orchestrates the domain and the outbound +//! port, and owns the domain ↔ contract mapping so driving adapters never see +//! domain entities. + +use std::sync::Arc; + +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use uuid::Uuid; + +use crate::features::example::application::Service; +use crate::features::example::contract::{ + CreateItemRequest, ItemResponse, ListItemsRequest, ListItemsResponse, +}; +use crate::features::example::domain::{Error, Item}; +use crate::features::example::port::Repository; + +const DEFAULT_PAGE_SIZE: i32 = 20; +const MAX_PAGE_SIZE: i32 = 100; +const MAX_NAME_LENGTH: usize = 255; + +/// Concrete use case backed by a [`Repository`] outbound port. +#[derive(Clone)] +pub struct Usecase { + repo: Arc, + default_page_size: i32, + max_page_size: i32, + max_name_length: usize, +} + +impl Usecase { + /// Build a use case. Values `<= 0` fall back to the package defaults + /// (`20` / `100` / `255`), mirroring Go. + pub fn new( + repo: Arc, + default_page_size: i32, + max_page_size: i32, + max_name_length: i32, + ) -> Self { + let default_page_size = if default_page_size <= 0 { + DEFAULT_PAGE_SIZE + } else { + default_page_size + }; + let max_page_size = if max_page_size <= 0 { + MAX_PAGE_SIZE + } else { + max_page_size + }; + let max_name_length = if max_name_length <= 0 { + MAX_NAME_LENGTH + } else { + max_name_length as usize + }; + Self { + repo, + default_page_size, + max_page_size, + max_name_length, + } + } + + /// Map a domain item to its wire form (RFC 3339 timestamps). + fn item_response(item: &Item) -> ItemResponse { + ItemResponse { + id: item.id.to_string(), + name: item.name.clone(), + created_at: format_rfc3339(item.created_at), + updated_at: format_rfc3339(item.updated_at), + } + } +} + +#[async_trait::async_trait] +impl Service for Usecase { + async fn create(&self, req: CreateItemRequest) -> Result { + let name = req.name.trim(); + // Mirror Go's `utf8.RuneCountInString(name) > maxNameLength`: count + // Unicode scalar values (chars), not UTF-8 bytes, so multi-byte + // names (e.g. CJK, Thai, emoji) follow the documented 255-rune cap + // rather than failing on raw byte length. + if name.is_empty() || name.chars().count() > self.max_name_length { + return Err(Error::InvalidName); + } + let now = OffsetDateTime::now_utc(); + let item = Item { + id: Uuid::now_v7(), + name: name.to_string(), + created_at: now, + updated_at: now, + }; + self.repo.create(&item).await?; + Ok(Self::item_response(&item)) + } + + async fn get(&self, id: String) -> Result { + // The wire id string is parsed here so both driving adapters share one + // validation path (Go usecase.Get parity). + let id = Uuid::parse_str(&id).map_err(|_| Error::InvalidId)?; + let item = self.repo.get_by_id(id).await?; + Ok(Self::item_response(&item)) + } + + async fn list(&self, req: ListItemsRequest) -> Result { + let mut limit = req.limit.unwrap_or(0); + if limit <= 0 { + limit = self.default_page_size; + } + if limit > self.max_page_size { + limit = self.max_page_size; + } + let offset = req.offset.unwrap_or(0); + let offset = if offset < 0 { 0 } else { offset }; + let items = self.repo.list(limit, offset).await?; + Ok(ListItemsResponse { + items: items.iter().map(Self::item_response).collect(), + }) + } +} + +fn format_rfc3339(t: OffsetDateTime) -> String { + t.format(&Rfc3339).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::features::example::port::MockRepository; + use mockall::predicate::*; + + fn item(id: Uuid, name: &str) -> Item { + let now = OffsetDateTime::now_utc(); + Item { + id, + name: name.to_string(), + created_at: now, + updated_at: now, + } + } + + #[tokio::test] + async fn create_rejects_empty_name() { + let repo = Arc::new(MockRepository::new()); + let svc = Usecase::new(repo, 20, 100, 255); + assert_eq!( + svc.create(CreateItemRequest { + name: " ".to_string(), + }) + .await + .unwrap_err(), + Error::InvalidName + ); + } + + #[tokio::test] + async fn create_rejects_overlong_name() { + let repo = Arc::new(MockRepository::new()); + let svc = Usecase::new(repo, 20, 100, 255); + let big = "a".repeat(256); + assert_eq!( + svc.create(CreateItemRequest { name: big }) + .await + .unwrap_err(), + Error::InvalidName + ); + } + + #[tokio::test] + async fn create_accepts_multibyte_name_within_rune_cap() { + // 200 Thai "ช" (U+0E0A, 3 UTF-8 bytes each = 600 bytes) would be + // rejected by a byte-length check, but must pass under rune-count + // matching Go's `utf8.RuneCountInString`. Cap is 255 runes. + let mut mock = MockRepository::new(); + mock.expect_create().returning(|_| Ok(())); + let svc = Usecase::new(Arc::new(mock), 20, 100, 255); + let name = "ช".repeat(200); + assert_eq!(name.len(), 600, "sanity: 3 bytes per Thai char"); + let resp = svc.create(CreateItemRequest { name }).await.unwrap(); + assert_eq!(resp.name.chars().count(), 200); + } + + #[tokio::test] + async fn create_rejects_multibyte_name_over_rune_cap() { + // 256 emoji 🎉 (U+1F389, 4 UTF-8 bytes each = 1024 bytes) — must + // be rejected because 256 > 255 rune cap (the 1024-byte-count + // version would also reject, but the rune-count version is the + // parity contract with Go). + let repo = Arc::new(MockRepository::new()); + let svc = Usecase::new(repo, 20, 100, 255); + let name = "🎉".repeat(256); + assert_eq!(name.len(), 1024, "sanity: 4 bytes per emoji"); + assert_eq!( + svc.create(CreateItemRequest { name }).await.unwrap_err(), + Error::InvalidName + ); + } + + #[tokio::test] + async fn create_trims_and_persists() { + let mut mock = MockRepository::new(); + mock.expect_create().returning(|_| Ok(())); + let repo = Arc::new(mock); + let svc = Usecase::new(repo, 20, 100, 255); + let resp = svc + .create(CreateItemRequest { + name: " hello ".to_string(), + }) + .await + .unwrap(); + assert_eq!(resp.name, "hello"); + } + + #[tokio::test] + async fn get_rejects_malformed_id_before_touching_the_port() { + // One validation path for both driving adapters: a bad uuid fails + // without a repository call. + let mut mock = MockRepository::new(); + mock.expect_get_by_id().times(0); + let svc = Usecase::new(Arc::new(mock), 20, 100, 255); + assert_eq!( + svc.get("not-a-uuid".to_string()).await.unwrap_err(), + Error::InvalidId + ); + } + + #[tokio::test] + async fn get_passes_through_not_found() { + let mut mock = MockRepository::new(); + mock.expect_get_by_id() + .with(eq(Uuid::nil())) + .returning(|_| Err(Error::NotFound)); + let svc = Usecase::new(Arc::new(mock), 20, 100, 255); + assert_eq!( + svc.get(Uuid::nil().to_string()).await.unwrap_err(), + Error::NotFound + ); + } + + #[tokio::test] + async fn get_returns_wire_response_on_hit() { + let mut mock = MockRepository::new(); + mock.expect_get_by_id() + .with(eq(Uuid::nil())) + .returning(|id| Ok(item(id, "x"))); + let svc = Usecase::new(Arc::new(mock), 20, 100, 255); + let got = svc.get(Uuid::nil().to_string()).await.unwrap(); + assert_eq!(got.name, "x"); + assert_eq!(got.id, Uuid::nil().to_string()); + } + + #[tokio::test] + async fn list_clamps_limit_above_max() { + let mut mock = MockRepository::new(); + mock.expect_list() + .withf(|limit, offset| *limit == 100 && *offset == 0) + .returning(|_, _| Ok(vec![])); + let svc = Usecase::new(Arc::new(mock), 20, 100, 255); + let resp = svc + .list(ListItemsRequest { + limit: Some(9_999), + offset: Some(0), + }) + .await + .unwrap(); + assert!(resp.items.is_empty()); + } + + #[tokio::test] + async fn list_uses_default_when_limit_zero_or_missing() { + let mut mock = MockRepository::new(); + mock.expect_list() + .withf(|limit, offset| *limit == 20 && *offset == 0) + .returning(|_, _| Ok(vec![])); + let svc = Usecase::new(Arc::new(mock), 20, 100, 255); + // limit absent entirely (None) and limit = 0 both hit the default. + svc.list(ListItemsRequest { + limit: None, + offset: None, + }) + .await + .unwrap(); + svc.list(ListItemsRequest { + limit: Some(0), + offset: Some(0), + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn list_clamps_negative_offset_to_zero() { + let mut mock = MockRepository::new(); + mock.expect_list() + .withf(|limit, offset| *limit == 10 && *offset == 0) + .returning(|_, _| Ok(vec![])); + let svc = Usecase::new(Arc::new(mock), 20, 100, 255); + svc.list(ListItemsRequest { + limit: Some(10), + offset: Some(-5), + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn fallback_defaults_apply_when_config_zero() { + let mut mock = MockRepository::new(); + mock.expect_list() + .withf(|limit, offset| *limit == 100 && *offset == 0) + .returning(|_, _| Ok(vec![])); + // All config values ≤ 0 → fall back to 20/100/255 + let svc = Usecase::new(Arc::new(mock), 0, 0, 0); + svc.list(ListItemsRequest { + limit: Some(9_999), + offset: Some(0), + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn create_maps_domain_item_to_wire_timestamps() { + let mut mock = MockRepository::new(); + mock.expect_create().returning(|_| Ok(())); + let svc = Usecase::new(Arc::new(mock), 20, 100, 255); + let resp = svc + .create(CreateItemRequest { + name: "alpha".to_string(), + }) + .await + .unwrap(); + // RFC 3339 with a Z suffix — exercised lightly; exact formatting is + // covered by the contract + facade tests. + assert!(resp.created_at.ends_with('Z'), "got {}", resp.created_at); + } +} diff --git a/src/features/example/contract/create_item.rs b/src/features/example/contract/create_item.rs new file mode 100644 index 0000000..39396c3 --- /dev/null +++ b/src/features/example/contract/create_item.rs @@ -0,0 +1,27 @@ +//! STUB FEATURE — delete src/features/example to start your project. + +use serde::{Deserialize, Serialize}; +use validator::Validate; + +/// Payload for `POST /items`. +/// +/// `Serialize` is provided so external consumers (via the published facade) +/// can construct and serialize payloads symmetrically. +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct CreateItemRequest { + #[validate(length(min = 1, max = 255))] + pub name: String, +} + +/// JSON representation of an `Item`. +/// +/// Timestamps are RFC 3339 strings; mapping from the domain entity lives in +/// the application layer (`application::usecase`), keeping this module free of +/// any crate-internal dependency. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ItemResponse { + pub id: String, + pub name: String, + pub created_at: String, + pub updated_at: String, +} diff --git a/src/features/example/contract/list_items.rs b/src/features/example/contract/list_items.rs new file mode 100644 index 0000000..03165c6 --- /dev/null +++ b/src/features/example/contract/list_items.rs @@ -0,0 +1,24 @@ +//! STUB FEATURE — delete src/features/example to start your project. + +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use super::create_item::ItemResponse; + +/// Query parameters for `GET /items`. +/// +/// `None` fields mean "not supplied"; the application layer applies safe +/// defaults so a zero-value request never produces `LIMIT 0`. +#[derive(Debug, Clone, Default, Deserialize, Validate)] +pub struct ListItemsRequest { + #[validate(range(min = 0, max = 100))] + pub limit: Option, + #[validate(range(min = 0))] + pub offset: Option, +} + +/// Response body for `GET /items`. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ListItemsResponse { + pub items: Vec, +} diff --git a/src/features/example/contract/mod.rs b/src/features/example/contract/mod.rs new file mode 100644 index 0000000..fb1d12f --- /dev/null +++ b/src/features/example/contract/mod.rs @@ -0,0 +1,47 @@ +//! STUB FEATURE — delete src/features/example to start your project. +//! +//! Canonical inbound wire types for the example feature's `/api/v1` endpoints +//! (Go `internal/features/example/contract` parity). This module is the single +//! source of the HTTP JSON shapes; the published facade `crate::api::v1` +//! re-exports these types so other services can construct payloads without +//! importing server internals. +//! +//! Leaf rule: the contract must not depend on any crate-internal module +//! (`tests/architecture.rs`: contract-is-leaf), so the published facade drags +//! in nothing but serde/validator types. + +pub mod create_item; +pub mod list_items; + +pub use create_item::{CreateItemRequest, ItemResponse}; +pub use list_items::{ListItemsRequest, ListItemsResponse}; + +#[cfg(test)] +mod tests { + use super::*; + use validator::Validate; + + #[test] + fn create_request_validates_length() { + let req = CreateItemRequest { + name: "".to_string(), + }; + assert!(req.validate().is_err()); + let req = CreateItemRequest { + name: "a".repeat(256), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn list_request_validates_range() { + let mut req = ListItemsRequest { + limit: Some(200), + offset: Some(-1), + }; + assert!(req.validate().is_err()); + req.limit = Some(50); + req.offset = Some(0); + assert!(req.validate().is_ok()); + } +} diff --git a/src/features/example/di.rs b/src/features/example/di.rs new file mode 100644 index 0000000..9c9db88 --- /dev/null +++ b/src/features/example/di.rs @@ -0,0 +1,81 @@ +//! STUB FEATURE — delete src/features/example to start your project. +//! +//! Composition point for the example feature (Go `di.Register` parity): builds +//! the driven adapter, the use case, and the driving adapters, and registers +//! the domain sentinel → boundary error mapping. + +use std::sync::Arc; + +use axum::Router; +use sqlx::PgPool; + +use crate::features::example::adapter::driven::postgres::PgRepository; +use crate::features::example::adapter::driving::{grpc, http}; +use crate::features::example::application::{Service, Usecase}; +use crate::features::example::domain::Error; +use crate::platform::config::Config; +use crate::platform::errors::AppError; +use crate::platform::server::GrpcRouter; + +/// Sentinel → boundary error mapping, registered here at the composition edge +/// so the domain layer stays dependency-free (Go +/// `apperrors.RegisterSentinel(domain.ErrX, apperrors.ErrY)` parity; the impl +/// is crate-global once defined, so every adapter can rely on `AppError::from`). +impl From for AppError { + fn from(err: Error) -> Self { + match err { + Error::NotFound => AppError::NotFound { cause: None }, + Error::InvalidName | Error::InvalidId => AppError::InvalidInput { cause: None }, + Error::Internal { cause } => AppError::Internal { cause }, + } + } +} + +/// Everything the feature contributes to the running application. +pub struct Wired { + /// axum routes for this feature, pre-nested under `/api/v1`. + pub http: Router, + /// tonic router for this feature (platform shell serves it as-is). + pub grpc: GrpcRouter, +} + +/// Wire the example feature: postgres repository → use case → HTTP + gRPC +/// adapters. Mirrors Go `di.Register`. +pub fn register(cfg: &Config, db: PgPool) -> Wired { + let repo = Arc::new(PgRepository::new(db)); + let service: Arc = Arc::new(Usecase::new( + repo, + clamp_i32(cfg.example.default_page_size), + clamp_i32(cfg.example.max_page_size), + clamp_i32(cfg.example.max_name_length), + )); + + let http = Router::new().nest("/api/v1", http::routes(service.clone())); + let grpc = crate::platform::server::grpc_server() + .add_service(grpc::server(grpc::GrpcServer::new(service))); + + Wired { http, grpc } +} + +/// Config page sizes are validated `>= 1` u32s; clamp to i32 for the proto / +/// port boundary (Go carries them as int32). +fn clamp_i32(v: u32) -> i32 { + i32::try_from(v).unwrap_or(i32::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::errors::errcodes; + + #[test] + fn domain_sentinels_map_to_boundary_codes() { + // Assert the registered sentinel mapping (the From impl above) lands + // on the published error codes. + let code = |e: Error| AppError::from(e).code().to_string(); + assert_eq!(code(Error::NotFound), errcodes::NOT_FOUND); + assert_eq!(code(Error::InvalidName), errcodes::INVALID_INPUT); + assert_eq!(code(Error::InvalidId), errcodes::INVALID_INPUT); + assert_eq!(code(Error::Internal { cause: None }), errcodes::INTERNAL); + } +} diff --git a/src/features/example/domain.rs b/src/features/example/domain.rs deleted file mode 100644 index 2d0d826..0000000 --- a/src/features/example/domain.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! STUB FEATURE — delete src/features/example to start your project. -//! -//! Domain types + traits for the example feature (canonical clean-architecture -//! demo). Mirrors Go `internal/features/example/domain/{item,errors,repository,service}.go`. -//! -//! Per decision-log D6, traits take no explicit context parameter — the impl -//! holds the pool and emits request-scoped spans via `tracing`. - -use std::sync::Arc; - -use async_trait::async_trait; -use time::OffsetDateTime; -use uuid::Uuid; - -use crate::shared::errors::AppError; - -/// The trivial example entity. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Item { - pub id: Uuid, - pub name: String, - pub created_at: OffsetDateTime, - pub updated_at: OffsetDateTime, -} - -impl Item { - /// Replace the name and refresh the `updated_at` timestamp to now (UTC). - pub fn rename(&mut self, name: String) { - self.name = name; - self.updated_at = OffsetDateTime::now_utc(); - } -} - -/// Domain sentinel errors. Mapping to the shared boundary `AppError` lives in -/// the `From` impl below so `shared` does not import this feature (D7). -/// -/// `Internal` carries infrastructure failures (`sqlx`, etc.) that don't map to -/// a semantic sentinel; it parallels Go's `fmt.Errorf("...: %w", err)` wrappers -/// in `service.go`. The three semantic sentinels are mapped to `AppError`; the -/// `Internal` variant forwards the cause. -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("item not found")] - NotFound, - #[error("item name is invalid")] - InvalidName, - #[error("item id is invalid")] - InvalidId, - #[error("internal error")] - Internal { cause: Option }, -} - -impl PartialEq for Error { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Error::NotFound, Error::NotFound) - | (Error::InvalidName, Error::InvalidName) - | (Error::InvalidId, Error::InvalidId) => true, - (Error::Internal { cause: a }, Error::Internal { cause: b }) => { - a.as_ref().map(anyhow::Error::to_string) == b.as_ref().map(anyhow::Error::to_string) - } - _ => false, - } - } -} - -impl Eq for Error {} - -impl From for AppError { - fn from(err: Error) -> Self { - match err { - Error::NotFound => AppError::NotFound { cause: None }, - Error::InvalidName | Error::InvalidId => AppError::InvalidInput { cause: None }, - Error::Internal { cause } => AppError::Internal { cause }, - } - } -} - -/// Outbound persistence port for `Item`. -#[cfg_attr(test, mockall::automock)] -#[async_trait] -pub trait Repository: Send + Sync { - async fn create(&self, item: &Item) -> Result<(), Error>; - async fn get_by_id(&self, id: Uuid) -> Result; - async fn list(&self, limit: i32, offset: i32) -> Result, Error>; -} - -/// Inbound use-case port for `Item`. -#[cfg_attr(test, mockall::automock)] -#[async_trait] -pub trait Service: Send + Sync { - async fn create(&self, name: String) -> Result; - async fn get(&self, id: Uuid) -> Result; - async fn list(&self, limit: i32, offset: i32) -> Result, Error>; -} - -/// Type alias for an `Arc`-shared service handle. -pub type SharedService = Arc; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rename_updates_name_and_timestamp() { - let mut item = Item { - id: Uuid::nil(), - name: "old".to_string(), - created_at: OffsetDateTime::UNIX_EPOCH, - updated_at: OffsetDateTime::UNIX_EPOCH, - }; - item.rename("new".to_string()); - assert_eq!(item.name, "new"); - assert!(item.updated_at > OffsetDateTime::UNIX_EPOCH); - } - - #[test] - fn error_maps_to_app_error() { - assert!(matches!( - AppError::from(Error::NotFound), - AppError::NotFound { .. } - )); - assert!(matches!( - AppError::from(Error::InvalidName), - AppError::InvalidInput { .. } - )); - assert!(matches!( - AppError::from(Error::InvalidId), - AppError::InvalidInput { .. } - )); - } -} diff --git a/src/features/example/domain/error.rs b/src/features/example/domain/error.rs new file mode 100644 index 0000000..db0ac50 --- /dev/null +++ b/src/features/example/domain/error.rs @@ -0,0 +1,40 @@ +//! STUB FEATURE — delete src/features/example to start your project. +//! +//! Domain sentinel errors for the example feature (Go `domain/errors.go` +//! parity). `Internal` carries infrastructure failures (sqlx, …) that don't +//! map to a semantic sentinel — the typed equivalent of Go's free-form +//! `fmt.Errorf("...: %w", err)` wrapping. +//! +//! Mapping to the shared boundary `AppError` is registered at the composition +//! edge in the feature's `di` module (Go `apperrors.RegisterSentinel` parity), +//! so this module stays dependency-free. + +/// Domain error type. The three semantic sentinels map to boundary error +/// codes; `Internal` forwards the cause to the boundary for a 500. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("item not found")] + NotFound, + #[error("item name is invalid")] + InvalidName, + #[error("item id is invalid")] + InvalidId, + #[error("internal error")] + Internal { cause: Option }, +} + +impl PartialEq for Error { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Error::NotFound, Error::NotFound) + | (Error::InvalidName, Error::InvalidName) + | (Error::InvalidId, Error::InvalidId) => true, + (Error::Internal { cause: a }, Error::Internal { cause: b }) => { + a.as_ref().map(anyhow::Error::to_string) == b.as_ref().map(anyhow::Error::to_string) + } + _ => false, + } + } +} + +impl Eq for Error {} diff --git a/src/features/example/domain/item.rs b/src/features/example/domain/item.rs new file mode 100644 index 0000000..618c1b3 --- /dev/null +++ b/src/features/example/domain/item.rs @@ -0,0 +1,39 @@ +//! STUB FEATURE — delete src/features/example to start your project. + +use time::OffsetDateTime; +use uuid::Uuid; + +/// The trivial example entity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Item { + pub id: Uuid, + pub name: String, + pub created_at: OffsetDateTime, + pub updated_at: OffsetDateTime, +} + +impl Item { + /// Replace the name and refresh the `updated_at` timestamp to now (UTC). + pub fn rename(&mut self, name: String) { + self.name = name; + self.updated_at = OffsetDateTime::now_utc(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rename_updates_name_and_timestamp() { + let mut item = Item { + id: Uuid::nil(), + name: "old".to_string(), + created_at: OffsetDateTime::UNIX_EPOCH, + updated_at: OffsetDateTime::UNIX_EPOCH, + }; + item.rename("new".to_string()); + assert_eq!(item.name, "new"); + assert!(item.updated_at > OffsetDateTime::UNIX_EPOCH); + } +} diff --git a/src/features/example/domain/mod.rs b/src/features/example/domain/mod.rs new file mode 100644 index 0000000..7e3ba2f --- /dev/null +++ b/src/features/example/domain/mod.rs @@ -0,0 +1,11 @@ +//! STUB FEATURE — delete src/features/example to start your project. +//! +//! Innermost layer: entities + domain errors. The domain depends on nothing +//! crate-internal (`tests/architecture.rs`: domain-is-innermost) — it imports +//! only stdlib-adjacent crates (uuid, time, thiserror). + +pub mod error; +pub mod item; + +pub use error::Error; +pub use item::Item; diff --git a/src/features/example/dto.rs b/src/features/example/dto.rs deleted file mode 100644 index d2f2928..0000000 --- a/src/features/example/dto.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! STUB FEATURE — delete src/features/example to start your project. -//! -//! Transport DTOs for the example feature (axum HTTP + tonic gRPC bodies). -//! Mirrors Go `internal/features/example/dto/{create_item,list_items}.go`. -//! -//! Timestamps are formatted as RFC 3339 at the DTO boundary. - -use serde::{Deserialize, Serialize}; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; -use validator::Validate; - -use crate::features::example::domain::Item; - -/// Payload for `POST /items`. -#[derive(Debug, Clone, Deserialize, Validate)] -pub struct CreateItemRequest { - #[validate(length(min = 1, max = 255))] - pub name: String, -} - -/// JSON representation of an `Item`. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct ItemResponse { - pub id: String, - pub name: String, - pub created_at: String, - pub updated_at: String, -} - -impl ItemResponse { - pub fn from_item(item: &Item) -> Self { - Self { - id: item.id.to_string(), - name: item.name.clone(), - created_at: format_rfc3339(item.created_at), - updated_at: format_rfc3339(item.updated_at), - } - } -} - -impl From<&Item> for ItemResponse { - fn from(item: &Item) -> Self { - Self::from_item(item) - } -} - -/// Query / body parameters for `GET /items`. -#[derive(Debug, Clone, Default, Deserialize, Validate)] -pub struct ListItemsRequest { - #[validate(range(min = 0, max = 100))] - pub limit: Option, - #[validate(range(min = 0))] - pub offset: Option, -} - -/// Response body for `GET /items`. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct ListItemsResponse { - pub items: Vec, -} - -impl From> for ListItemsResponse { - fn from(items: Vec) -> Self { - Self { - items: items.iter().map(ItemResponse::from_item).collect(), - } - } -} - -fn format_rfc3339(t: OffsetDateTime) -> String { - t.format(&Rfc3339).unwrap_or_default() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_item() -> Item { - Item { - id: uuid::Uuid::nil(), - name: "alpha".to_string(), - created_at: OffsetDateTime::UNIX_EPOCH, - updated_at: OffsetDateTime::UNIX_EPOCH, - } - } - - #[test] - fn item_response_uses_rfc3339() { - let r = ItemResponse::from_item(&sample_item()); - assert_eq!(r.id, "00000000-0000-0000-0000-000000000000"); - assert_eq!(r.name, "alpha"); - assert_eq!(r.created_at, "1970-01-01T00:00:00Z"); - assert_eq!(r.updated_at, "1970-01-01T00:00:00Z"); - } - - #[test] - fn list_response_from_vec() { - let v: ListItemsResponse = vec![sample_item(), sample_item()].into(); - assert_eq!(v.items.len(), 2); - } - - #[test] - fn create_request_validates_length() { - let req = CreateItemRequest { - name: "".to_string(), - }; - assert!(req.validate().is_err()); - } - - #[test] - fn list_request_validates_range() { - let mut req = ListItemsRequest { - limit: Some(200), - offset: Some(-1), - }; - assert!(req.validate().is_err()); - req.limit = Some(50); - req.offset = Some(0); - assert!(req.validate().is_ok()); - } -} diff --git a/src/features/example/grpc.rs b/src/features/example/grpc.rs deleted file mode 100644 index 973412d..0000000 --- a/src/features/example/grpc.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! STUB FEATURE — delete src/features/example to start your project. -//! -//! tonic gRPC server for the example feature. -//! -//! Proto module is generated by `build.rs` into `OUT_DIR`. With package -//! `example.v1` and no `rust_package` override, `tonic::include_proto!` emits a -//! module named after the package name with dots replaced by underscores: -//! `example_v1`. - -use std::sync::Arc; - -use time::OffsetDateTime; -use tonic::{Request, Response, Status}; -use uuid::Uuid; - -use crate::features::example::domain::{Error, Service}; -use crate::shared::errors::AppError; - -pub mod example_v1 { - tonic::include_proto!("example.v1"); -} - -use example_v1::{ - CreateItemRequest, GetItemRequest, Item, ListItemsRequest, ListItemsResponse, - example_service_server::{ExampleService, ExampleServiceServer}, -}; - -/// tonic server implementation of the example feature. -#[derive(Clone)] -pub struct GrpcServer { - service: Arc, -} - -impl GrpcServer { - pub fn new(service: Arc) -> Self { - Self { service } - } -} - -#[tonic::async_trait] -impl ExampleService for GrpcServer { - async fn create_item( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - let item = self - .service - .create(req.name) - .await - .map_err(|e| AppError::from(e).to_grpc_status())?; - Ok(Response::new(domain_to_pb(&item))) - } - - async fn get_item(&self, request: Request) -> Result, Status> { - let id = Uuid::parse_str(&request.into_inner().id) - .map_err(|_| AppError::from(Error::InvalidId).to_grpc_status())?; - let item = self - .service - .get(id) - .await - .map_err(|e| AppError::from(e).to_grpc_status())?; - Ok(Response::new(domain_to_pb(&item))) - } - - async fn list_items( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - let items = self - .service - .list(req.limit, req.offset) - .await - .map_err(|e| AppError::from(e).to_grpc_status())?; - let resp = ListItemsResponse { - items: items.iter().map(domain_to_pb).collect(), - }; - Ok(Response::new(resp)) - } -} - -/// Build a tonic server suitable for `Server::add_service`. -pub fn server(srv: GrpcServer) -> ExampleServiceServer> { - ExampleServiceServer::new(srv) -} - -fn domain_to_pb(item: &crate::features::example::domain::Item) -> Item { - Item { - id: item.id.to_string(), - name: item.name.clone(), - created_at: format_rfc3339(item.created_at), - updated_at: format_rfc3339(item.updated_at), - } -} - -fn format_rfc3339(t: OffsetDateTime) -> String { - use time::format_description::well_known::Rfc3339; - t.format(&Rfc3339).unwrap_or_default() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::features::example::domain::{Item, MockService}; - use mockall::predicate::*; - - fn sample() -> Item { - let now = OffsetDateTime::now_utc(); - Item { - id: Uuid::nil(), - name: "alpha".to_string(), - created_at: now, - updated_at: now, - } - } - - #[tokio::test] - async fn create_item_ok() { - let mut m = MockService::new(); - m.expect_create().withf(|n| n == "alpha").returning(|n| { - let mut s = sample(); - s.name = n.to_string(); - Ok(s) - }); - let srv = GrpcServer::new(Arc::new(m)); - let resp = srv - .create_item(Request::new(CreateItemRequest { - name: "alpha".to_string(), - })) - .await - .unwrap(); - assert_eq!(resp.into_inner().name, "alpha"); - } - - #[tokio::test] - async fn create_item_maps_invalid_name_to_invalid_argument() { - let mut m = MockService::new(); - m.expect_create().returning(|_| Err(Error::InvalidName)); - let srv = GrpcServer::new(Arc::new(m)); - let err = srv - .create_item(Request::new(CreateItemRequest { - name: "".to_string(), - })) - .await - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::InvalidArgument); - } - - #[tokio::test] - async fn get_item_ok() { - let mut m = MockService::new(); - m.expect_get() - .with(eq(Uuid::nil())) - .returning(|_| Ok(sample())); - let srv = GrpcServer::new(Arc::new(m)); - let resp = srv - .get_item(Request::new(GetItemRequest { - id: Uuid::nil().to_string(), - })) - .await - .unwrap(); - assert_eq!(resp.into_inner().id, Uuid::nil().to_string()); - } - - #[tokio::test] - async fn get_item_bad_uuid_maps_to_invalid_argument() { - let m = MockService::new(); - let srv = GrpcServer::new(Arc::new(m)); - let err = srv - .get_item(Request::new(GetItemRequest { - id: "not-a-uuid".to_string(), - })) - .await - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::InvalidArgument); - } - - #[tokio::test] - async fn get_item_not_found_maps_to_not_found() { - let mut m = MockService::new(); - m.expect_get().returning(|_| Err(Error::NotFound)); - let srv = GrpcServer::new(Arc::new(m)); - let err = srv - .get_item(Request::new(GetItemRequest { - id: Uuid::nil().to_string(), - })) - .await - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::NotFound); - } - - #[tokio::test] - async fn list_items_ok() { - let mut m = MockService::new(); - m.expect_list() - .returning(|_, _| Ok(vec![sample(), sample()])); - let srv = GrpcServer::new(Arc::new(m)); - let resp = srv - .list_items(Request::new(ListItemsRequest { - limit: 10, - offset: 0, - })) - .await - .unwrap(); - assert_eq!(resp.into_inner().items.len(), 2); - } -} diff --git a/src/features/example/mod.rs b/src/features/example/mod.rs index 3ddcf25..febadc8 100644 --- a/src/features/example/mod.rs +++ b/src/features/example/mod.rs @@ -1,29 +1,28 @@ //! STUB FEATURE — delete src/features/example to start your project. //! -//! Public re-exports + composition helpers for the example feature. - -use std::sync::Arc; - -use axum::Router; +//! Example feature sliced into clean-architecture layers (Go +//! `internal/features/example` parity): +//! +//! ```text +//! contract/ canonical inbound wire types (leaf; published via crate::api::v1) +//! domain/ entities + domain errors (innermost) +//! port/ outbound (driven) ports +//! application/ inbound use-case port + implementation +//! adapter/in/ driving adapters (axum HTTP, tonic gRPC) +//! adapter/out/ driven adapters (postgres) +//! di.rs composition: wiring + sentinel → boundary error registration +//! ``` +//! +//! To start a real project: `rm -rf src/features/example`, remove `pub mod +//! example;` from `src/features/mod.rs`, and drop the `example::di::register` +//! call (plus the `example` config section) — the platform and app shell do +//! not reference the feature from anywhere else. +pub mod adapter; +pub mod application; +pub mod contract; +pub mod di; pub mod domain; -pub mod dto; -pub mod grpc; -pub mod handler; -pub mod repository; -pub mod service; - -pub use domain::{Error, Item, Repository, Service, SharedService}; -pub use dto::{CreateItemRequest, ItemResponse, ListItemsRequest, ListItemsResponse}; -pub use grpc::{GrpcServer, server as grpc_server}; -pub use handler::{Handler, routes as handler_routes}; -pub use repository::PgRepository; -pub use service::ServiceImpl; +pub mod port; -/// Build the axum router for the example feature. -pub fn http_routes(service: Arc) -> Router -where - S: domain::Service + ?Sized + Send + Sync + 'static, -{ - handler::routes(service) -} +pub use di::{Wired, register}; diff --git a/src/features/example/port/mod.rs b/src/features/example/port/mod.rs new file mode 100644 index 0000000..b426bca --- /dev/null +++ b/src/features/example/port/mod.rs @@ -0,0 +1,11 @@ +//! STUB FEATURE — delete src/features/example to start your project. +//! +//! Outbound (driven) ports for the example feature (Go +//! `internal/features/example/port` parity). The application layer consumes +//! these traits; driven adapters under `adapter/out` implement them. + +pub mod repository; + +#[cfg(test)] +pub use repository::MockRepository; +pub use repository::Repository; diff --git a/src/features/example/port/repository.rs b/src/features/example/port/repository.rs new file mode 100644 index 0000000..c16793d --- /dev/null +++ b/src/features/example/port/repository.rs @@ -0,0 +1,19 @@ +//! STUB FEATURE — delete src/features/example to start your project. + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::features::example::domain::{Error, Item}; + +/// Outbound persistence port for `Item` (Go `port.Repository` parity). +/// +/// The application layer depends on this abstraction; persistence adapters +/// under `adapter/out` satisfy it. May reference only this feature's domain +/// (`tests/architecture.rs`: port-depends-only-on-domain). +#[cfg_attr(test, mockall::automock)] +#[async_trait] +pub trait Repository: Send + Sync { + async fn create(&self, item: &Item) -> Result<(), Error>; + async fn get_by_id(&self, id: Uuid) -> Result; + async fn list(&self, limit: i32, offset: i32) -> Result, Error>; +} diff --git a/src/features/example/service.rs b/src/features/example/service.rs deleted file mode 100644 index df86f66..0000000 --- a/src/features/example/service.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! STUB FEATURE — delete src/features/example to start your project. -//! -//! Implementation of `domain::Service`. Mirrors Go -//! `internal/features/example/service/service.go`. - -use std::sync::Arc; - -use time::OffsetDateTime; -use uuid::Uuid; - -use crate::features::example::domain::{Error, Item, Repository, Service}; - -const DEFAULT_PAGE_SIZE: i32 = 20; -const MAX_PAGE_SIZE: i32 = 100; -const MAX_NAME_LENGTH: usize = 255; - -/// Concrete use-case service backed by a `domain::Repository`. -#[derive(Clone)] -pub struct ServiceImpl { - repo: Arc, - default_page_size: i32, - max_page_size: i32, - max_name_length: usize, -} - -impl ServiceImpl { - /// Build a service. Values `<= 0` fall back to the package defaults - /// (`20` / `100` / `255`), mirroring Go. - pub fn new( - repo: Arc, - default_page_size: i32, - max_page_size: i32, - max_name_length: i32, - ) -> Self { - let default_page_size = if default_page_size <= 0 { - DEFAULT_PAGE_SIZE - } else { - default_page_size - }; - let max_page_size = if max_page_size <= 0 { - MAX_PAGE_SIZE - } else { - max_page_size - }; - let max_name_length = if max_name_length <= 0 { - MAX_NAME_LENGTH - } else { - max_name_length as usize - }; - Self { - repo, - default_page_size, - max_page_size, - max_name_length, - } - } -} - -#[async_trait::async_trait] -impl Service for ServiceImpl { - async fn create(&self, name: String) -> Result { - let name = name.trim(); - // Mirror Go's `utf8.RuneCountInString(name) > maxNameLength`: count - // Unicode scalar values (chars), not UTF-8 bytes, so multi-byte - // names (e.g. CJK, Thai, emoji) follow the documented 255-rune cap - // rather than failing on raw byte length. - if name.is_empty() || name.chars().count() > self.max_name_length { - return Err(Error::InvalidName); - } - let now = OffsetDateTime::now_utc(); - let item = Item { - id: Uuid::now_v7(), - name: name.to_string(), - created_at: now, - updated_at: now, - }; - self.repo.create(&item).await?; - Ok(item) - } - - async fn get(&self, id: Uuid) -> Result { - self.repo.get_by_id(id).await - } - - async fn list(&self, limit: i32, offset: i32) -> Result, Error> { - let mut limit = if limit <= 0 { - self.default_page_size - } else { - limit - }; - if limit > self.max_page_size { - limit = self.max_page_size; - } - let offset = if offset < 0 { 0 } else { offset }; - self.repo.list(limit, offset).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::features::example::domain::MockRepository; - use mockall::predicate::*; - - fn item(id: Uuid, name: &str) -> Item { - let now = OffsetDateTime::now_utc(); - Item { - id, - name: name.to_string(), - created_at: now, - updated_at: now, - } - } - - #[tokio::test] - async fn create_rejects_empty_name() { - let repo = Arc::new(MockRepository::new()); - let svc = ServiceImpl::new(repo, 20, 100, 255); - assert_eq!( - svc.create(" ".to_string()).await.unwrap_err(), - Error::InvalidName - ); - } - - #[tokio::test] - async fn create_rejects_overlong_name() { - let repo = Arc::new(MockRepository::new()); - let svc = ServiceImpl::new(repo, 20, 100, 255); - let big = "a".repeat(256); - assert_eq!(svc.create(big).await.unwrap_err(), Error::InvalidName); - } - - #[tokio::test] - async fn create_accepts_multibyte_name_within_rune_cap() { - // 200 Thai "ช" (U+0E0A, 3 UTF-8 bytes each = 600 bytes) would be - // rejected by a byte-length check, but must pass under rune-count - // matching Go's `utf8.RuneCountInString`. Cap is 255 runes. - let mut mock = MockRepository::new(); - mock.expect_create().returning(|_| Ok(())); - let svc = ServiceImpl::new(Arc::new(mock), 20, 100, 255); - let name = "ช".repeat(200); - assert_eq!(name.len(), 600, "sanity: 3 bytes per Thai char"); - let item = svc.create(name).await.unwrap(); - assert_eq!(item.name.chars().count(), 200); - } - - #[tokio::test] - async fn create_rejects_multibyte_name_over_rune_cap() { - // 256 emoji 🎉 (U+1F389, 4 UTF-8 bytes each = 1024 bytes) — must - // be rejected because 256 > 255 rune cap (the 4 MiB-byte-count - // version would also reject, but the rune-count version is the - // parity contract with Go). - let repo = Arc::new(MockRepository::new()); - let svc = ServiceImpl::new(repo, 20, 100, 255); - let name = "🎉".repeat(256); - assert_eq!(name.len(), 1024, "sanity: 4 bytes per emoji"); - assert_eq!(svc.create(name).await.unwrap_err(), Error::InvalidName); - } - - #[tokio::test] - async fn create_trims_and_persists() { - let mut mock = MockRepository::new(); - mock.expect_create().returning(|_| Ok(())); - let repo = Arc::new(mock); - let svc = ServiceImpl::new(repo, 20, 100, 255); - let it = svc.create(" hello ".to_string()).await.unwrap(); - assert_eq!(it.name, "hello"); - assert_eq!(it.created_at, it.updated_at); - } - - #[tokio::test] - async fn get_passes_through_not_found() { - let mut mock = MockRepository::new(); - mock.expect_get_by_id() - .with(eq(Uuid::nil())) - .returning(|_| Err(Error::NotFound)); - let svc = ServiceImpl::new(Arc::new(mock), 20, 100, 255); - assert_eq!(svc.get(Uuid::nil()).await.unwrap_err(), Error::NotFound); - } - - #[tokio::test] - async fn get_returns_item_on_hit() { - let mut mock = MockRepository::new(); - mock.expect_get_by_id() - .with(eq(Uuid::nil())) - .returning(|_| Ok(item(Uuid::nil(), "x"))); - let svc = ServiceImpl::new(Arc::new(mock), 20, 100, 255); - let got = svc.get(Uuid::nil()).await.unwrap(); - assert_eq!(got.name, "x"); - } - - #[tokio::test] - async fn list_clamps_limit_above_max() { - let mut mock = MockRepository::new(); - mock.expect_list() - .withf(|limit, offset| *limit == 100 && *offset == 0) - .returning(|_, _| Ok(vec![])); - let svc = ServiceImpl::new(Arc::new(mock), 20, 100, 255); - let items = svc.list(9_999, 0).await.unwrap(); - assert!(items.is_empty()); - } - - #[tokio::test] - async fn list_uses_default_when_limit_zero() { - let mut mock = MockRepository::new(); - mock.expect_list() - .withf(|limit, offset| *limit == 20 && *offset == 0) - .returning(|_, _| Ok(vec![])); - let svc = ServiceImpl::new(Arc::new(mock), 20, 100, 255); - svc.list(0, 0).await.unwrap(); - } - - #[tokio::test] - async fn list_clamps_negative_offset_to_zero() { - let mut mock = MockRepository::new(); - mock.expect_list() - .withf(|limit, offset| *limit == 10 && *offset == 0) - .returning(|_, _| Ok(vec![])); - let svc = ServiceImpl::new(Arc::new(mock), 20, 100, 255); - svc.list(10, -5).await.unwrap(); - } - - #[tokio::test] - async fn fallback_defaults_apply_when_config_zero() { - let mut mock = MockRepository::new(); - mock.expect_list() - .withf(|limit, offset| *limit == 100 && *offset == 0) - .returning(|_, _| Ok(vec![])); - // All config values ≤ 0 → fall back to 20/100/255 - let svc = ServiceImpl::new(Arc::new(mock), 0, 0, 0); - svc.list(9_999, 0).await.unwrap(); - } -} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs deleted file mode 100644 index 2a773d6..0000000 --- a/src/infrastructure/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Infrastructure adapters: PostgreSQL pool + Valkey client + health checkers. - -pub mod db; -pub mod valkey; - -pub use db::{PgChecker, new_pool}; -pub use valkey::{ValkeyChecker, new_client}; diff --git a/src/lib.rs b/src/lib.rs index 9e3a8d3..4a727ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,18 +1,32 @@ //! zercle-rust-template — opinionated Rust (axum) microservice template. //! -//! Composition root = `Arc` (decision D2). See `app.rs` for the build order. +//! Clean architecture (DDD) layout mirroring the Go template +//! (`internal/features//{contract,domain,port,application,adapter,di}`): +//! +//! * [`platform`] — cross-cutting concerns (config, db, valkey, boundary +//! errors, health, telemetry, server shell, middleware). Feature-agnostic by +//! rule: platform may never import features. +//! * [`features`] — per-feature clean-architecture slices: +//! `contract` (inbound wire types, leaf) · `domain` (entities + errors, +//! innermost) · `port` (outbound ports) · `application` (use cases, speaks +//! contract types at the boundary) · `adapter/driving` + `adapter/driven` +//! (interface adapters) · `di` (composition). +//! * [`api`] — published contract facade for external consumers. Internal code +//! must not import it: the dependency is strictly outward-only. +//! * [`app`] — the composition root. +//! +//! All dependencies point inward (business logic never knows about +//! databases or frameworks), and the rule is enforced executably by +//! `tests/architecture.rs`. +pub mod api; pub mod app; -pub mod config; pub mod features; -pub mod infrastructure; -pub mod middleware; -pub mod shared; +pub mod platform; -pub use app::{AppState, build}; -pub use config::Config; -pub use shared::server; -pub use shared::telemetry::Telemetry; +pub use app::{Built, build}; +pub use platform::config::Config; +pub use platform::telemetry::Telemetry; /// Top-level run entry point. Loads config from the environment, validates it, /// then boots the full HTTP + gRPC server stack and waits for a shutdown signal. @@ -20,7 +34,7 @@ pub use shared::telemetry::Telemetry; /// Tests that need to run the application against real infrastructure call this /// directly; the binary `server` (`src/main.rs`) is a thin wrapper. pub async fn run() -> anyhow::Result<()> { - let cfg = config::Config::load()?; + let cfg = platform::config::Config::load()?; validator::Validate::validate(&cfg).map_err(|e| anyhow::anyhow!(e))?; cfg.validate_cross()?; app::run(cfg).await diff --git a/src/main.rs b/src/main.rs index 61acffd..6805d97 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ use std::process::ExitCode; -use zercle_rust_template::config::Config; +use zercle_rust_template::platform::config::Config; /// Compile-time build metadata. Overridden by the build system via /// `option_env!` so the binary runs without extra build flags. diff --git a/src/config.rs b/src/platform/config.rs similarity index 100% rename from src/config.rs rename to src/platform/config.rs diff --git a/src/infrastructure/db.rs b/src/platform/db.rs similarity index 97% rename from src/infrastructure/db.rs rename to src/platform/db.rs index 7e78bd5..c2b3949 100644 --- a/src/infrastructure/db.rs +++ b/src/platform/db.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result}; use async_trait::async_trait; use sqlx::postgres::PgPoolOptions; -use crate::{config::Config, shared::health::Checker}; +use crate::{platform::config::Config, platform::health::Checker}; /// Build a tuned [`sqlx::PgPool`] from `cfg` and ping it before returning. /// diff --git a/src/shared/errors.rs b/src/platform/errors.rs similarity index 85% rename from src/shared/errors.rs rename to src/platform/errors.rs index a898bfd..fa44f5f 100644 --- a/src/shared/errors.rs +++ b/src/platform/errors.rs @@ -1,8 +1,12 @@ -//! Shared boundary error type. Mirrors `internal/shared/errors/app_error.go` (structure.md §6). +//! Shared boundary error type. Mirrors Go `internal/platform/errors`. //! //! - `AppError::http_status()` → `axum::http::StatusCode` //! - `AppError::grpc_code()` → `tonic::Code` //! - `impl IntoResponse for AppError` → JSON `{"error": CODE, "message": MSG}` with the status. +//! +//! The machine-readable codes are exposed as [`errcodes`] constants and +//! published outward via `crate::api::v1::errcodes` so other services can +//! interpret error envelopes without importing server internals. use axum::{ Json, @@ -12,6 +16,21 @@ use axum::{ use serde::Serialize; use tonic::Code as GrpcCode; +/// Stable machine-readable error codes carried on the wire (the HTTP JSON +/// `error` field). Published outward via `crate::api::v1::errcodes`; feature +/// domains map their sentinels onto these at the composition edge (each +/// feature's `di`). +pub mod errcodes { + pub const NOT_FOUND: &str = "NOT_FOUND"; + pub const INVALID_INPUT: &str = "INVALID_INPUT"; + pub const UNAUTHORIZED: &str = "UNAUTHORIZED"; + pub const FORBIDDEN: &str = "FORBIDDEN"; + pub const CONFLICT: &str = "CONFLICT"; + pub const CANCELED: &str = "CANCELED"; + pub const DEADLINE_EXCEEDED: &str = "DEADLINE_EXCEEDED"; + pub const INTERNAL: &str = "INTERNAL"; +} + /// Shared, transport-agnostic error used at the HTTP / gRPC boundary. #[derive(Debug, thiserror::Error)] pub enum AppError { @@ -36,14 +55,14 @@ pub enum AppError { impl AppError { pub fn code(&self) -> &'static str { match self { - Self::NotFound { .. } => "NOT_FOUND", - Self::InvalidInput { .. } => "INVALID_INPUT", - Self::Unauthorized => "UNAUTHORIZED", - Self::Forbidden => "FORBIDDEN", - Self::Conflict => "CONFLICT", - Self::Canceled => "CANCELED", - Self::DeadlineExceeded => "DEADLINE_EXCEEDED", - Self::Internal { .. } => "INTERNAL", + Self::NotFound { .. } => errcodes::NOT_FOUND, + Self::InvalidInput { .. } => errcodes::INVALID_INPUT, + Self::Unauthorized => errcodes::UNAUTHORIZED, + Self::Forbidden => errcodes::FORBIDDEN, + Self::Conflict => errcodes::CONFLICT, + Self::Canceled => errcodes::CANCELED, + Self::DeadlineExceeded => errcodes::DEADLINE_EXCEEDED, + Self::Internal { .. } => errcodes::INTERNAL, } } diff --git a/src/shared/health.rs b/src/platform/health.rs similarity index 100% rename from src/shared/health.rs rename to src/platform/health.rs diff --git a/src/middleware/access_log.rs b/src/platform/middleware/access_log.rs similarity index 98% rename from src/middleware/access_log.rs rename to src/platform/middleware/access_log.rs index f2e0f4e..b1b53de 100644 --- a/src/middleware/access_log.rs +++ b/src/platform/middleware/access_log.rs @@ -11,7 +11,7 @@ use std::time::Instant; use axum::{extract::Request, middleware::Next, response::Response}; use tracing::info; -use crate::middleware::request_id::RequestId; +use crate::platform::middleware::request_id::RequestId; /// The underlying axum middleware function. Wrap with [`axum::middleware::from_fn`] (or use /// [`layer`]). diff --git a/src/middleware/cors.rs b/src/platform/middleware/cors.rs similarity index 99% rename from src/middleware/cors.rs rename to src/platform/middleware/cors.rs index 1e4c451..4ac6ce5 100644 --- a/src/middleware/cors.rs +++ b/src/platform/middleware/cors.rs @@ -15,7 +15,7 @@ use std::time::Duration; use axum::http::{HeaderName, HeaderValue, Method}; use tower_http::cors::{AllowOrigin, Any, CorsLayer}; -use crate::config::Config; +use crate::platform::config::Config; const DEFAULT_METHODS: &[&str] = &["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"]; const DEFAULT_HEADERS: &[&str] = &["Origin", "Content-Type", "Accept", "Authorization"]; @@ -98,7 +98,7 @@ mod tests { use tower::ServiceExt; use super::*; - use crate::config::Config; + use crate::platform::config::Config; fn sample_cfg_with(origins: Vec, methods: Vec, headers: Vec) -> Config { let origins_str = if origins.is_empty() { diff --git a/src/middleware/mod.rs b/src/platform/middleware/mod.rs similarity index 100% rename from src/middleware/mod.rs rename to src/platform/middleware/mod.rs diff --git a/src/middleware/recover.rs b/src/platform/middleware/recover.rs similarity index 98% rename from src/middleware/recover.rs rename to src/platform/middleware/recover.rs index 729a9d5..8595a74 100644 --- a/src/middleware/recover.rs +++ b/src/platform/middleware/recover.rs @@ -13,7 +13,7 @@ use std::any::Any; use axum::{body::Body, http::Response, response::IntoResponse}; use tower_http::catch_panic::{CatchPanicLayer, ResponseForPanic}; -use crate::shared::errors::AppError; +use crate::platform::errors::AppError; /// Inner panic handler that produces the same JSON body as `AppError::Internal.into_response()`. #[derive(Clone, Copy, Debug)] diff --git a/src/middleware/request_id.rs b/src/platform/middleware/request_id.rs similarity index 100% rename from src/middleware/request_id.rs rename to src/platform/middleware/request_id.rs diff --git a/src/platform/mod.rs b/src/platform/mod.rs new file mode 100644 index 0000000..d3b977e --- /dev/null +++ b/src/platform/mod.rs @@ -0,0 +1,16 @@ +//! Cross-cutting platform concerns (Go `internal/platform/*` parity): +//! config, database/cache adapters, boundary errors, health, telemetry, +//! the HTTP/gRPC server shell, and middleware. +//! +//! Platform code is feature-agnostic by rule — it may never import +//! `crate::features::…` (enforced by `tests/architecture.rs`). Features +//! depend on platform, never the reverse. + +pub mod config; +pub mod db; +pub mod errors; +pub mod health; +pub mod middleware; +pub mod server; +pub mod telemetry; +pub mod valkey; diff --git a/src/shared/server/grpc_interceptor.rs b/src/platform/server/grpc_interceptor.rs similarity index 100% rename from src/shared/server/grpc_interceptor.rs rename to src/platform/server/grpc_interceptor.rs diff --git a/src/shared/server/http.rs b/src/platform/server/http.rs similarity index 86% rename from src/shared/server/http.rs rename to src/platform/server/http.rs index 68f9b0b..a52daa6 100644 --- a/src/shared/server/http.rs +++ b/src/platform/server/http.rs @@ -1,6 +1,8 @@ //! axum HTTP server builder: middleware stack, shared routes, and feature mount. //! -//! Mirrors `internal/shared/server/http.go` (structure.md §14, canvas row 26). +//! Mirrors Go `internal/platform/server/http.go`. Feature-agnostic: feature +//! routers arrive pre-mounted (each feature nests itself under its versioned +//! prefix) and are merged here with the shared health/metrics routes. //! //! Middleware order (applied as the outermost layers in the same sequence): //! Recover → RequestID → OTel(`TraceLayer`) → AccessLog → CORS → BodyLimit. @@ -24,19 +26,19 @@ use prometheus::Registry; use tower::ServiceBuilder; use tower_http::{limit::RequestBodyLimitLayer, trace::TraceLayer}; -use crate::{ - features::example, +use crate::platform::{ middleware::{access_log, cors, recover, request_id}, - shared::telemetry::metrics_body as render_metrics, + telemetry::metrics_body as render_metrics, }; const METRICS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(5); -/// Build the application router with the full middleware stack and shared routes. +/// Build the application router with the full middleware stack, shared routes, +/// and the merged feature routers. /// /// The returned `Router` is `Send + 'static` and ready for `axum::serve(listener, router)`. -pub fn build_router(state: Arc) -> Router { +pub fn build_router(state: Arc, api: Router) -> Router { let cfg = state.cfg.clone(); let body_limit_bytes = parse_body_limit_bytes(&cfg.http.body_limit); @@ -72,21 +74,18 @@ pub fn build_router(state: Arc) -> Router { }; // Shared routes — use idiomatic Axum `State` extraction so the parent - // router stays state-less and can `nest` the example feature router - // (whose own state type is unrelated to ours). State is a tuple of - // `Arc` plus the probe timeout; handlers destructure it via - // the `State` extractor. + // router stays state-less. State is a tuple of `Arc` plus the + // probe timeout; handlers destructure it via the `State` extractor. let shared = Router::new() .route("/healthz", get(healthz_handler)) .route("/readyz", get(readyz_handler)) .route("/metrics", get(metrics_handler)) .with_state((state.clone(), probe_timeout)); - // Mount the example feature under `/api/v1`. - let app_router = shared.nest( - "/api/v1", - example::http_routes(state.example_service.clone()), - ); + // Feature routers arrive pre-mounted under their versioned prefixes + // (e.g. `/api/v1` — see each feature's `di`), so this module never + // references feature code. + let app_router = shared.merge(api); let app_router = app_router.layer(middleware_stack); @@ -98,7 +97,7 @@ pub fn build_router(state: Arc) -> Router { } async fn healthz_handler( - State((state, probe_timeout)): State<(Arc, Duration)>, + State((state, probe_timeout)): State<(Arc, Duration)>, ) -> Response { let registry = state.health.clone(); let result = tokio::time::timeout(probe_timeout, registry.live()).await; @@ -116,7 +115,7 @@ async fn healthz_handler( } async fn readyz_handler( - State((state, probe_timeout)): State<(Arc, Duration)>, + State((state, probe_timeout)): State<(Arc, Duration)>, ) -> Response { let registry = state.health.clone(); let result = tokio::time::timeout(probe_timeout, registry.ready()).await; @@ -136,7 +135,7 @@ async fn readyz_handler( } async fn metrics_handler( - State((_state, _probe_timeout)): State<(Arc, Duration)>, + State((_state, _probe_timeout)): State<(Arc, Duration)>, ) -> Response { let registry = metrics_registry(); let body = render_metrics(®istry); diff --git a/src/shared/server/mod.rs b/src/platform/server/mod.rs similarity index 69% rename from src/shared/server/mod.rs rename to src/platform/server/mod.rs index b4d7a75..7825f65 100644 --- a/src/shared/server/mod.rs +++ b/src/platform/server/mod.rs @@ -1,7 +1,10 @@ //! HTTP + gRPC server orchestration. Owns axum + tonic and the ordered shutdown. //! -//! Mirrors `internal/shared/server/{shutdown,grpc}.go` (structure.md §14, canvas row 28). -//! See [`run`] for the top-level entry point used by [`crate::app::run`]. +//! Mirrors Go `internal/platform/server`. The server shell is feature-agnostic: +//! HTTP feature routers arrive pre-mounted (each feature nests itself under its +//! versioned prefix) and gRPC services arrive pre-registered on the router built +//! by [`grpc_server`]. [`run`] is the top-level entry point used by the +//! composition root ([`crate::app`]). pub mod grpc_interceptor; pub mod http; @@ -10,27 +13,66 @@ pub mod shutdown; use std::{sync::Arc, time::Duration}; use anyhow::{Context, Result}; +use axum::Router; use tokio::task::JoinHandle; use tonic::transport::Server as TonicServer; -use tower::ServiceBuilder; - -/// 4 MiB cap on incoming + outgoing gRPC message bodies. Matches Go's -/// `grpc.MaxRecvMsgSize(4*1024*1024)` and `grpc.MaxSendMsgSize(4*1024*1024)` -/// in `internal/shared/server/grpc.go` (Go defaults to 4 MiB anyway, but -/// setting it explicitly makes the limit visible at the call site). -const GRPC_MESSAGE_SIZE_LIMIT: usize = 4 * 1024 * 1024; - -use crate::{ - app::AppState, - features::example::{GrpcServer, grpc_server}, - shared::telemetry::{Telemetry, shutdown as shutdown_telemetry}, + +use crate::platform::{ + config::Config, + health::Registry as HealthRegistry, + telemetry::{Telemetry, shutdown as shutdown_telemetry}, }; +/// Layer stack applied by [`grpc_server`] on top of the plain tonic builder. +/// +/// `tower::layer::util::Stack` and `Identity` are public types, so the concrete +/// tonic `Router` type is nameable — no generics are needed anywhere in the +/// wiring (`di::register` → `app::build` → `run`). +pub type GrpcStack = + tower::layer::util::Stack; + +/// A tonic router carrying every registered feature gRPC service. +pub type GrpcRouter = tonic::transport::server::Router; + +/// Process-wide server state assembled by the composition root. Cloned cheaply +/// via [`Arc`] (the underlying pools and registries are already `Arc`-based). +#[derive(Clone)] +pub struct AppState { + pub cfg: Arc, + pub db: sqlx::PgPool, + pub valkey: redis::aio::ConnectionManager, + pub health: Arc, +} + +pub use http::build_router; pub use shutdown::shutdown_signal; +/// Pre-configured tonic server builder: trace spans per call (OTel parity with +/// the Go template's `otelgrpc` stats handler) plus the unary logging + +/// panic-recovery interceptor. Features call `.add_service(…)` on the returned +/// builder; keep all transport-level gRPC policy here so features stay +/// transport-dumb. +pub fn grpc_server() -> TonicServer { + TonicServer::builder() + .layer(grpc_interceptor::GrpcLogRecoverLayer) + .trace_fn(|req| { + let method = req.uri().path(); + tracing::info_span!("grpc", method = %method, otel.kind = "server", otel.status_code = tracing::field::Empty) + }) +} + /// Start the HTTP + gRPC servers, wait for a shutdown signal (or a server /// error), then run the ordered graceful shutdown. -pub async fn run(state: AppState, telemetry: Telemetry) -> Result<()> { +/// +/// `api` is the merged feature HTTP router (each feature nests itself under +/// its versioned prefix); `grpc` is the tonic router built from +/// [`grpc_server`] + feature services. +pub async fn run( + state: AppState, + telemetry: Telemetry, + api: Router, + grpc: GrpcRouter, +) -> Result<()> { // Install the Prometheus registry for the /metrics handler. http::install_metrics_registry(telemetry.prometheus_registry.clone()); @@ -65,7 +107,7 @@ pub async fn run(state: AppState, telemetry: Telemetry) -> Result<()> { }); // --- HTTP server (axum) ---------------------------------------------- - let router = http::build_router(state.clone()); + let router = http::build_router(state.clone(), api); let http_state_for_shutdown = state.clone(); let mut http_rx = shutdown_rx.clone(); let http_handle: JoinHandle> = tokio::spawn(async move { @@ -80,38 +122,16 @@ pub async fn run(state: AppState, telemetry: Telemetry) -> Result<()> { // --- gRPC server (tonic) --------------------------------------------- // - // Observability parity with the Go template's - // `grpc.StatsHandler(otelgrpc.NewServerHandler())` is achieved via - // `trace_fn`, which attaches a `tracing::Span` to each call. The - // `tracing-opentelemetry` layer installed in `shared::telemetry` then - // exports those spans over OTLP, so OTel sees one span per gRPC call - // without pulling a new crate. The unary logging + panic-recovery - // interceptor (`grpc_interceptor::GrpcLogRecoverLayer`) is the - // functional equivalent of the Go unary interceptor. Stream RPCs - // receive the tracing span but no panic-recovery wrapper (tonic 0.12's - // `Interceptor` trait only covers unary; this is the documented - // acceptable gap). - let example_grpc = grpc_server(GrpcServer::new(state.example_service.clone())) - .max_decoding_message_size(GRPC_MESSAGE_SIZE_LIMIT) - .max_encoding_message_size(GRPC_MESSAGE_SIZE_LIMIT); - let grpc_log_layer = ServiceBuilder::new() - .layer(grpc_interceptor::GrpcLogRecoverLayer) - .into_inner(); + // `grpc` already carries every feature service plus the platform layer + // stack (see [`grpc_server`]); here we only drive it to completion. let mut grpc_rx = shutdown_rx.clone(); let grpc_handle: JoinHandle> = tokio::spawn(async move { - TonicServer::builder() - .layer(grpc_log_layer) - .trace_fn(|req| { - let method = req.uri().path(); - tracing::info_span!("grpc", method = %method, otel.kind = "server", otel.status_code = tracing::field::Empty) - }) - .add_service(example_grpc) - .serve_with_shutdown(grpc_socket_addr, async move { - let _ = grpc_rx.changed().await; - tracing::info!("grpc graceful shutdown initiated"); - }) - .await - .context("tonic serve") + grpc.serve_with_shutdown(grpc_socket_addr, async move { + let _ = grpc_rx.changed().await; + tracing::info!("grpc graceful shutdown initiated"); + }) + .await + .context("tonic serve") }); let shutdown_timeout = cfg.shutdown_timeout(); @@ -184,7 +204,7 @@ pub async fn run(state: AppState, telemetry: Telemetry) -> Result<()> { /// Ordered graceful shutdown: HTTP drain (already in-flight via /// `with_graceful_shutdown`), gRPC drain (bounded), DB close, Valkey drop, /// telemetry flush. Bounded by `shutdown_timeout`. -pub async fn shutdown(state: &AppState, telemetry: Telemetry, shutdown_timeout: Duration) { +async fn shutdown(state: &AppState, telemetry: Telemetry, shutdown_timeout: Duration) { tracing::info!( timeout_secs = shutdown_timeout.as_secs(), "shutdown initiated" diff --git a/src/shared/server/shutdown.rs b/src/platform/server/shutdown.rs similarity index 100% rename from src/shared/server/shutdown.rs rename to src/platform/server/shutdown.rs diff --git a/src/shared/telemetry.rs b/src/platform/telemetry.rs similarity index 99% rename from src/shared/telemetry.rs rename to src/platform/telemetry.rs index 5132d9c..cec59c1 100644 --- a/src/shared/telemetry.rs +++ b/src/platform/telemetry.rs @@ -25,7 +25,7 @@ use opentelemetry_sdk::{ use prometheus::{Encoder, Registry, TextEncoder}; use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, prelude::*}; -use crate::config::Config; +use crate::platform::config::Config; /// Errors raised by [`init`]. Kept narrow so callers can convert into the shared /// `AppError::Internal` without losing the cause chain. diff --git a/src/infrastructure/valkey.rs b/src/platform/valkey.rs similarity index 99% rename from src/infrastructure/valkey.rs rename to src/platform/valkey.rs index f797be4..c05f625 100644 --- a/src/infrastructure/valkey.rs +++ b/src/platform/valkey.rs @@ -11,7 +11,7 @@ use anyhow::{Context, Result}; use async_trait::async_trait; use redis::{Client, aio::ConnectionManager}; -use crate::{config::Config, shared::health::Checker}; +use crate::{platform::config::Config, platform::health::Checker}; /// Build the Valkey/Redis connection URL from `cfg`. /// diff --git a/src/shared/mod.rs b/src/shared/mod.rs deleted file mode 100644 index 0c5aaad..0000000 --- a/src/shared/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Shared utilities (errors, telemetry, health, server orchestration). - -pub mod errors; -pub mod health; -pub mod server; -pub mod telemetry; diff --git a/tests/architecture.rs b/tests/architecture.rs new file mode 100644 index 0000000..1645f23 --- /dev/null +++ b/tests/architecture.rs @@ -0,0 +1,358 @@ +//! Executable dependency gates for the clean-architecture layering. +//! +//! Each rule scans the `use crate::…` statements of every source file under +//! `src/` and fails with the violated rule's rationale. Mirrors the Go +//! template's `internal/architecture_test.go`: driving adapters depend on the +//! application port, the use case depends on outbound ports + domain + +//! contract, adapters satisfy ports structurally, and the published contract +//! facade `crate::api` is importable only from outside internal code. +//! +//! Scope note: only `crate::`-rooted imports are checked. Relative imports +//! (`super::…`, sibling modules) are inherently intra-layer and cannot cross +//! feature/layer boundaries, so they are out of scope — same spirit as Go's +//! import-path scan. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// A layering rule: `denied` reports whether the crate-rooted `import` path +/// violates the rule for the module at `module` (path relative to `src/`, +/// extension-less, `mod.rs` collapsed to its directory). +struct Rule { + name: &'static str, + why: &'static str, + denied: fn(module: &str, import: &str) -> bool, +} + +fn is_under(path: &str, prefix: &str) -> bool { + path == prefix || path.starts_with(&format!("{prefix}/")) +} + +/// Feature name for a `features//…` module path. +fn feature_of(module: &str) -> Option<&str> { + let segs: Vec<&str> = module.split('/').collect(); + if segs.len() >= 2 && segs[0] == "features" { + Some(segs[1]) + } else { + None + } +} + +fn rules() -> Vec { + vec![ + Rule { + name: "published-contract-is-outward-only", + why: "internal code must not import the api facade; depend on the feature contract modules directly", + denied: |module, import| !is_under(module, "api") && is_under(import, "api"), + }, + Rule { + name: "domain-is-innermost", + why: "domain may depend on nothing crate-internal (stdlib-adjacent crates only)", + denied: |module, _import| { + feature_of(module).is_some() + && is_under( + module, + &format!("features/{}/domain", feature_of(module).unwrap_or_default()), + ) + }, + }, + Rule { + name: "contract-is-leaf", + why: "the wire contract must stay dependency-free so the published api facade drags in nothing", + denied: |module, _import| { + feature_of(module).is_some() + && is_under( + module, + &format!( + "features/{}/contract", + feature_of(module).unwrap_or_default() + ), + ) + }, + }, + Rule { + name: "port-depends-only-on-domain", + why: "outbound ports may reference only their own feature's domain", + denied: |module, import| { + let Some(f) = feature_of(module) else { + return false; + }; + is_under(module, &format!("features/{f}/port")) + && !is_under(import, &format!("features/{f}/domain")) + }, + }, + Rule { + name: "application-depends-on-domain-port-contract", + why: "use cases orchestrate their own feature's domain, ports, and wire contract, nothing else", + denied: |module, import| { + let Some(f) = feature_of(module) else { + return false; + }; + if !is_under(module, &format!("features/{f}/application")) { + return false; + } + !(is_under(import, &format!("features/{f}/domain")) + || is_under(import, &format!("features/{f}/port")) + || is_under(import, &format!("features/{f}/contract")) + || is_under(import, &format!("features/{f}/application"))) + }, + }, + Rule { + name: "driven-adapters-ignore-application", + why: "adapter/driven satisfies ports structurally and must not know about the application layer or driving adapters", + denied: |module, import| { + let Some(f) = feature_of(module) else { + return false; + }; + if !is_under(module, &format!("features/{f}/adapter/driven")) { + return false; + } + is_under(import, &format!("features/{f}/application")) + || import.contains("/adapter/driving") + }, + }, + Rule { + name: "driving-adapters-ignore-ports-and-driven-adapters", + why: "adapter/driving talks to the application port only, never to outbound ports or other adapters", + denied: |module, import| { + let Some(f) = feature_of(module) else { + return false; + }; + if !is_under(module, &format!("features/{f}/adapter/driving")) { + return false; + } + is_under(import, &format!("features/{f}/port")) + || import.contains("/adapter/driven") + }, + }, + Rule { + name: "platform-ignores-features", + why: "cross-cutting platform code must stay feature-agnostic; features depend on platform, never the reverse", + denied: |module, import| is_under(module, "platform") && is_under(import, "features"), + }, + ] +} + +/// Collapse a file path under `src/` to its module path: extension stripped, +/// `mod.rs` replaced by its directory. +fn module_path(src_root: &Path, file: &Path) -> String { + let rel = file + .strip_prefix(src_root) + .expect("file is under src root") + .to_string_lossy() + .into_owned(); + let rel = rel.strip_suffix(".rs").unwrap_or(&rel); + let rel = rel.strip_suffix("/mod").unwrap_or(rel); + rel.to_string() +} + +/// Remove `//` line comments so commented-out code cannot trip the scanner. +/// (Block comments are not used for code in this codebase's style.) +fn strip_line_comments(text: &str) -> String { + text.lines() + .map(|line| match line.find("//") { + Some(idx) if !line[..idx].contains('"') => &line[..idx], + _ => line, + }) + .collect::>() + .join("\n") +} + +/// Extract all `crate::`-rooted import paths from a source text, expanding +/// braced use-trees (`use crate::a::{b::{c}, d};`) and stripping `as` renames +/// and glob markers. +/// +/// All slice indices derive from `str::find` results, so multibyte source +/// text (identifiers in test fixtures, CJK comments) is handled safely. +fn crate_imports(text: &str) -> Vec { + let text = strip_line_comments(text); + let mut imports = Vec::new(); + let mut search_from = 0usize; + while let Some(rel) = text[search_from..].find("use crate::") { + let stmt_start = search_from + rel + "use ".len(); + let stmt_end = text[stmt_start..] + .find(';') + .map_or(text.len(), |e| stmt_start + e); + let tree = text[stmt_start..stmt_end].trim(); + let tree = tree.strip_prefix("crate::").unwrap_or(tree); + expand_use_tree("", tree, &mut imports); + search_from = stmt_end.max(stmt_start + 1); + } + imports +} + +/// Recursively expand a use-tree (`a::b`, `{x, y}`, `a::{b::{c}, d}`, +/// `x as y`, `prelude::*`) into concrete paths, prefixing each with `prefix`. +fn expand_use_tree(prefix: &str, tree: &str, out: &mut Vec) { + // Split on top-level commas. + let mut depth = 0i32; + let mut start = 0usize; + let mut parts: Vec<&str> = Vec::new(); + for (i, c) in tree.char_indices() { + match c { + '{' => depth += 1, + '}' => depth -= 1, + ',' if depth == 0 => { + parts.push(&tree[start..i]); + start = i + 1; + } + _ => {} + } + } + parts.push(&tree[start..]); + + for part in parts { + let part = part.trim(); + if part.is_empty() { + continue; + } + // Locate the first top-level `::{` group. + let bytes = part.as_bytes(); + let mut group: Option<(usize, usize)> = None; // (head_end, close_index) + let mut d = 0i32; + let mut j = 0usize; + while j < bytes.len() { + match bytes[j] { + b'{' => d += 1, + b'}' => d -= 1, + b':' if d == 0 + && j + 2 < bytes.len() + && bytes[j + 1] == b':' + && bytes[j + 2] == b'{' => + { + let mut dd = 1i32; + let mut k = j + 3; + while k < bytes.len() && dd > 0 { + match bytes[k] { + b'{' => dd += 1, + b'}' => dd -= 1, + _ => {} + } + k += 1; + } + group = Some((j, k - 1)); + break; + } + _ => {} + } + j += 1; + } + + if let Some((head_end, close)) = group { + let head = &part[..head_end]; + let inner = &part[head_end + 3..close]; + expand_use_tree(&format!("{prefix}{head}::"), inner, out); + } else if part.starts_with('{') && part.ends_with('}') { + expand_use_tree(prefix, &part[1..part.len() - 1], out); + } else { + let leaf = part.split(" as ").next().unwrap_or(part).trim(); + let leaf = leaf.strip_suffix('*').unwrap_or(leaf); + if !leaf.is_empty() { + // Emit module paths in slash form (the form the rule table + // compares against): `a::b::C` → `a/b/C`. + out.push(format!("{prefix}{leaf}").replace("::", "/")); + } + } + } +} + +/// Recursively collect `*.rs` files under `dir`. +fn collect_rs_files(dir: &Path, out: &mut Vec) { + let entries = fs::read_dir(dir).unwrap_or_else(|e| panic!("read dir {}: {e}", dir.display())); + for entry in entries { + let entry = entry.expect("dir entry"); + let path = entry.path(); + if path.is_dir() { + collect_rs_files(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } +} + +#[test] +fn architecture_rules_hold() { + let manifest = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + let src_root = Path::new(&manifest).join("src"); + + let mut files = Vec::new(); + collect_rs_files(&src_root, &mut files); + assert!( + files.len() >= 20, + "expected the full src tree, found {}", + files.len() + ); + files.sort(); + + let rule_table = rules(); + let mut violations: Vec = Vec::new(); + + for file in &files { + let module = module_path(&src_root, file); + let text = &fs::read_to_string(file).expect("read source"); + for import in crate_imports(text) { + for rule in &rule_table { + if (rule.denied)(&module, &import) { + violations.push(format!( + "{}: module `{}` violates {}: imports `crate::{}` ({})", + file.display(), + module, + rule.name, + import, + rule.why + )); + } + } + } + } + + assert!( + violations.is_empty(), + "clean-architecture dependency rules violated:\n {}", + violations.join("\n ") + ); +} + +#[cfg(test)] +mod scanner_tests { + use super::*; + + #[test] + fn expands_plain_paths() { + assert_eq!( + crate_imports("use crate::platform::config::Config;"), + vec!["platform/config/Config".to_string()] + ); + } + + #[test] + fn expands_braced_groups_with_rename() { + let got = crate_imports( + "use crate::platform::{middleware::{access_log, cors}, telemetry::metrics_body as render_metrics};", + ); + assert_eq!( + got, + vec![ + "platform/middleware/access_log".to_string(), + "platform/middleware/cors".to_string(), + "platform/telemetry/metrics_body".to_string(), + ] + ); + } + + #[test] + fn ignores_comments_and_relative_imports() { + let got = crate_imports("// use crate::platform::db;\nuse super::http;\nuse sqlx::PgPool;"); + assert!(got.is_empty(), "got {got:?}"); + } + + #[test] + fn module_paths_collapse_mod_rs() { + let root = Path::new("/x/src"); + assert_eq!( + module_path(root, Path::new("/x/src/platform/server/mod.rs")), + "platform/server" + ); + assert_eq!(module_path(root, Path::new("/x/src/app.rs")), "app"); + } +} diff --git a/tests/e2e.rs b/tests/e2e.rs index c0e9650..16d2089 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -11,7 +11,7 @@ use std::time::Duration; use tokio::net::TcpListener; -use zercle_rust_template::config::Config; +use zercle_rust_template::platform::config::Config; /// Run the full application stack and assert that the documented HTTP probes /// all return the expected status codes. diff --git a/tests/example_http.rs b/tests/example_http.rs index 7ea4962..a990972 100644 --- a/tests/example_http.rs +++ b/tests/example_http.rs @@ -1,6 +1,10 @@ //! Integration test for the example feature's HTTP routes against a real //! Postgres + Valkey pair. //! +//! Exercises the real feature composition path: `features::example::di::register` +//! (repository → use case → HTTP adapter), with the router mounted exactly as +//! the application mounts it (nested under `/api/v1`). +//! //! Skips cleanly (`return Ok(())`) when neither backing service is reachable, //! so `cargo test --test example_http` is green on a developer machine without //! docker-compose running and still exercises the full HTTP path against a @@ -8,7 +12,6 @@ mod common; -use std::sync::Arc; use std::time::Duration; use axum::body::Body; @@ -17,13 +20,12 @@ use sqlx::postgres::PgPoolOptions; use tower::ServiceExt; use uuid::Uuid; -use zercle_rust_template::config::Config; -use zercle_rust_template::features::example::{ - PgRepository, ServiceImpl, http_routes as example_http_routes, -}; +use zercle_rust_template::features::example::di; +use zercle_rust_template::platform::config::Config; /// Happy path + error path against a real Postgres: build a pool, run -/// migrations, mount the example router, and exercise POST/GET /items. +/// migrations, mount the feature via its `di`, and exercise +/// POST/GET `/api/v1/items`. #[tokio::test] async fn example_http_round_trip() -> anyhow::Result<()> { let cfg = match Config::load() { @@ -60,49 +62,40 @@ async fn example_http_round_trip() -> anyhow::Result<()> { // Clean any leftover rows so the GET-count assertion is stable. sqlx::query("DELETE FROM items").execute(&pool).await.ok(); - let repo: Arc = - Arc::new(PgRepository::new(pool.clone())); - let service = Arc::new(ServiceImpl::new( - repo, - cfg.example.default_page_size as i32, - cfg.example.max_page_size as i32, - cfg.example.max_name_length as i32, - )); - - let app = example_http_routes(service); + let app = di::register(&cfg, pool).http; - // --- Happy path: POST /items → 201 ------------------------------- + // --- Happy path: POST /api/v1/items → 201 ------------------------- let resp = app .clone() .oneshot( Request::builder() .method("POST") - .uri("/items") + .uri("/api/v1/items") .header("content-type", "application/json") .body(Body::from(r#"{"name":"alpha"}"#)) .unwrap(), ) .await .unwrap(); - assert_eq!(resp.status(), SC::CREATED, "POST /items happy path"); + assert_eq!(resp.status(), SC::CREATED, "POST /api/v1/items happy path"); let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let created_id = v["id"].as_str().expect("id is a string").to_string(); assert!(Uuid::parse_str(&created_id).is_ok(), "id is a valid uuid"); assert_eq!(v["name"], "alpha"); - // --- Happy path: GET /items → 200 + the row we just created ------ + // --- Happy path: GET /api/v1/items → 200 + the row we just created let resp = app .clone() .oneshot( Request::builder() - .uri("/items") + .uri("/api/v1/items") .body(Body::empty()) .unwrap(), ) .await .unwrap(); - assert_eq!(resp.status(), SC::OK, "GET /items happy path"); + assert_eq!(resp.status(), SC::OK, "GET /api/v1/items happy path"); let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let items = v["items"].as_array().unwrap(); @@ -111,26 +104,26 @@ async fn example_http_round_trip() -> anyhow::Result<()> { "alpha present in list: {v}" ); - // --- GET /items/:id → 200 hit ----------------------------------- + // --- GET /api/v1/items/:id → 200 hit ------------------------------ let resp = app .clone() .oneshot( Request::builder() - .uri(format!("/items/{created_id}")) + .uri(format!("/api/v1/items/{created_id}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); - assert_eq!(resp.status(), SC::OK, "GET /items/:id hit"); + assert_eq!(resp.status(), SC::OK, "GET /api/v1/items/:id hit"); - // --- POST /items → 400 invalid name (empty) --------------------- + // --- POST /api/v1/items → 400 invalid name (empty) ---------------- let resp = app .clone() .oneshot( Request::builder() .method("POST") - .uri("/items") + .uri("/api/v1/items") .header("content-type", "application/json") .body(Body::from(r#"{"name":""}"#)) .unwrap(), @@ -139,12 +132,12 @@ async fn example_http_round_trip() -> anyhow::Result<()> { .unwrap(); assert_eq!(resp.status(), SC::BAD_REQUEST, "POST empty name"); - // --- GET /items/:id → 404 not found ------------------------------ + // --- GET /api/v1/items/:id → 404 not found ------------------------ let resp = app .clone() .oneshot( Request::builder() - .uri(format!("/items/{}", Uuid::nil())) + .uri(format!("/api/v1/items/{}", Uuid::nil())) .body(Body::empty()) .unwrap(), ) @@ -152,12 +145,12 @@ async fn example_http_round_trip() -> anyhow::Result<()> { .unwrap(); assert_eq!(resp.status(), SC::NOT_FOUND, "GET missing id"); - // --- GET /items/:id → 400 bad uuid ------------------------------- + // --- GET /api/v1/items/:id → 400 bad uuid ------------------------- let resp = app .clone() .oneshot( Request::builder() - .uri("/items/not-a-uuid") + .uri("/api/v1/items/not-a-uuid") .body(Body::empty()) .unwrap(), ) From aba54a0a801c879e24075604c3e30a62f47c8a69 Mon Sep 17 00:00:00 2001 From: "Kawin.V" Date: Sun, 30 Aug 2026 15:56:52 +0700 Subject: [PATCH 2/2] feat(ci): add CD and security workflows, codecov upload (parity with go template) - cd.yml: multi-arch (amd64/arm64) server + migrate images to ghcr.io on v* tags. Unlike the Go CD single buildx call, each platform is built natively (arm64 on an arm64 runner) with per-arch musl build-args, pushed by digest, then merged into manifest lists. - security.yml: weekly Trivy SARIF scan + cargo-audit (RustSec, the go template's govulncheck counterpart). - ci.yml: upload lcov coverage to codecov (non-blocking), matching the go CI's codecov step. - All actions pinned by SHA, consistent with the repo's existing CI. --- .github/workflows/cd.yml | 203 +++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 5 + .github/workflows/security.yml | 71 ++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/security.yml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..462fa13 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,203 @@ +# ============================================================================= +# GitHub Actions CD Workflow +# ============================================================================= +# This workflow handles continuous deployment of container images. +# +# Triggers: +# - Push of a version tag (v*) +# +# Jobs: +# - build: server + migrate images to ghcr.io, one job per platform, +# pushed by digest +# - merge: assembles the per-platform digests into multi-arch +# (amd64/arm64) manifest lists +# +# Notes (why this differs from the Go template's CD): +# - The Go Containerfile cross-compiles trivially (CGO_ENABLED=0) in a +# single buildx call over both platforms. Here the Rust musl +# cross-compiler setup (base image, target triple, linker name) is +# arch-specific, and build-push-action cannot vary build-args per +# platform in one invocation. So each platform is built natively (the +# arm64 leg runs on a native arm64 runner — QEMU emulation is +# prohibitively slow for Rust release builds), pushed by digest, and +# the manifest list is assembled afterwards. +# - Compilation happens inside the Containerfile, so no Rust toolchain +# is installed on the runner. +# ============================================================================= + +name: CD + +on: + push: + tags: ["v*"] + +env: + REGISTRY: ghcr.io + +jobs: + # ========================================================================== + # Job: Build Container Images (per platform, by digest) + # ========================================================================== + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + permissions: + packages: write + contents: read + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + platform_id: linux-amd64 + runner: ubuntu-latest + rust_musl_tag: x86_64-musl + musl_target: x86_64-unknown-linux-musl + musl_gcc: x86_64-unknown-linux-musl-gcc + musl_target_underscore: x86_64_unknown_linux_musl + musl_target_upper: X86_64_UNKNOWN_LINUX_MUSL + - platform: linux/arm64 + platform_id: linux-arm64 + runner: ubuntu-24.04-arm + rust_musl_tag: aarch64-musl + musl_target: aarch64-unknown-linux-musl + musl_gcc: aarch64-unknown-linux-musl-gcc + musl_target_underscore: aarch64_unknown_linux_musl + musl_target_upper: AARCH64_UNKNOWN_LINUX_MUSL + outputs: + version: ${{ steps.meta.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Extract metadata + id: meta + run: | + VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev") + COMMIT_SHA=$(git rev-parse --short HEAD) + BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + { + echo "version=${VERSION}" + echo "commit_sha=${COMMIT_SHA}" + echo "build_time=${BUILD_TIME}" + } >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push server image by digest + id: build-server + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./Containerfile + platforms: ${{ matrix.platform }} + push: true + outputs: type=image,name=${{ env.REGISTRY }}/${{ github.repository }},push-by-digest=true,name-canonical=true + build-args: | + RUST_MUSL_TAG=${{ matrix.rust_musl_tag }} + MUSL_TARGET=${{ matrix.musl_target }} + MUSL_GCC=${{ matrix.musl_gcc }} + MUSL_TARGET_UNDERSCORE=${{ matrix.musl_target_underscore }} + MUSL_TARGET_UPPER=${{ matrix.musl_target_upper }} + VERSION=${{ steps.meta.outputs.version }} + COMMIT_SHA=${{ steps.meta.outputs.commit_sha }} + BUILD_TIME=${{ steps.meta.outputs.build_time }} + cache-from: type=gha,scope=${{ matrix.platform }}-server + cache-to: type=gha,mode=max,scope=${{ matrix.platform }}-server + + - name: Build and push migrate image by digest + id: build-migrate + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./Containerfile.migrate + platforms: ${{ matrix.platform }} + push: true + outputs: type=image,name=${{ env.REGISTRY }}/${{ github.repository }}-migrate,push-by-digest=true,name-canonical=true + build-args: | + RUST_MUSL_TAG=${{ matrix.rust_musl_tag }} + MUSL_TARGET=${{ matrix.musl_target }} + MUSL_GCC=${{ matrix.musl_gcc }} + MUSL_TARGET_UNDERSCORE=${{ matrix.musl_target_underscore }} + MUSL_TARGET_UPPER=${{ matrix.musl_target_upper }} + VERSION=${{ steps.meta.outputs.version }} + COMMIT_SHA=${{ steps.meta.outputs.commit_sha }} + BUILD_TIME=${{ steps.meta.outputs.build_time }} + cache-from: type=gha,scope=${{ matrix.platform }}-migrate + cache-to: type=gha,mode=max,scope=${{ matrix.platform }}-migrate + + - name: Export digests + run: | + mkdir -p /tmp/digests/${{ matrix.platform_id }} + echo "${{ steps.build-server.outputs.digest }}" | sed 's/^sha256://' > "/tmp/digests/${{ matrix.platform_id }}/server" + echo "${{ steps.build-migrate.outputs.digest }}" | sed 's/^sha256://' > "/tmp/digests/${{ matrix.platform_id }}/migrate" + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: digests-${{ matrix.platform_id }} + path: /tmp/digests/* + retention-days: 1 + if-no-files-found: error + + # ========================================================================== + # Job: Merge Manifest Lists + # ========================================================================== + merge: + name: Merge Manifests + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: build + permissions: + packages: write + contents: read + steps: + - name: Download digests + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create multi-arch manifest lists + run: | + VERSION="${{ needs.build.outputs.version }}" + cd /tmp/digests + for component in server migrate; do + image="${{ env.REGISTRY }}/${{ github.repository }}" + if [ "$component" = migrate ]; then image="${image}-migrate"; fi + refs=() + while IFS= read -r digest; do + refs+=("$image@sha256:$digest") + done < <(cat "linux-amd64/$component" "linux-arm64/$component") + docker buildx imagetools create \ + -t "$image:$VERSION" \ + -t "$image:latest" \ + -t "$image:${{ github.ref_name }}" \ + "${refs[@]}" + done + + - name: Inspect resulting manifests + run: | + docker buildx imagetools inspect "${{ env.REGISTRY }}/${{ github.repository }}:latest" + docker buildx imagetools inspect "${{ env.REGISTRY }}/${{ github.repository }}-migrate:latest" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa04580..0e659fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,11 @@ jobs: env: THRESHOLD: "60" run: cargo llvm-cov report --fail-under-lines "$THRESHOLD" + - uses: codecov/codecov-action@e53489f4d376d79066609109e7a95a29eb3740b1 # v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.lcov + fail_ci_if_error: false - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: coverage-report diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..50bc05b --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,71 @@ +# ============================================================================= +# GitHub Actions Security Workflow +# ============================================================================= +# This workflow runs weekly security scans to detect vulnerabilities. +# +# Triggers: +# - Weekly schedule (Monday 06:00 UTC) +# - Manual dispatch +# +# Jobs: +# - trivy-scan: Full filesystem scan with Trivy +# - cargo-audit: Rust dependency vulnerability check (RustSec advisory db) +# ============================================================================= + +name: Weekly Security Scan + +on: + schedule: + - cron: '0 6 * * 1' # Monday 06:00 UTC + workflow_dispatch: + +jobs: + # ========================================================================== + # Job: Trivy Scan + # ========================================================================== + trivy-scan: + name: Trivy Scan + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@1f0aa582c8c8f5f7639610d6d38baddfea4fdcee # v0.9.2 + with: + scan-type: 'fs' + scan-ref: '.' + severity: 'LOW,MEDIUM,HIGH,CRITICAL' + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy results to GitHub Security tab + uses: github/codeql-action/upload-sarif@6f530319d8c989665d0835536ec9571735fd2008 # v4 + with: + sarif_file: 'trivy-results.sarif' + + # ========================================================================== + # Job: Cargo Audit + # ========================================================================== + # Rust's counterpart to the Go template's govulncheck: cargo-audit checks + # Cargo.lock against the RustSec advisory database. + cargo-audit: + name: Cargo Audit + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Install cargo-audit + uses: taiki-e/install-action@6a241a1328ca6173d49760e1de6702cd5e52ef94 # cargo-audit + with: + tool: cargo-audit + + - name: Run cargo audit + run: cargo audit \ No newline at end of file