| .vscode | ||
| db | ||
| dev-db | ||
| frontend | ||
| src | ||
| .env | ||
| .gitignore | ||
| build.rs | ||
| Cargo.lock | ||
| Cargo.toml | ||
| config.example.yml | ||
| README.md | ||
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
dbmate n create_thingsand write the migration, thendbmate upsrc/core/models/thing.rs: the domain typessrc/core/repositories/things_repository.rs: the trait, added toDatabaseRepositorysrc/services/database/things.rs: the sqlx implementationsrc/core/controller/things.rs: the logic, plus aThingsControllerErrorif neededsrc/api/things.rs: the handlers and their docs, mounted insrc/api/mod.rscd frontend && npm run openapito 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.
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 fromservices/api/, which returns a vue-query query or mutation. Caching, loading and error states come for free. - mutations update the cache in
onSuccessso the ui reacts immediately. - texts live in
locales/*.ymland 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.