This is just a test repository for using Docker with Rust.
A simple REST API for managing users, built with Rust and containerized with Docker. It uses Axum as the web framework, rusqlite for SQLite storage, provides basic CRUD operations, and validates every input at the HTTP boundary — no request reaches the database without passing the validation rules and the unified JSON error contract.
- Rust – systems programming language
- Axum – lightweight web framework
- SQLite – embedded database (via rusqlite)
- validator – declarative input validation on DTOs
- thiserror – error taxonomy behind the unified JSON error contract
- Docker – containerization
- GitHub Actions – CI (fmt, clippy, tests, release build, Docker image build)
- Build stage base image:
rust:latest - Runtime stage base image:
debian:bookworm-slim - Operating system: Debian 12 (Bookworm)
- Rust version: latest
- Container port: 5070
The final Docker image contains only the compiled binary and necessary runtime libraries, keeping it lightweight.
simple_api_rust/
├── src/
│ ├── main.rs bootstrap: env, logging, bind
│ ├── lib.rs module declarations (lib+bin pattern for testability)
│ ├── routes.rs single source of routes + body limit
│ ├── error.rs unified JSON error contract (ApiError)
│ ├── models.rs DTOs + validation rules
│ ├── handlers.rs orchestration only
│ ├── db.rs data access (SQL)
│ └── validation/ boundary extractors
│ ├── mod.rs
│ ├── json.rs ValidatedJson: parse + domain rules
│ ├── query.rs ValidatedQuery: typed query strings
│ └── types.rs UserId, SortKey, SortedUsersQuery
├── tests/
│ ├── db_tests.rs data layer tests
│ ├── validation_tests.rs domain rule tests
│ └── api_tests.rs full HTTP chain tests
├── .github/workflows/ci.yml
├── Cargo.toml
├── Cargo.lock
├── Dockerfile
├── .dockerignore
└── .gitignore
Architecture details, design decisions and the fix log live in internals.md.
- Docker installed on your system
- (Optional, for local development) Rust toolchain
-
Build the Docker image (from the project root where the Dockerfile is located):
sudo docker build -t simple-api-rust . -
Run the container (detached mode, mapping host port 5000 to container port 5070, with a named volume for persistent SQLite data):
sudo docker run -d -p 5000:5070 -v simple_api_rust_db:/app/data simple-api-rust
The database path defaults to
data/app.dband can be overridden with theDB_PATHenvironment variable (-e DB_PATH=/app/data/custom.db).The container will be assigned a random numeric ID and a random name. To see the running container and its ID:
sudo docker ps
The output will show a
CONTAINER IDcolumn. Use that ID for subsequent commands (e.g., logs, stop, rm). Example output:CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES a1b2c3d4e5f6 simple-api-rust "simple-api-rust" 5 seconds ago Up 4 seconds 0.0.0.0:5000->5070/tcp charming_curie -
Check logs (replace
<container-id>with the actual ID fromdocker ps):sudo docker logs <container-id>
Base URL: http://localhost:5000
- Method:
GET - Path:
/users - Response: JSON array of user objects (
id,name,age)
Example:
curl http://localhost:5000/usersResponse:
[
{"id": 1, "name": "Ali", "age": 30},
{"id": 2, "name": "Sara", "age": 25}
]- Method:
GET - Path:
/users/sorted - Query parameters:
key(optional, default:id) – sort field: exactly one ofid,name, oragereverse(optional, default:false) – only the literal stringstrueorfalse(case-sensitive)
Any other value for key or reverse is rejected with 400 — nothing is silently ignored.
Example (sort by age descending):
curl "http://localhost:5000/users/sorted?key=age&reverse=true"- Method:
POST - Path:
/users - Request body: JSON with
name(string) andage(integer) — unknown fields are rejected - Response: Created user object with
id
Example:
curl -X POST http://localhost:5000/users \
-H "Content-Type: application/json" \
-d '{"name":"Ali","age":30}'Response:
{"id": 1, "name": "Ali", "age": 30}Invalid example (age out of range):
curl -X POST http://localhost:5000/users \
-H "Content-Type: application/json" \
-d '{"name":"Ali","age":15}'Response (422 Unprocessable Entity):
{
"error": {
"code": "VALIDATION_FAILED",
"message": "validation failed",
"details": [
{"field": "age", "message": "age must be between 16 and 88"}
]
}
}- Method:
PUT - Path:
/users/{id}—idmust be a positive integer (≥ 1), otherwise400 - Request body: JSON containing at least one of
nameorage(both validated by the same rules as create) - Response: Success message or
404if user not found
Example (update name only):
curl -X PUT http://localhost:5000/users/1 \
-H "Content-Type: application/json" \
-d '{"name":"Ali Rezaei"}'Response:
"User updated"- Method:
DELETE - Path:
/users/{id}—idmust be a positive integer (≥ 1), otherwise400 - Response: Success message or
404if user not found
Example:
curl -X DELETE http://localhost:5000/users/1Response:
"User deleted"All inputs are validated at the HTTP boundary before any handler or database code runs.
| Input | Rule | On failure |
|---|---|---|
name |
English letters, spaces, hyphens and apostrophes only; must start and end with a letter; no consecutive separators; 1–100 characters; not blank | 422 |
age |
Integer between 16 and 88 (inclusive) | 422 |
| Update payload | At least one of name or age |
422 |
| Unknown JSON fields | Rejected | 422 |
Malformed JSON / wrong Content-Type |
Rejected | 400 / 415 |
| Request body size | At most 16 KiB | 413 |
Path id |
Positive integer (≥ 1) |
400 |
key query param |
Exactly id, name or age |
400 |
reverse query param |
Exactly true or false (case-sensitive) |
400 |
Names are stored trimmed: " Ali " is accepted and stored as "Ali".
Every error response uses the same JSON shape:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "...",
"details": [{"field": "name", "message": "..."}]
}
}| Code | Meaning | Typical status |
|---|---|---|
VALIDATION_FAILED |
A domain rule was violated | 422 |
REQUEST_REJECTED |
Structural problem: bad JSON, bad params, oversized body, wrong Content-Type |
400 / 413 / 415 / 422 |
NOT_FOUND |
Resource does not exist | 404 |
INTERNAL |
Unexpected server error | 500 |
details is always an array (empty when there is nothing to report).
Internal error details are logged server-side only — they are never sent to the client.
cargo run # start the API on 0.0.0.0:5070
cargo fmt --all # format (run before every push)
cargo clippy --all-targets -- -D warnings # lint
cargo test --all # db + validation + api test suitesThe local pre-push sequence mirrors CI exactly: if these three commands
(fmt, clippy, test) are green locally, CI is green too.
Every push to any branch (and every PR targeting main) runs two jobs:
- quality —
cargo fmt --check,cargo clippy -D warnings,cargo test --all,cargo build --release - docker — builds the Docker image (build only, never published) once quality passes
See the Actions tab of the repository for run history.
-
The root path
/is not defined and will return404. -
The SQLite database is stored in a Docker volume for persistence.
-
To stop and remove the container (replace
<container-id>with the actual ID):sudo docker stop <container-id> sudo docker rm <container-id>