cargagep-v2/README.md
Antoine Pelletier 51df9e75ad wip
2026-08-25 10:38:05 +02:00

9.1 KiB

CarGAGEP

Cargo bike reservation service for AGEPoly: a Rust backend (axum + sqlx + aide) serving a Vue 3 frontend (TypeScript + vue-query + shadcn-vue), on PostgreSQL with dbmate migrations.

The data model is in place (users, bikes, reservations) down to the sqlx layer; the api routes and the frontend views are not written yet.

What you get

  • Layered backend (api / core / services), see Backend
  • An OpenAPI document generated from the code, and TypeScript types generated from it: the frontend cannot call an endpoint that does not exist
  • i18n (fr/en) on both sides, localized strings stored as JSONB
  • dev-db/: postgres + adminer in docker for development
  • Tailwind 4 and the shadcn-vue components already vendored in frontend/src/components/ui
  • A single binary in production: the backend serves the built frontend

Users are mirrored from AGEPoly's OIDC provider (Whiskey): users.oidc_sub is the identity, and a login upserts the row from the claims of the token. Whiskey calls the units groups and sends them in the groups claim; each one is a row in units, shared by everybody who belongs to it, and the memberships are rewritten at every login — so a user removed from a group there loses it here too. A group we have never seen is created on the spot rather than rejected: an unknown unit must never break a login.

admin is ours, and is never touched by a login.

In a debug build, dev_users from the configuration can be logged in through POST /api/login without going through the provider — see the login page.

/reservations needs a session and /admin needs an admin. The router guards are a convenience only: every protected route answers 401 or 403 on its own, whatever the frontend does.

Getting started

# 1. Database
cd dev-db && docker compose up -d && cd ..          # or use a local postgres
psql -h localhost -U postgres -c 'CREATE DATABASE cargagep'
dbmate up
psql "$(grep DATABASE_URL .env | cut -d= -f2-)" -f db/seed.sql   # optional demo data

# 2. Configure the app
cp config.example.yml config.yml

# 3. Run the backend (port 3000)
cargo run

# 4. Run the frontend (port 5000, proxies /api to the backend)
cd frontend && npm install && npm run dev

Open http://localhost:5000. The api documentation is on http://localhost:3000/api/docs.

config.yml is gitignored: it is where the secrets go. config.example.yml documents every key, keep it up to date. Every value can also come from the environment (APP__POSTGRES__PASSWORD=...), which is how the app is configured in production.

Layout

├── src/                  backend
│   ├── api/              http layer: routes, extractors, OpenAPI
│   ├── core/             business logic, independent of axum and sqlx
│   │   ├── controller/   what the app can do
│   │   ├── models/       domain types
│   │   └── repositories/ traits describing what the core needs from the storage
│   ├── services/         implementations of the repositories (postgres/sqlx)
│   └── utils/            configuration
├── db/
│   ├── migrations/       dbmate migrations
│   ├── schema.sql        dump regenerated by dbmate, do not edit by hand
│   └── seed.sql          development data
├── dev-db/               postgres + adminer for development
└── frontend/
    └── src/
        ├── components/    shared components (ui/ = shadcn-vue)
        ├── views/         one component per route
        ├── router/        route table
        ├── services/      api/ (one file per domain area) + i18n
        ├── lib/api.d.ts   generated from the backend, never edited by hand
        ├── utils/types.ts shorthands over the generated schemas
        └── locales/       fr.yml / en.yml

Backend

Requests flow through three layers, each one only knowing the next:

api (axum handler)  ->  core/controller  ->  core/repositories  ->  services/database
   status codes           business logic         trait                sqlx + postgres
                          domain models

The point of the repository traits is that the core never depends on sqlx: you can add another implementation (a mock in tests, another storage) without touching the logic. Handlers stay thin — extract the controller, call it, map the error to a status code — and their OpenAPI documentation sits right next to them (fn *_docs).

Authorization is a type, not a check

There are four controllers, each dereferencing into the one above it:

AnonAppController        anybody: reading the fleet, the calendar, the login routes
  └─ AppController       a logged in user
       ├─ ManagerAppController   a member of one unit, acting for that unit
       └─ AdminAppController     an admin

A handler declares what it needs in its signature. AnonAppController always extracts; AppController resolves the session cookie and answers 401 on its own, so a protected route cannot be left open by forgetting a check. Going further down is explicit and fallible — try_into_manager(unit) and try_into_admin(), wrapped by api::helpers so a refusal becomes a 403.

Sessions are handled by axum-login on top of tower-sessions, wired in api/mod.rs. The store is in memory: everybody is logged out when the backend restarts. Swap it for a persistent store if that becomes a problem.

Adding an entity

  1. dbmate n create_things and write the migration, then dbmate up
  2. src/core/models/thing.rs: the domain types
  3. src/core/repositories/things_repository.rs: the trait, added to DatabaseRepository
  4. src/services/database/things.rs: the sqlx implementation
  5. src/core/controller/things.rs: the logic, plus a ThingsControllerError if needed
  6. src/api/things.rs: the handlers and their docs, mounted in src/api/mod.rs
  7. cd frontend && npm run openapi to regenerate the types, then write the service and the view

Steps 1 to 5 are done for users, bikes and reservations; steps 6 and 7 are not. Until an api route exists, the whole stack is unused, which is why src/main.rs carries a crate-level #![allow(dead_code)] — delete it once the handlers are written.

Request bodies are best kept separate from the domain models (an api/models.rs holding the ...Api structs and their Into<Domain> impls), so that the public contract does not change every time a domain model does.

sqlx and compilation

query!/query_as! check the sql against a real database at compile time, so the development database must be up and migrated for cargo build to work. DATABASE_URL is read from .env, which is gitignored: copy .env.example to .env and fill in the secrets there — never commit them.

To build without a database (CI, docker image), commit the offline data:

cargo install sqlx-cli
cargo sqlx prepare      # writes .sqlx/, commit it

Migrations

dbmate n add_something   # creates db/migrations/<timestamp>_add_something.sql
dbmate up                # applies, and regenerates db/schema.sql
dbmate rollback          # undoes the last migration (write your `migrate:down`!)

Frontend

npm run dev          # dev server on :5000, /api proxied to the backend on :3000
npm run build        # type-check + build into dist/
npm run type-check
npm run lint
npm run format
npm run openapi      # regenerate src/lib/api.d.ts from the running backend

Rules of thumb:

  • views never call fetch: they use a hook from services/api/, which returns a vue-query query or mutation. Caching, loading and error states come for free.
  • mutations update the cache in onSuccess so the ui reacts immediately.
  • texts live in locales/*.yml and are used through $t('key'), never hardcoded.
  • add a shadcn-vue component with npx shadcn-vue@latest add <name>.

Production

cargo build --release, npm run build, then point frontend_dir at the built frontend/dist: the backend serves the static files and falls back on index.html so the vue router keeps working on a page reload. Only one process to deploy.

Data model

units ──< units_users >── users     a unit (a Whiskey group) has many members,
  │                         │         and a user belongs to many units
  │                         ├──< reservations_users >── reservations
  │                         └──── reservations.requester_id
  └──── reservations.unit_id          exactly one unit borrows the bikes

reservations ──< reservations_bikes >── bikes

A reservation moves through a state machine, enforced in ReservationsController::set_reservation_status and documented on core::models::reservation::ReservationStatus:

requested ──▶ refused
    │
    ▼
approved ──▶ cancelled
    │            ▲
    ▼            │
 ongoing ────────┘
    │
    ▼
archived

refused, cancelled and archived are final. A bike is in_service or out_of_service; a bike that has ever been booked cannot be deleted (ON DELETE RESTRICT), take it out of service instead.