Ver código fonte

Initial Axum + Vue fullstack template.

Workspace starter for RustRover: Axum, sqlx, PostgreSQL, Vue 3,
TypeScript, Tailwind, shared TOML config, request logging, and
OpenAPI-generated frontend client.
master
kashiro 1 semana atrás
commit
6442cd5045
63 arquivos alterados com 7502 adições e 0 exclusões
  1. +15
    -0
      .editorconfig
  2. +10
    -0
      .env.example
  3. +3
    -0
      .gitea/template
  4. +19
    -0
      .gitignore
  5. +2534
    -0
      Cargo.lock
  6. +31
    -0
      Cargo.toml
  7. +21
    -0
      LICENSE
  8. +72
    -0
      README.md
  9. +41
    -0
      apps/server/Cargo.toml
  10. +11
    -0
      apps/server/migrations/0001_init.sql
  11. +26
    -0
      apps/server/src/bin/export-openapi.rs
  12. +192
    -0
      apps/server/src/config.rs
  13. +18
    -0
      apps/server/src/db.rs
  14. +87
    -0
      apps/server/src/error.rs
  15. +34
    -0
      apps/server/src/handlers/health.rs
  16. +174
    -0
      apps/server/src/handlers/items.rs
  17. +2
    -0
      apps/server/src/handlers/mod.rs
  18. +12
    -0
      apps/server/src/lib.rs
  19. +60
    -0
      apps/server/src/main.rs
  20. +1
    -0
      apps/server/src/middleware/mod.rs
  21. +36
    -0
      apps/server/src/middleware/request_log.rs
  22. +14
    -0
      apps/server/src/models/health.rs
  23. +60
    -0
      apps/server/src/models/item.rs
  24. +3
    -0
      apps/server/src/models/mod.rs
  25. +48
    -0
      apps/server/src/models/pagination.rs
  26. +105
    -0
      apps/server/src/routes/mod.rs
  27. +36
    -0
      apps/server/src/state.rs
  28. +38
    -0
      apps/server/src/telemetry.rs
  29. +17
    -0
      apps/server/tests/config.rs
  30. +10
    -0
      apps/server/tests/openapi.rs
  31. +4
    -0
      apps/web/.gitignore
  32. +18
    -0
      apps/web/index.html
  33. +2128
    -0
      apps/web/package-lock.json
  34. +26
    -0
      apps/web/package.json
  35. +30
    -0
      apps/web/src/App.vue
  36. +79
    -0
      apps/web/src/api/client.ts
  37. +128
    -0
      apps/web/src/api/generated.ts
  38. +41
    -0
      apps/web/src/components/HealthBadge.vue
  39. +131
    -0
      apps/web/src/components/ItemBoard.vue
  40. +6
    -0
      apps/web/src/main.ts
  41. +34
    -0
      apps/web/src/style.css
  42. +7
    -0
      apps/web/src/vite-env.d.ts
  43. +24
    -0
      apps/web/tsconfig.app.json
  44. +7
    -0
      apps/web/tsconfig.json
  45. +19
    -0
      apps/web/tsconfig.node.json
  46. +125
    -0
      apps/web/vite.config.ts
  47. +7
    -0
      cargo-generate.toml
  48. +39
    -0
      config/default.toml
  49. +7
    -0
      config/development.toml
  50. +17
    -0
      config/production.toml
  51. +30
    -0
      docs/architecture.md
  52. +8
    -0
      docs/decisions/0001-stack.md
  53. +47
    -0
      docs/runbook.md
  54. +19
    -0
      infra/compose/docker-compose.yml
  55. +22
    -0
      infra/docker/nginx.conf
  56. +18
    -0
      infra/docker/server.Dockerfile
  57. +14
    -0
      infra/docker/web.Dockerfile
  58. +32
    -0
      justfile
  59. +11
    -0
      packages/contracts/README.md
  60. +458
    -0
      packages/contracts/openapi.json
  61. +3
    -0
      rust-toolchain.toml
  62. +5
    -0
      rustfmt.toml
  63. +228
    -0
      scripts/generate-api.mjs

+ 15
- 0
.editorconfig Ver arquivo

@@ -0,0 +1,15 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true

[*.{ts,vue,js,json,yml,yaml,css,html}]
indent_size = 2

[*.md]
trim_trailing_whitespace = false

+ 10
- 0
.env.example Ver arquivo

@@ -0,0 +1,10 @@
# Copy to .env in the workspace root. dotenv is optional — TOML + APP__* win.

APP_ENV=development

# Overrides config/*.toml. Nested keys use a double underscore.
# APP__SERVER__PORT=8080
# APP__DATABASE__URL=postgres://app:app@127.0.0.1:5432/app

# Used by sqlx-cli if you install it.
DATABASE_URL=postgres://app:app@127.0.0.1:5432/app

+ 3
- 0
.gitea/template Ver arquivo

@@ -0,0 +1,3 @@
# Gitea template-repository marker.
# Creating a new repo from this template copies the tree as-is.
# RustRover also consumes this repo via cargo-generate (Git URL).

+ 19
- 0
.gitignore Ver arquivo

@@ -0,0 +1,19 @@
/target
**/*.rs.bk
.idea/
.vscode/
.DS_Store
.env
config/local.toml
*.swp

# Node
apps/web/node_modules/
apps/web/dist/
apps/web/.vite/

# sqlx offline data is committed when present
# .sqlx/

# Generated lock noise from local experiments
.direnv/

+ 2534
- 0
Cargo.lock
Diferenças do arquivo suprimidas por serem muito extensas
Ver arquivo


+ 31
- 0
Cargo.toml Ver arquivo

@@ -0,0 +1,31 @@
[workspace]
resolver = "3"
members = ["apps/server"]

[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT"
repository = "https://ikibani.com/kashiro/rust-template"

[workspace.dependencies]
anyhow = "1"
axum = { version = "0.8", features = ["json", "macros", "tokio"] }
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
figment = { version = "0.10", features = ["toml", "env"] }
http = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "postgres", "migrate", "uuid", "chrono", "macros", "derive"] }
thiserror = "2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "request-id", "util", "compression-gzip"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
utoipa-axum = "0.2"
utoipa-swagger-ui = { version = "9", features = ["axum"] }
uuid = { version = "1", features = ["serde", "v4"] }
validator = { version = "0.20", features = ["derive"] }

+ 21
- 0
LICENSE Ver arquivo

@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

+ 72
- 0
README.md Ver arquivo

@@ -0,0 +1,72 @@
# rust-template

RustRover-ready fullstack starter: Axum + PostgreSQL + sqlx on the backend, Vue 3 + TypeScript + Tailwind 4 on the frontend.

The backend route table is the API contract. `utoipa-axum` builds OpenAPI from the same handlers Axum serves. `just generate-api` writes `packages/contracts/openapi.json` and a typed Vue client. Vite reads `config/*.toml` and reverse-proxies `/api` and `/health` to the server bind address.

## Layout

```
config/ shared TOML (server + Vite)
apps/server/ Axum, sqlx, request logging, OpenAPI
apps/web/ Vue 3 + TS + Tailwind
packages/contracts/ generated openapi.json
scripts/generate-api.mjs typed client from that spec
infra/compose/ local Postgres
```

## Run it

```bash
cp .env.example .env
docker compose -f infra/compose/docker-compose.yml up -d postgres
cargo run -p server
```

```bash
cd apps/web && npm install && npm run dev
```

- UI: http://127.0.0.1:5173
- API: http://127.0.0.1:8080
- Swagger: http://127.0.0.1:8080/api/docs

Vite binds `127.0.0.1` on purpose. `localhost` can resolve to `::1` and miss the proxy.

## RustRover

Open the repo root. Run `cargo run -p server` with `APP_ENV=development`.

To use this as a New Project template: Settings → Languages & Frameworks → Rust → custom cargo-generate template → paste the Git URL of this repo.

## Config

Load order:

1. `config/default.toml`
2. `config/{APP_ENV}.toml`
3. `APP__SECTION__KEY` environment variables

```bash
APP__SERVER__PORT=9000 cargo run -p server
```

Both processes honor `APP_CONFIG_DIR` if you need the files somewhere else.

## Add an endpoint

1. Handler + `#[utoipa::path(...)]`
2. `.routes(routes!(your_handler))` in `apps/server/src/routes/mod.rs`
3. `just generate-api`
4. Import the new function from `apps/web/src/api/generated.ts`

Request logs include method, path, status, latency, and `X-Request-Id`.

## Useful commands

```bash
just server
just web
just generate-api
just check
```

+ 41
- 0
apps/server/Cargo.toml Ver arquivo

@@ -0,0 +1,41 @@
[package]
name = "server"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Axum API server"

[[bin]]
name = "server"
path = "src/main.rs"

[[bin]]
name = "export-openapi"
path = "src/bin/export-openapi.rs"

[dependencies]
anyhow.workspace = true
axum.workspace = true
chrono.workspace = true
dotenvy.workspace = true
figment.workspace = true
http.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
thiserror.workspace = true
tokio.workspace = true
tower.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
utoipa.workspace = true
utoipa-axum.workspace = true
utoipa-swagger-ui.workspace = true
uuid.workspace = true
validator.workspace = true

[dev-dependencies]
http-body-util = "0.1"
tower = { version = "0.5", features = ["util"] }

+ 11
- 0
apps/server/migrations/0001_init.sql Ver arquivo

@@ -0,0 +1,11 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE IF NOT EXISTS items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS items_created_at_idx ON items (created_at DESC);

+ 26
- 0
apps/server/src/bin/export-openapi.rs Ver arquivo

@@ -0,0 +1,26 @@
//! Dump the live Axum OpenAPI document to `packages/contracts/openapi.json`.
//!
//! Run via `just generate-api` (or `cargo run -p server --bin export-openapi`).
//! The Vue client is generated from that file so frontend calls cannot drift
//! from the backend route table.

use std::fs;
use std::io::Write;

use server::config;
use server::routes;

fn main() -> anyhow::Result<()> {
let spec = routes::openapi_spec();
let json = spec.to_pretty_json().map_err(|err| anyhow::anyhow!(err))?;

let dest = config::discover_workspace_root().join("packages/contracts/openapi.json");
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
let mut file = fs::File::create(&dest)?;
file.write_all(json.as_bytes())?;
file.write_all(b"\n")?;
println!("wrote {}", dest.display());
Ok(())
}

+ 192
- 0
apps/server/src/config.rs Ver arquivo

@@ -0,0 +1,192 @@
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::Duration;

use figment::Figment;
use figment::providers::{Env, Format, Serialized, Toml};
use serde::{Deserialize, Serialize};

/// Layered application config.
///
/// Load order (later wins):
/// 1. struct defaults
/// 2. `config/default.toml`
/// 3. `config/{APP_ENV}.toml` (`development` when unset)
/// 4. `APP__SECTION__KEY` environment variables
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub app: AppSection,
pub server: ServerSection,
pub frontend: FrontendSection,
pub database: DatabaseSection,
pub logging: LoggingSection,
pub cors: CorsSection,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSection {
pub name: String,
pub description: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerSection {
pub host: String,
pub port: u16,
pub public_url: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrontendSection {
pub host: String,
pub port: u16,
pub public_url: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseSection {
pub url: String,
pub max_connections: u32,
pub min_connections: u32,
pub acquire_timeout_secs: u64,
pub idle_timeout_secs: u64,
pub run_migrations: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingSection {
pub level: String,
pub format: String,
pub filter: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorsSection {
pub allowed_origins: Vec<String>,
pub allow_credentials: bool,
}

impl Default for AppConfig {
fn default() -> Self {
Self {
app: AppSection {
name: "rust-template".into(),
description: "Axum + Vue + PostgreSQL fullstack app".into(),
},
server: ServerSection {
host: "127.0.0.1".into(),
port: 8080,
public_url: "http://127.0.0.1:8080".into(),
},
frontend: FrontendSection {
host: "127.0.0.1".into(),
port: 5173,
public_url: "http://127.0.0.1:5173".into(),
},
database: DatabaseSection {
url: "postgres://app:app@127.0.0.1:5432/app".into(),
max_connections: 10,
min_connections: 1,
acquire_timeout_secs: 5,
idle_timeout_secs: 600,
run_migrations: true,
},
logging: LoggingSection {
level: "info".into(),
format: "pretty".into(),
filter: "server=debug,tower_http=info,sqlx=warn".into(),
},
cors: CorsSection {
allowed_origins: vec!["http://127.0.0.1:5173".into()],
allow_credentials: true,
},
}
}
}

impl AppConfig {
pub fn load() -> Result<Self, Box<figment::Error>> {
let _ = dotenvy::dotenv();
let env = current_env();
let config_dir = discover_config_dir();

Figment::from(Serialized::defaults(Self::default()))
.merge(Toml::file(config_dir.join("default.toml")))
.merge(Toml::file(config_dir.join(format!("{env}.toml"))))
.merge(Env::prefixed("APP_").split("__"))
.extract()
.map_err(Box::new)
}

pub fn socket_addr(&self) -> Result<SocketAddr, std::net::AddrParseError> {
format!("{}:{}", self.server.host, self.server.port).parse()
}

pub fn acquire_timeout(&self) -> Duration {
Duration::from_secs(self.database.acquire_timeout_secs)
}

pub fn idle_timeout(&self) -> Duration {
Duration::from_secs(self.database.idle_timeout_secs)
}
}

pub fn current_env() -> String {
std::env::var("APP_ENV").unwrap_or_else(|_| "development".into())
}

/// Walks from cwd (and the server crate dir) until `config/default.toml` is found.
pub fn discover_config_dir() -> PathBuf {
if let Ok(explicit) = std::env::var("APP_CONFIG_DIR") {
return PathBuf::from(explicit);
}

let mut candidates = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd.clone());
candidates.push(cwd.join("config"));
}
if let Ok(manifest) = std::env::var("CARGO_MANIFEST_DIR") {
let manifest = PathBuf::from(manifest);
candidates.push(manifest.clone());
candidates.push(manifest.join("../../config"));
candidates.push(manifest.join("config"));
}

for candidate in candidates {
if let Ok(canonical) = candidate.canonicalize() {
if looks_like_config_dir(&canonical) {
return canonical;
}
let nested = canonical.join("config");
if looks_like_config_dir(&nested) {
return nested;
}
if let Some(found) = walk_parents_for_config(&canonical) {
return found;
}
} else if looks_like_config_dir(&candidate) {
return candidate;
}
}

PathBuf::from("config")
}

fn looks_like_config_dir(path: &Path) -> bool {
path.join("default.toml").is_file()
}

fn walk_parents_for_config(start: &Path) -> Option<PathBuf> {
for parent in start.ancestors() {
let nested = parent.join("config");
if looks_like_config_dir(&nested) {
return Some(nested);
}
}
None
}

pub fn discover_workspace_root() -> PathBuf {
discover_config_dir().parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."))
}

+ 18
- 0
apps/server/src/db.rs Ver arquivo

@@ -0,0 +1,18 @@
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;

use crate::config::AppConfig;

pub async fn connect(config: &AppConfig) -> Result<PgPool, sqlx::Error> {
PgPoolOptions::new()
.max_connections(config.database.max_connections)
.min_connections(config.database.min_connections)
.acquire_timeout(config.acquire_timeout())
.idle_timeout(config.idle_timeout())
.connect(&config.database.url)
.await
}

pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> {
sqlx::migrate!("./migrations").run(pool).await
}

+ 87
- 0
apps/server/src/error.rs Ver arquivo

@@ -0,0 +1,87 @@
use axum::Json;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use utoipa::ToSchema;

/// Stable JSON error envelope returned by every handler.
#[derive(Debug, Serialize, ToSchema)]
pub struct ErrorBody {
pub error: ErrorDetail,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct ErrorDetail {
pub status: u16,
pub code: &'static str,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("{0}")]
BadRequest(String),
#[error("{0}")]
NotFound(String),
#[error("{0}")]
Conflict(String),
#[error("validation failed: {0}")]
Validation(String),
#[error("database error")]
Database(#[from] sqlx::Error),
#[error(transparent)]
Internal(#[from] anyhow::Error),
}

impl ApiError {
pub fn status(&self) -> StatusCode {
match self {
Self::BadRequest(_) | Self::Validation(_) => StatusCode::BAD_REQUEST,
Self::NotFound(_) => StatusCode::NOT_FOUND,
Self::Conflict(_) => StatusCode::CONFLICT,
Self::Database(sqlx::Error::RowNotFound) => StatusCode::NOT_FOUND,
Self::Database(_) | Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}

pub fn code(&self) -> &'static str {
match self {
Self::BadRequest(_) => "bad_request",
Self::NotFound(_) | Self::Database(sqlx::Error::RowNotFound) => "not_found",
Self::Conflict(_) => "conflict",
Self::Validation(_) => "validation_error",
Self::Database(_) => "database_error",
Self::Internal(_) => "internal_error",
}
}
}

impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = self.status();
if status.is_server_error() {
tracing::error!(error = %self, "request failed");
} else {
tracing::warn!(error = %self, "request rejected");
}

let body = ErrorBody {
error: ErrorDetail {
status: status.as_u16(),
code: self.code(),
message: self.to_string(),
trace_id: None,
},
};

(status, Json(body)).into_response()
}
}

pub fn trace_id_from(headers: &HeaderMap) -> Option<String> {
headers.get("x-request-id").and_then(|value| value.to_str().ok()).map(ToOwned::to_owned)
}

pub type ApiResult<T> = Result<T, ApiError>;

+ 34
- 0
apps/server/src/handlers/health.rs Ver arquivo

@@ -0,0 +1,34 @@
use axum::Json;
use axum::extract::State;

use crate::error::ApiResult;
use crate::models::health::{HealthResponse, ReadyResponse};
use crate::state::AppState;

/// Liveness probe. Process is up.
#[utoipa::path(
get,
path = "/health/live",
tag = "health",
responses(
(status = 200, description = "Process is running", body = HealthResponse)
)
)]
pub async fn live(State(state): State<AppState>) -> Json<HealthResponse> {
Json(HealthResponse { status: "ok", service: state.config().app.name.clone() })
}

/// Readiness probe. Database is reachable.
#[utoipa::path(
get,
path = "/health/ready",
tag = "health",
responses(
(status = 200, description = "Database is reachable", body = ReadyResponse),
(status = 500, description = "Database is unreachable", body = crate::error::ErrorBody)
)
)]
pub async fn ready(State(state): State<AppState>) -> ApiResult<Json<ReadyResponse>> {
sqlx::query_scalar::<_, i32>("SELECT 1").fetch_one(state.db()).await?;
Ok(Json(ReadyResponse { status: "ok", database: "up" }))
}

+ 174
- 0
apps/server/src/handlers/items.rs Ver arquivo

@@ -0,0 +1,174 @@
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use uuid::Uuid;
use validator::Validate;

use crate::error::{ApiError, ApiResult};
use crate::models::item::{CreateItemRequest, Item, ItemPage, ItemResponse, UpdateItemRequest};
use crate::models::pagination::PaginationQuery;
use crate::state::AppState;

/// List items, newest first.
#[utoipa::path(
get,
path = "/api/v1/items",
tag = "items",
params(PaginationQuery),
responses(
(status = 200, description = "Paged item list", body = ItemPage)
)
)]
pub async fn list_items(
State(state): State<AppState>,
Query(query): Query<PaginationQuery>,
) -> ApiResult<Json<ItemPage>> {
let query = query.sanitize();
let total =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM items").fetch_one(state.db()).await?;

let items = sqlx::query_as::<_, Item>(
r#"
SELECT id, name, description, created_at, updated_at
FROM items
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#,
)
.bind(query.limit())
.bind(query.offset())
.fetch_all(state.db())
.await?;

Ok(Json(ItemPage {
items: items.into_iter().map(ItemResponse::from).collect(),
page: query.page,
per_page: query.per_page,
total,
}))
}

/// Fetch a single item.
#[utoipa::path(
get,
path = "/api/v1/items/{id}",
tag = "items",
params(("id" = Uuid, Path, description = "Item id")),
responses(
(status = 200, description = "Item", body = ItemResponse),
(status = 404, description = "Missing item", body = crate::error::ErrorBody)
)
)]
pub async fn get_item(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<ItemResponse>> {
let item = sqlx::query_as::<_, Item>(
r#"
SELECT id, name, description, created_at, updated_at
FROM items
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(state.db())
.await?
.ok_or_else(|| ApiError::NotFound(format!("item {id} not found")))?;

Ok(Json(item.into()))
}

/// Create an item.
#[utoipa::path(
post,
path = "/api/v1/items",
tag = "items",
request_body = CreateItemRequest,
responses(
(status = 201, description = "Created item", body = ItemResponse),
(status = 400, description = "Validation error", body = crate::error::ErrorBody)
)
)]
pub async fn create_item(
State(state): State<AppState>,
Json(body): Json<CreateItemRequest>,
) -> ApiResult<(StatusCode, Json<ItemResponse>)> {
body.validate().map_err(|err| ApiError::Validation(err.to_string()))?;

let item = sqlx::query_as::<_, Item>(
r#"
INSERT INTO items (name, description)
VALUES ($1, $2)
RETURNING id, name, description, created_at, updated_at
"#,
)
.bind(body.name.trim())
.bind(body.description.as_deref())
.fetch_one(state.db())
.await?;

Ok((StatusCode::CREATED, Json(item.into())))
}

/// Replace selected item fields.
#[utoipa::path(
patch,
path = "/api/v1/items/{id}",
tag = "items",
params(("id" = Uuid, Path, description = "Item id")),
request_body = UpdateItemRequest,
responses(
(status = 200, description = "Updated item", body = ItemResponse),
(status = 404, description = "Missing item", body = crate::error::ErrorBody)
)
)]
pub async fn update_item(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateItemRequest>,
) -> ApiResult<Json<ItemResponse>> {
body.validate().map_err(|err| ApiError::Validation(err.to_string()))?;

let item = sqlx::query_as::<_, Item>(
r#"
UPDATE items
SET
name = COALESCE($2, name),
description = COALESCE($3, description),
updated_at = now()
WHERE id = $1
RETURNING id, name, description, created_at, updated_at
"#,
)
.bind(id)
.bind(body.name.as_deref().map(str::trim))
.bind(body.description.as_deref())
.fetch_optional(state.db())
.await?
.ok_or_else(|| ApiError::NotFound(format!("item {id} not found")))?;

Ok(Json(item.into()))
}

/// Delete an item.
#[utoipa::path(
delete,
path = "/api/v1/items/{id}",
tag = "items",
params(("id" = Uuid, Path, description = "Item id")),
responses(
(status = 204, description = "Deleted"),
(status = 404, description = "Missing item", body = crate::error::ErrorBody)
)
)]
pub async fn delete_item(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> {
let result =
sqlx::query("DELETE FROM items WHERE id = $1").bind(id).execute(state.db()).await?;
if result.rows_affected() == 0 {
return Err(ApiError::NotFound(format!("item {id} not found")));
}
Ok(StatusCode::NO_CONTENT)
}

+ 2
- 0
apps/server/src/handlers/mod.rs Ver arquivo

@@ -0,0 +1,2 @@
pub mod health;
pub mod items;

+ 12
- 0
apps/server/src/lib.rs Ver arquivo

@@ -0,0 +1,12 @@
pub mod config;
pub mod db;
pub mod error;
pub mod handlers;
pub mod middleware;
pub mod models;
pub mod routes;
pub mod state;
pub mod telemetry;

pub use config::AppConfig;
pub use state::AppState;

+ 60
- 0
apps/server/src/main.rs Ver arquivo

@@ -0,0 +1,60 @@
use server::config::AppConfig;
use server::db;
use server::routes;
use server::state::AppState;
use server::telemetry;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. Config — default.toml → {APP_ENV}.toml → APP__* env
let config =
AppConfig::load().map_err(|err| anyhow::anyhow!("failed to load config: {err}"))?;

// 2. Logging
telemetry::init(&config)?;
tracing::info!(
app = %config.app.name,
env = %server::config::current_env(),
"starting"
);

// 3. Database + migrations
let pool = db::connect(&config).await?;
if config.database.run_migrations {
db::migrate(&pool).await?;
tracing::info!("migrations applied");
}

// 4. Router
let addr = config.socket_addr()?;
let app = routes::router(AppState::new(config, pool));

// 5. Serve
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(%addr, "listening");
axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()).await?;
Ok(())
}

async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c().await.expect("ctrl+c handler");
};

#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("SIGTERM handler")
.recv()
.await;
};

#[cfg(not(unix))]
let terminate = std::future::pending::<()>();

tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received");
}

+ 1
- 0
apps/server/src/middleware/mod.rs Ver arquivo

@@ -0,0 +1 @@
pub mod request_log;

+ 36
- 0
apps/server/src/middleware/request_log.rs Ver arquivo

@@ -0,0 +1,36 @@
use std::time::Instant;

use axum::extract::Request;
use axum::http::header::HeaderName;
use axum::middleware::Next;
use axum::response::Response;

pub static REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");

/// Structured access log: method, path, status, latency, request id.
pub async fn request_logging(request: Request, next: Next) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_owned();
let request_id = request
.headers()
.get(&REQUEST_ID_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or("-")
.to_owned();

let started = Instant::now();
let response = next.run(request).await;
let status = response.status().as_u16();
let latency_ms = started.elapsed().as_millis() as u64;

tracing::info!(
%method,
path,
status,
latency_ms,
request_id,
"request"
);

response
}

+ 14
- 0
apps/server/src/models/health.rs Ver arquivo

@@ -0,0 +1,14 @@
use serde::Serialize;
use utoipa::ToSchema;

#[derive(Debug, Serialize, ToSchema)]
pub struct HealthResponse {
pub status: &'static str,
pub service: String,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct ReadyResponse {
pub status: &'static str,
pub database: &'static str,
}

+ 60
- 0
apps/server/src/models/item.rs Ver arquivo

@@ -0,0 +1,60 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate;

#[derive(Debug, Clone, FromRow)]
pub struct Item {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct ItemResponse {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct ItemPage {
pub items: Vec<ItemResponse>,
pub page: u32,
pub per_page: u32,
pub total: i64,
}

impl From<Item> for ItemResponse {
fn from(item: Item) -> Self {
Self {
id: item.id,
name: item.name,
description: item.description,
created_at: item.created_at,
updated_at: item.updated_at,
}
}
}

#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateItemRequest {
#[validate(length(min = 1, max = 120))]
pub name: String,
#[validate(length(max = 2000))]
pub description: Option<String>,
}

#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateItemRequest {
#[validate(length(min = 1, max = 120))]
pub name: Option<String>,
#[validate(length(max = 2000))]
pub description: Option<String>,
}

+ 3
- 0
apps/server/src/models/mod.rs Ver arquivo

@@ -0,0 +1,3 @@
pub mod health;
pub mod item;
pub mod pagination;

+ 48
- 0
apps/server/src/models/pagination.rs Ver arquivo

@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};

#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct PaginationQuery {
/// 1-based page index.
#[serde(default = "default_page")]
pub page: u32,
/// Page size (max 100).
#[serde(default = "default_per_page")]
pub per_page: u32,
}

fn default_page() -> u32 {
1
}

fn default_per_page() -> u32 {
20
}

impl PaginationQuery {
pub fn sanitize(self) -> Self {
Self { page: self.page.max(1), per_page: self.per_page.clamp(1, 100) }
}

pub fn limit(&self) -> i64 {
i64::from(self.per_page)
}

pub fn offset(&self) -> i64 {
i64::from(self.page.saturating_sub(1)) * self.limit()
}
}

#[derive(Debug, Serialize, ToSchema)]
pub struct PagedResponse<T> {
pub items: Vec<T>,
pub page: u32,
pub per_page: u32,
pub total: i64,
}

impl<T> PagedResponse<T> {
pub fn new(items: Vec<T>, page: u32, per_page: u32, total: i64) -> Self {
Self { items, page, per_page, total }
}
}

+ 105
- 0
apps/server/src/routes/mod.rs Ver arquivo

@@ -0,0 +1,105 @@
use axum::Router;
use axum::http::{HeaderValue, Method, header};
use axum::middleware;
use tower::ServiceBuilder;
use tower_http::compression::CompressionLayer;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use utoipa_swagger_ui::SwaggerUi;

use crate::config::AppConfig;
use crate::handlers::{health, items};
use crate::middleware::request_log::{REQUEST_ID_HEADER, request_logging};
use crate::state::AppState;

/// Code-first OpenAPI document. Handler `#[utoipa::path]` attrs are merged
/// by `utoipa-axum` when the router is assembled.
#[derive(OpenApi)]
#[openapi(
info(title = "rust-template", version = "0.1.0"),
tags(
(name = "health", description = "Liveness and readiness"),
(name = "items", description = "Example CRUD resource")
),
components(schemas(
crate::models::health::HealthResponse,
crate::models::health::ReadyResponse,
crate::models::item::ItemResponse,
crate::models::item::CreateItemRequest,
crate::models::item::UpdateItemRequest,
crate::models::item::ItemPage,
crate::error::ErrorBody,
crate::error::ErrorDetail
))
)]
pub struct ApiDoc;

pub fn openapi_router() -> OpenApiRouter<AppState> {
OpenApiRouter::with_openapi(ApiDoc::openapi())
.routes(routes!(health::live))
.routes(routes!(health::ready))
.routes(routes!(items::list_items))
.routes(routes!(items::get_item))
.routes(routes!(items::create_item))
.routes(routes!(items::update_item))
.routes(routes!(items::delete_item))
}

pub fn openapi_spec() -> utoipa::openapi::OpenApi {
openapi_router().split_for_parts().1
}

pub fn router(state: AppState) -> Router {
let config = state.config().clone();
let (router, api) = openapi_router().split_for_parts();

router
.merge(SwaggerUi::new("/api/docs").url("/api/openapi.json", api.clone()))
.layer(middleware::from_fn(request_logging))
.layer(
ServiceBuilder::new()
.layer(SetRequestIdLayer::new(REQUEST_ID_HEADER.clone(), MakeRequestUuid))
.layer(PropagateRequestIdLayer::new(REQUEST_ID_HEADER.clone()))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(cors_layer(&config)),
)
.with_state(state)
}

fn cors_layer(config: &AppConfig) -> CorsLayer {
let origins: Vec<HeaderValue> =
config.cors.allowed_origins.iter().filter_map(|origin| origin.parse().ok()).collect();

let mut layer = CorsLayer::new()
.allow_methods([
Method::GET,
Method::POST,
Method::PATCH,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([
header::AUTHORIZATION,
header::CONTENT_TYPE,
header::ACCEPT,
REQUEST_ID_HEADER.clone(),
]);

layer = if origins.is_empty() {
layer.allow_origin(AllowOrigin::predicate(|_, _| false))
} else {
layer.allow_origin(origins)
};

if config.cors.allow_credentials {
layer = layer.allow_credentials(true);
}

layer
}

+ 36
- 0
apps/server/src/state.rs Ver arquivo

@@ -0,0 +1,36 @@
use std::ops::Deref;
use std::sync::Arc;

use sqlx::PgPool;

use crate::config::AppConfig;

#[derive(Clone)]
pub struct AppState(Arc<InnerState>);

pub struct InnerState {
pub config: AppConfig,
pub db: PgPool,
}

impl AppState {
pub fn new(config: AppConfig, db: PgPool) -> Self {
Self(Arc::new(InnerState { config, db }))
}

pub fn db(&self) -> &PgPool {
&self.0.db
}

pub fn config(&self) -> &AppConfig {
&self.0.config
}
}

impl Deref for AppState {
type Target = InnerState;

fn deref(&self) -> &Self::Target {
&self.0
}
}

+ 38
- 0
apps/server/src/telemetry.rs Ver arquivo

@@ -0,0 +1,38 @@
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;

use crate::config::AppConfig;

pub fn init(config: &AppConfig) -> anyhow::Result<()> {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
let mut directive = config.logging.filter.clone();
if !directive.contains("info") && !directive.contains(&config.logging.level) {
directive = format!("{},{}", config.logging.level, directive);
}
EnvFilter::new(directive)
});

let registry = tracing_subscriber::registry().with(filter);

match config.logging.format.as_str() {
"json" => {
registry
.with(tracing_subscriber::fmt::layer().json().with_span_events(FmtSpan::NONE))
.try_init()?;
}
_ => {
registry
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_span_events(FmtSpan::NONE)
.compact(),
)
.try_init()?;
}
}

Ok(())
}

+ 17
- 0
apps/server/tests/config.rs Ver arquivo

@@ -0,0 +1,17 @@
use server::AppConfig;

#[test]
fn default_config_binds_loopback() {
let config = AppConfig::default();
let addr = config.socket_addr().expect("addr");
assert_eq!(addr.port(), 8080);
assert!(addr.ip().is_loopback());
}

#[test]
fn layered_config_loads_from_workspace() {
let config = AppConfig::load().expect("load config from workspace");
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.frontend.host, "127.0.0.1");
assert!(!config.database.url.is_empty());
}

+ 10
- 0
apps/server/tests/openapi.rs Ver arquivo

@@ -0,0 +1,10 @@
use server::routes;

#[test]
fn openapi_includes_item_and_health_paths() {
let spec = routes::openapi_spec();
let json = spec.to_pretty_json().expect("serialize openapi");
assert!(json.contains("/health/live"), "missing live probe: {json}");
assert!(json.contains("/health/ready"), "missing ready probe: {json}");
assert!(json.contains("/api/v1/items"), "missing items collection: {json}");
}

+ 4
- 0
apps/web/.gitignore Ver arquivo

@@ -0,0 +1,4 @@
node_modules
dist
.vite
*.tsbuildinfo

+ 18
- 0
apps/web/index.html Ver arquivo

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>rust-template</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

+ 2128
- 0
apps/web/package-lock.json
Diferenças do arquivo suprimidas por serem muito extensas
Ver arquivo


+ 26
- 0
apps/web/package.json Ver arquivo

@@ -0,0 +1,26 @@
{
"name": "web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc -b --pretty false",
"generate:api": "node ../../scripts/generate-api.mjs"
},
"dependencies": {
"vue": "^3.5.18"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^22.17.0",
"@vitejs/plugin-vue": "^6.0.1",
"smol-toml": "^1.4.2",
"tailwindcss": "^4.1.11",
"typescript": "^5.9.2",
"vite": "^7.1.2",
"vue-tsc": "^3.0.5"
}
}

+ 30
- 0
apps/web/src/App.vue Ver arquivo

@@ -0,0 +1,30 @@
<script setup lang="ts">
import HealthBadge from "@/components/HealthBadge.vue";
import ItemBoard from "@/components/ItemBoard.vue";
</script>

<template>
<div class="mx-auto flex min-h-screen max-w-5xl flex-col px-6 py-10">
<header class="flex flex-wrap items-end justify-between gap-4 border-b border-line pb-8">
<div>
<p class="text-xs uppercase tracking-[0.22em] text-accent">Axum · Vue · sqlx</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">rust-template</h1>
<p class="mt-2 max-w-xl text-sm leading-6 text-muted">
Backend routes annotated with utoipa become OpenAPI, then a typed Vue
client. Vite reads the same TOML the server loads and reverse-proxies
<code>/api</code> and <code>/health</code> to 127.0.0.1.
</p>
</div>
<HealthBadge />
</header>
<main class="flex-1 py-8">
<ItemBoard />
</main>
<footer class="border-t border-line pt-6 text-xs text-muted">
Docs live at
<a class="text-accent hover:underline" href="/api/docs" target="_blank" rel="noreferrer">
/api/docs
</a>
</footer>
</div>
</template>

+ 79
- 0
apps/web/src/api/client.ts Ver arquivo

@@ -0,0 +1,79 @@
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly traceId?: string;

constructor(status: number, code: string, message: string, traceId?: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.traceId = traceId;
}
}

type RequestOptions = {
method: string;
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
expectedStatus: number;
};

function buildUrl(path: string, query?: RequestOptions["query"]): string {
const url = new URL(path, window.location.origin);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== "") {
url.searchParams.set(key, String(value));
}
}
}
return `${url.pathname}${url.search}`;
}

export async function request<T>(options: RequestOptions): Promise<T> {
const headers: Record<string, string> = { Accept: "application/json" };
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
}

const response = await fetch(buildUrl(options.path, options.query), {
method: options.method,
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});

if (response.status === 204 || options.expectedStatus === 204) {
if (!response.ok && response.status !== options.expectedStatus) {
throw await toApiError(response);
}
return undefined as T;
}

if (response.status !== options.expectedStatus) {
throw await toApiError(response);
}

if (response.headers.get("content-type")?.includes("application/json")) {
return (await response.json()) as T;
}
return undefined as T;
}

async function toApiError(response: Response): Promise<ApiError> {
const traceId = response.headers.get("x-request-id") ?? undefined;
try {
const payload = (await response.json()) as {
error?: { code?: string; message?: string; trace_id?: string };
};
return new ApiError(
response.status,
payload.error?.code ?? "http_error",
payload.error?.message ?? response.statusText,
payload.error?.trace_id ?? traceId,
);
} catch {
return new ApiError(response.status, "http_error", response.statusText, traceId);
}
}

+ 128
- 0
apps/web/src/api/generated.ts Ver arquivo

@@ -0,0 +1,128 @@
/* eslint-disable */
/* generated by scripts/generate-api.mjs — do not edit */
import { request } from "./client";

export type CreateItemRequest = {
description?: string | null;
name: string;
};

export type ErrorBody = {
error: ErrorDetail;
};

export type ErrorDetail = {
code: string;
message: string;
status: number;
trace_id?: string | null;
};

export type HealthResponse = {
service: string;
status: string;
};

export type ItemPage = {
items: Array<ItemResponse>;
page: number;
per_page: number;
total: number;
};

export type ItemResponse = {
created_at: string;
description?: string | null;
id: string;
name: string;
updated_at: string;
};

export type ReadyResponse = {
database: string;
status: string;
};

export type UpdateItemRequest = {
description?: string | null;
name?: string | null;
};

export function listItems(args: {
query?: {
page?: number;
per_page?: number;
}
}): Promise<ItemPage> {
return request<ItemPage>({
method: "GET",
path: "/api/v1/items",
query: args.query,
expectedStatus: 200,
});
}

export function createItem(args: {
body: CreateItemRequest
}): Promise<ItemResponse> {
return request<ItemResponse>({
method: "POST",
path: "/api/v1/items",
body: args.body,
expectedStatus: 201,
});
}

export function getItem(args: {
path: {
id: string;
}
}): Promise<ItemResponse> {
return request<ItemResponse>({
method: "GET",
path: "/api/v1/items/" + encodeURIComponent(String(args.path.id)),
expectedStatus: 200,
});
}

export function deleteItem(args: {
path: {
id: string;
}
}): Promise<void> {
return request<void>({
method: "DELETE",
path: "/api/v1/items/" + encodeURIComponent(String(args.path.id)),
expectedStatus: 204,
});
}

export function updateItem(args: {
path: {
id: string;
};
body: UpdateItemRequest
}): Promise<ItemResponse> {
return request<ItemResponse>({
method: "PATCH",
path: "/api/v1/items/" + encodeURIComponent(String(args.path.id)),
body: args.body,
expectedStatus: 200,
});
}

export function live(): Promise<HealthResponse> {
return request<HealthResponse>({
method: "GET",
path: "/health/live",
expectedStatus: 200,
});
}

export function ready(): Promise<ReadyResponse> {
return request<ReadyResponse>({
method: "GET",
path: "/health/ready",
expectedStatus: 200,
});
}

+ 41
- 0
apps/web/src/components/HealthBadge.vue Ver arquivo

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";

import { live, ready } from "@/api/generated";

const status = ref<"checking" | "ready" | "degraded">("checking");
const label = ref("checking API");

onMounted(async () => {
try {
const liveResult = await live();
try {
await ready();
status.value = "ready";
label.value = `${liveResult.service} ready`;
} catch {
status.value = "degraded";
label.value = `${liveResult.service} up, database down`;
}
} catch {
status.value = "degraded";
label.value = "API unreachable";
}
});
</script>

<template>
<div
class="inline-flex items-center gap-2 rounded-full border border-line bg-panel px-3 py-1 text-xs uppercase tracking-[0.14em] text-muted"
>
<span
class="size-1.5 rounded-full"
:class="{
'bg-accent animate-pulse': status === 'checking',
'bg-accent': status === 'ready',
'bg-danger': status === 'degraded',
}"
/>
{{ label }}
</div>
</template>

+ 131
- 0
apps/web/src/components/ItemBoard.vue Ver arquivo

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";

import { ApiError } from "@/api/client";
import {
createItem,
deleteItem,
listItems,
type ItemResponse,
} from "@/api/generated";

const items = ref<ItemResponse[]>([]);
const name = ref("");
const description = ref("");
const error = ref("");
const busy = ref(false);

async function refresh() {
const page = await listItems({ query: { page: 1, per_page: 50 } });
items.value = page.items;
}

async function onCreate() {
error.value = "";
busy.value = true;
try {
await createItem({
body: {
name: name.value.trim(),
description: description.value.trim() || null,
},
});
name.value = "";
description.value = "";
await refresh();
} catch (err) {
error.value = err instanceof ApiError ? err.message : "create failed";
} finally {
busy.value = false;
}
}

async function onDelete(id: string) {
error.value = "";
try {
await deleteItem({ path: { id } });
await refresh();
} catch (err) {
error.value = err instanceof ApiError ? err.message : "delete failed";
}
}

onMounted(async () => {
try {
await refresh();
} catch (err) {
error.value =
err instanceof ApiError
? err.message
: "Could not load items. Is Postgres up?";
}
});
</script>

<template>
<section class="grid gap-8 lg:grid-cols-[minmax(0,22rem)_1fr]">
<form
class="rounded-2xl border border-line bg-panel p-5 shadow-[0_20px_60px_rgba(0,0,0,0.25)]"
@submit.prevent="onCreate"
>
<h2 class="text-sm font-semibold tracking-wide text-ink">New item</h2>
<p class="mt-1 text-sm text-muted">
Typed call into <code class="text-accent">POST /api/v1/items</code>.
</p>
<label class="mt-5 block text-xs uppercase tracking-[0.16em] text-muted">
Name
<input
v-model="name"
required
maxlength="120"
class="mt-2 w-full rounded-lg border border-line bg-page px-3 py-2 text-sm text-ink outline-none focus:border-accent"
/>
</label>
<label class="mt-4 block text-xs uppercase tracking-[0.16em] text-muted">
Description
<textarea
v-model="description"
rows="4"
maxlength="2000"
class="mt-2 w-full resize-y rounded-lg border border-line bg-page px-3 py-2 text-sm text-ink outline-none focus:border-accent"
/>
</label>
<button
type="submit"
:disabled="busy || !name.trim()"
class="mt-5 w-full rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-page disabled:opacity-50"
>
{{ busy ? "Saving…" : "Create item" }}
</button>
<p v-if="error" class="mt-3 text-sm text-danger">{{ error }}</p>
</form>

<div class="space-y-3">
<article
v-for="item in items"
:key="item.id"
class="rounded-2xl border border-line bg-panel/80 px-5 py-4"
>
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="font-medium text-ink">{{ item.name }}</h3>
<p class="mt-1 text-sm text-muted">
{{ item.description || "No description" }}
</p>
</div>
<button
type="button"
class="text-xs uppercase tracking-[0.14em] text-muted hover:text-danger"
@click="onDelete(item.id)"
>
Delete
</button>
</div>
<p class="mt-3 font-mono text-[11px] text-muted/70">{{ item.id }}</p>
</article>
<p v-if="!items.length && !error" class="text-sm text-muted">
No items yet. Create one to exercise the generated client.
</p>
</div>
</section>
</template>

+ 6
- 0
apps/web/src/main.ts Ver arquivo

@@ -0,0 +1,6 @@
import { createApp } from "vue";

import App from "./App.vue";
import "./style.css";

createApp(App).mount("#app");

+ 34
- 0
apps/web/src/style.css Ver arquivo

@@ -0,0 +1,34 @@
@import "tailwindcss";

@theme {
--font-sans: "IBM Plex Sans", "Segoe UI", sans-serif;
--color-ink: #e8edf5;
--color-muted: #93a0b5;
--color-line: #243044;
--color-panel: #121826;
--color-page: #0b1018;
--color-accent: #5eead4;
--color-accent-dim: #134e4a;
--color-danger: #fb7185;
}

html,
body,
#app {
min-height: 100%;
}

body {
margin: 0;
background:
radial-gradient(1200px 500px at 10% -10%, rgba(94, 234, 212, 0.08), transparent 50%),
var(--color-page);
color: var(--color-ink);
font-family: var(--font-sans);
}

input,
textarea,
button {
font: inherit;
}

+ 7
- 0
apps/web/src/vite-env.d.ts Ver arquivo

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />

declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}

+ 24
- 0
apps/web/tsconfig.app.json Ver arquivo

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

+ 7
- 0
apps/web/tsconfig.json Ver arquivo

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

+ 19
- 0
apps/web/tsconfig.node.json Ver arquivo

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

+ 125
- 0
apps/web/vite.config.ts Ver arquivo

@@ -0,0 +1,125 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import tailwindcss from "@tailwindcss/vite";
import vue from "@vitejs/plugin-vue";
import { parse } from "smol-toml";
import { defineConfig, type Plugin } from "vite";

const webRoot = path.dirname(fileURLToPath(import.meta.url));
const workspaceRoot = path.resolve(webRoot, "../..");

type TomlTable = Record<string, unknown>;

function isTable(value: unknown): value is TomlTable {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function deepMerge(base: TomlTable, overlay: TomlTable): TomlTable {
const next: TomlTable = { ...base };
for (const [key, value] of Object.entries(overlay)) {
const existing = next[key];
if (isTable(existing) && isTable(value)) {
next[key] = deepMerge(existing, value);
} else {
next[key] = value;
}
}
return next;
}

function readToml(filePath: string): TomlTable {
if (!fs.existsSync(filePath)) {
return {};
}
return parse(fs.readFileSync(filePath, "utf8")) as TomlTable;
}

/**
* Same load order as `AppConfig::load` in the Rust server:
* default.toml → {APP_ENV}.toml. Env overrides stay on the server.
*/
function loadAppConfig(): TomlTable {
const configDir = process.env.APP_CONFIG_DIR
? path.resolve(process.env.APP_CONFIG_DIR)
: path.join(workspaceRoot, "config");
const env = process.env.APP_ENV ?? "development";
return deepMerge(
readToml(path.join(configDir, "default.toml")),
readToml(path.join(configDir, `${env}.toml`)),
);
}

function section(config: TomlTable, name: string): TomlTable {
const value = config[name];
return isTable(value) ? value : {};
}

function generateApiFromSpec(): void {
const script = path.join(workspaceRoot, "scripts/generate-api.mjs");
const spec = path.join(workspaceRoot, "packages/contracts/openapi.json");
if (!fs.existsSync(script) || !fs.existsSync(spec)) {
return;
}
const result = spawnSync(process.execPath, [script], {
cwd: workspaceRoot,
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error("OpenAPI client generation failed");
}
}

function openapiClientPlugin(): Plugin {
const specPath = path.join(workspaceRoot, "packages/contracts/openapi.json");
return {
name: "openapi-client",
buildStart() {
generateApiFromSpec();
this.addWatchFile(specPath);
},
handleHotUpdate(ctx) {
if (ctx.file === specPath) {
generateApiFromSpec();
}
},
};
}

const appConfig = loadAppConfig();
const server = section(appConfig, "server");
const frontend = section(appConfig, "frontend");

const backendHost = String(server.host ?? "127.0.0.1");
const backendPort = Number(server.port ?? 8080);
const frontendHost = String(frontend.host ?? "127.0.0.1");
const frontendPort = Number(frontend.port ?? 5173);
const backendOrigin = `http://${backendHost}:${backendPort}`;

export default defineConfig({
plugins: [vue(), tailwindcss(), openapiClientPlugin()],
resolve: {
alias: {
"@": path.join(webRoot, "src"),
},
},
server: {
host: frontendHost,
port: frontendPort,
strictPort: true,
proxy: {
"/api": { target: backendOrigin, changeOrigin: true },
"/health": { target: backendOrigin, changeOrigin: true },
},
},
preview: {
host: frontendHost,
port: frontendPort,
proxy: {
"/api": { target: backendOrigin, changeOrigin: true },
"/health": { target: backendOrigin, changeOrigin: true },
},
},
});

+ 7
- 0
cargo-generate.toml Ver arquivo

@@ -0,0 +1,7 @@
[template]
cargo_generate_version = ">=0.21.0"

[placeholders.project_description]
type = "string"
prompt = "Short project description?"
default = "Axum + Vue + PostgreSQL fullstack app"

+ 39
- 0
config/default.toml Ver arquivo

@@ -0,0 +1,39 @@
# Shared by the Axum server and the Vite reverse-proxy plugin.
# Precedence: default.toml → {APP_ENV}.toml → APP__SECTION__KEY env vars.

[app]
name = "rust-template"
description = "Axum + Vue + PostgreSQL fullstack app"

[server]
host = "127.0.0.1"
port = 8080
# Public origin the API advertises in OpenAPI (and that Vite proxies to).
public_url = "http://127.0.0.1:8080"

[frontend]
host = "127.0.0.1"
port = 5173
# Vite binds here so /api is never resolved to IPv6 ::1.
public_url = "http://127.0.0.1:5173"

[database]
url = "postgres://app:app@127.0.0.1:5432/app"
max_connections = 10
min_connections = 1
acquire_timeout_secs = 5
idle_timeout_secs = 600
run_migrations = true

[logging]
# trace | debug | info | warn | error
level = "info"
# pretty | json
format = "pretty"
# Extra directive, e.g. "sqlx=warn,tower_http=debug"
filter = "server=debug,tower_http=info,sqlx=warn"

[cors]
# Development allows the Vite origin. Production should list real origins.
allowed_origins = ["http://127.0.0.1:5173"]
allow_credentials = true

+ 7
- 0
config/development.toml Ver arquivo

@@ -0,0 +1,7 @@
[logging]
level = "debug"
format = "pretty"
filter = "server=debug,tower_http=debug,sqlx=info"

[cors]
allowed_origins = ["http://127.0.0.1:5173"]

+ 17
- 0
config/production.toml Ver arquivo

@@ -0,0 +1,17 @@
[server]
host = "0.0.0.0"
port = 8080

[logging]
level = "info"
format = "json"
filter = "server=info,tower_http=info,sqlx=warn"

[database]
max_connections = 20
run_migrations = true

[cors]
# Replace with the real frontend origin before going live.
allowed_origins = []
allow_credentials = true

+ 30
- 0
docs/architecture.md Ver arquivo

@@ -0,0 +1,30 @@
# Architecture

```
browser → Vite :5173 --proxy /api,/health--> Axum :8080 → PostgreSQL
↑ │
└── generated client ← openapi.json ← utoipa-axum router
```

## Why this shape

One workspace. The server owns HTTP and persistence. The web app owns interaction. The contract package is generated, never edited.

Config is shared on purpose. `config/default.toml` is read by both `AppConfig::load` and `apps/web/vite.config.ts`, so the reverse proxy target cannot drift from the bind address.

## Request path

1. Vite binds `127.0.0.1` (not `localhost`) so the browser never hits IPv6 `::1`.
2. Browser calls `/api/v1/items`.
3. Vite proxies to `http://127.0.0.1:8080`.
4. `SetRequestIdLayer` assigns `X-Request-Id`.
5. `request_logging` records method, path, status, latency, request id.
6. Handler returns `Result<T, ApiError>`. Errors become a single JSON envelope.

## Adding an endpoint

1. DTO in `apps/server/src/models`.
2. Handler in `apps/server/src/handlers` with `#[utoipa::path]`.
3. Register it with `.routes(routes!(your_handler))` in `routes/mod.rs`.
4. `just generate-api`.
5. Call the new function from `apps/web/src/api/generated.ts`.

+ 8
- 0
docs/decisions/0001-stack.md Ver arquivo

@@ -0,0 +1,8 @@
# 0001 — Stack

- Axum 0.8 for the HTTP surface. Handlers stay as functions, not a framework object model.
- sqlx 0.9 with runtime queries so the template compiles without a live database. Compile-time `query!` can be adopted later with `.sqlx` offline data.
- utoipa + utoipa-axum so the route table is the OpenAPI document.
- Vue 3 + TypeScript + Tailwind 4 for the UI.
- Vite proxy configured from the same TOML the server loads.
- Generated fetch client instead of a hand-written axios wrapper.

+ 47
- 0
docs/runbook.md Ver arquivo

@@ -0,0 +1,47 @@
# Runbook

## First run

```bash
cp .env.example .env
docker compose -f infra/compose/docker-compose.yml up -d postgres
cargo run -p server
```

In another terminal:

```bash
cd apps/web && npm install && npm run dev
```

Open http://127.0.0.1:5173. Swagger UI is at http://127.0.0.1:8080/api/docs.

## RustRover

1. Open this folder as a Cargo workspace.
2. Run configuration: `cargo run -p server`.
3. Environment: `APP_ENV=development`.
4. Optional custom template: Settings → New Project → add this Git URL as a cargo-generate template.

## Config overrides

| Knob | Where |
| --- | --- |
| Profile | `APP_ENV=development\|production` |
| File | `config/{APP_ENV}.toml` |
| Env | `APP__SERVER__PORT=8080` |
| Explicit dir | `APP_CONFIG_DIR=/abs/path/to/config` |

## Generate the frontend client

```bash
just generate-api
```

Vite also regenerates on dev/build if `packages/contracts/openapi.json` changes.

## Postgres URL

Default: `postgres://app:app@127.0.0.1:5432/app`

Override with `APP__DATABASE__URL` or `config/local.toml` (gitignored).

+ 19
- 0
infra/compose/docker-compose.yml Ver arquivo

@@ -0,0 +1,19 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports:
- "5432:5432"
volumes:
- rust_template_pg:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 5s
retries: 10

volumes:
rust_template_pg:

+ 22
- 0
infra/docker/nginx.conf Ver arquivo

@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;

root /usr/share/nginx/html;
index index.html;

location /api/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
proxy_set_header X-Request-Id $request_id;
}

location /health/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
}

location / {
try_files $uri $uri/ /index.html;
}
}

+ 18
- 0
infra/docker/server.Dockerfile Ver arquivo

@@ -0,0 +1,18 @@
FROM rust:1-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY apps/server ./apps/server
COPY config ./config
RUN cargo build --release -p server

FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/server /usr/local/bin/server
COPY config ./config
COPY apps/server/migrations ./apps/server/migrations
ENV APP_ENV=production
EXPOSE 8080
CMD ["server"]

+ 14
- 0
infra/docker/web.Dockerfile Ver arquivo

@@ -0,0 +1,14 @@
FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY apps/web/package.json apps/web/package-lock.json* ./apps/web/
COPY config ./config
COPY packages/contracts ./packages/contracts
COPY scripts ./scripts
WORKDIR /app/apps/web
RUN npm install
COPY apps/web ./
RUN npm run build

FROM nginx:1.27-alpine
COPY infra/docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html

+ 32
- 0
justfile Ver arquivo

@@ -0,0 +1,32 @@
set dotenv-load := false
set ignore-comments := true

default:
@just --list

# Run the Axum API (reads config/ + APP_ENV).
server:
APP_ENV=${APP_ENV:-development} cargo run -p server

# Vite dev server. Proxy target is read from config/*.toml.
web:
cd apps/web && npm install && npm run dev

# Export OpenAPI from the Axum router and regenerate the typed Vue client.
generate-api:
cargo run -p server --bin export-openapi
node scripts/generate-api.mjs

# Postgres for local smoke tests.
db-up:
docker compose -f infra/compose/docker-compose.yml up -d postgres

db-down:
docker compose -f infra/compose/docker-compose.yml down

# Format + clippy + frontend typecheck.
check:
cargo fmt --all -- --check
cargo clippy -p server --all-targets -- -D warnings
cargo test -p server
cd apps/web && npm install && npm run typecheck

+ 11
- 0
packages/contracts/README.md Ver arquivo

@@ -0,0 +1,11 @@
# contracts

`openapi.json` is the generated API contract.

It is not hand-written. The Axum router (`utoipa-axum` + `#[utoipa::path]`) is the source of truth.

```bash
just generate-api
```

That dumps the spec here and rewrites `apps/web/src/api/generated.ts`.

+ 458
- 0
packages/contracts/openapi.json Ver arquivo

@@ -0,0 +1,458 @@
{
"openapi": "3.1.0",
"info": {
"title": "rust-template",
"description": "Axum API server",
"license": {
"name": "MIT",
"identifier": "MIT"
},
"version": "0.1.0"
},
"paths": {
"/api/v1/items": {
"get": {
"tags": [
"items"
],
"summary": "List items, newest first.",
"operationId": "list_items",
"parameters": [
{
"name": "page",
"in": "query",
"description": "1-based page index.",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 0
}
},
{
"name": "per_page",
"in": "query",
"description": "Page size (max 100).",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
],
"responses": {
"200": {
"description": "Paged item list",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ItemPage"
}
}
}
}
}
},
"post": {
"tags": [
"items"
],
"summary": "Create an item.",
"operationId": "create_item",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateItemRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Created item",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ItemResponse"
}
}
}
},
"400": {
"description": "Validation error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorBody"
}
}
}
}
}
}
},
"/api/v1/items/{id}": {
"get": {
"tags": [
"items"
],
"summary": "Fetch a single item.",
"operationId": "get_item",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Item id",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Item",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ItemResponse"
}
}
}
},
"404": {
"description": "Missing item",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorBody"
}
}
}
}
}
},
"delete": {
"tags": [
"items"
],
"summary": "Delete an item.",
"operationId": "delete_item",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Item id",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"204": {
"description": "Deleted"
},
"404": {
"description": "Missing item",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorBody"
}
}
}
}
}
},
"patch": {
"tags": [
"items"
],
"summary": "Replace selected item fields.",
"operationId": "update_item",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Item id",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateItemRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Updated item",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ItemResponse"
}
}
}
},
"404": {
"description": "Missing item",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorBody"
}
}
}
}
}
}
},
"/health/live": {
"get": {
"tags": [
"health"
],
"summary": "Liveness probe. Process is up.",
"operationId": "live",
"responses": {
"200": {
"description": "Process is running",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HealthResponse"
}
}
}
}
}
}
},
"/health/ready": {
"get": {
"tags": [
"health"
],
"summary": "Readiness probe. Database is reachable.",
"operationId": "ready",
"responses": {
"200": {
"description": "Database is reachable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReadyResponse"
}
}
}
},
"500": {
"description": "Database is unreachable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorBody"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"CreateItemRequest": {
"type": "object",
"required": [
"name"
],
"properties": {
"description": {
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
}
}
},
"ErrorBody": {
"type": "object",
"description": "Stable JSON error envelope returned by every handler.",
"required": [
"error"
],
"properties": {
"error": {
"$ref": "#/components/schemas/ErrorDetail"
}
}
},
"ErrorDetail": {
"type": "object",
"required": [
"status",
"code",
"message"
],
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"status": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"trace_id": {
"type": [
"string",
"null"
]
}
}
},
"HealthResponse": {
"type": "object",
"required": [
"status",
"service"
],
"properties": {
"service": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"ItemPage": {
"type": "object",
"required": [
"items",
"page",
"per_page",
"total"
],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ItemResponse"
}
},
"page": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"per_page": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"total": {
"type": "integer",
"format": "int64"
}
}
},
"ItemResponse": {
"type": "object",
"required": [
"id",
"name",
"created_at",
"updated_at"
],
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"description": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"ReadyResponse": {
"type": "object",
"required": [
"status",
"database"
],
"properties": {
"database": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"UpdateItemRequest": {
"type": "object",
"properties": {
"description": {
"type": [
"string",
"null"
]
},
"name": {
"type": [
"string",
"null"
]
}
}
}
}
},
"tags": [
{
"name": "health",
"description": "Liveness and readiness"
},
{
"name": "items",
"description": "Example CRUD resource"
}
]
}

+ 3
- 0
rust-toolchain.toml Ver arquivo

@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]

+ 5
- 0
rustfmt.toml Ver arquivo

@@ -0,0 +1,5 @@
edition = "2024"
max_width = 100
use_field_init_shorthand = true
use_small_heuristics = "Max"
newline_style = "Unix"

+ 228
- 0
scripts/generate-api.mjs Ver arquivo

@@ -0,0 +1,228 @@
#!/usr/bin/env node
/**
* Generate a typed fetch client from packages/contracts/openapi.json.
*
* The spec is produced by `cargo run -p server --bin export-openapi`, which
* walks the same utoipa-axum router the server serves. Adding a handler with
* `#[utoipa::path]` + `.routes(routes!(...))` is enough for it to show up here
* after `just generate-api`.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const specPath = path.join(workspaceRoot, "packages/contracts/openapi.json");
const outPath = path.join(workspaceRoot, "apps/web/src/api/generated.ts");

const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];

function die(message) {
console.error(message);
process.exit(1);
}

function pascal(value) {
return String(value)
.replace(/[^A-Za-z0-9]+/g, " ")
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}

function camel(value) {
const p = pascal(value);
return p ? p.charAt(0).toLowerCase() + p.slice(1) : "fn";
}

function uniqueName(base, used) {
let name = base;
let i = 2;
while (used.has(name)) {
name = `${base}${i}`;
i += 1;
}
used.add(name);
return name;
}

function tsType(schema, spec, seen = new Set()) {
if (!schema) return "unknown";
if (schema.$ref) {
const name = schema.$ref.split("/").pop();
return name ? pascal(name) : "unknown";
}
if (schema.allOf?.length) {
return schema.allOf.map((part) => tsType(part, spec, seen)).join(" & ");
}
if (schema.oneOf?.length || schema.anyOf?.length) {
const parts = (schema.oneOf ?? schema.anyOf).map((part) => tsType(part, spec, seen));
return [...new Set(parts)].join(" | ");
}
if (Array.isArray(schema.enum) && schema.enum.every((item) => typeof item === "string")) {
return schema.enum.map((item) => JSON.stringify(item)).join(" | ");
}
if (Array.isArray(schema.type)) {
const nullable = schema.type.includes("null");
const types = schema.type.filter((type) => type !== "null");
const inner = types.length
? types.map((type) => tsType({ ...schema, type }, spec, seen)).join(" | ")
: "unknown";
return nullable ? `${inner} | null` : inner;
}
switch (schema.type) {
case "string":
if (schema.format === "binary") return "Blob";
return "string";
case "integer":
case "number":
return "number";
case "boolean":
return "boolean";
case "array":
return `Array<${tsType(schema.items ?? {}, spec, seen)}>`;
case "object":
default: {
const props = schema.properties ?? {};
const required = new Set(schema.required ?? []);
const fields = Object.entries(props).map(([key, value]) => {
const optional = required.has(key) ? "" : "?";
return ` ${key}${optional}: ${tsType(value, spec, seen)};`;
});
if (schema.additionalProperties) {
const extra =
schema.additionalProperties === true
? "unknown"
: tsType(schema.additionalProperties, spec, seen);
fields.push(` [key: string]: ${extra};`);
}
if (!fields.length) return "Record<string, unknown>";
return `{\n${fields.join("\n")}\n}`;
}
}
}

function schemaFromContent(content) {
if (!content || typeof content !== "object") return null;
return (
content["application/json"]?.schema ??
content["application/problem+json"]?.schema ??
Object.values(content)[0]?.schema ??
null
);
}

function successResponse(operation) {
const responses = operation.responses ?? {};
const preferred = ["200", "201", "202", "204"];
for (const code of preferred) {
if (responses[code]) return { code, response: responses[code] };
}
const fallback = Object.entries(responses).find(([code]) => code.startsWith("2"));
return fallback ? { code: fallback[0], response: fallback[1] } : { code: "200", response: {} };
}

function operationName(method, pathName, operation, used) {
if (operation.operationId) {
return uniqueName(camel(operation.operationId), used);
}
const slug = pathName.replace(/[{}]/g, "").replace(/^\//, "").replaceAll("/", "_");
return uniqueName(camel(`${method}_${slug}`), used);
}

function pathToTemplate(pathName, paramNames) {
let template = JSON.stringify(pathName);
for (const name of paramNames) {
template = template.replace(`{${name}}`, `" + encodeURIComponent(String(args.path.${name})) + "`);
}
return template.replace(/ \+ ""/g, "");
}

if (!fs.existsSync(specPath)) {
die(`missing ${specPath} — run: cargo run -p server --bin export-openapi`);
}

const spec = JSON.parse(fs.readFileSync(specPath, "utf8"));
const schemas = spec.components?.schemas ?? {};
const usedNames = new Set();
const typeDecls = [];
const fnDecls = [];

for (const [name, schema] of Object.entries(schemas)) {
typeDecls.push(`export type ${pascal(name)} = ${tsType(schema, spec)};`);
}

const paths = spec.paths ?? {};
for (const [pathName, item] of Object.entries(paths)) {
for (const method of HTTP_METHODS) {
const operation = item?.[method];
if (!operation) continue;

const name = operationName(method, pathName, operation, usedNames);
const allParams = [...(item.parameters ?? []), ...(operation.parameters ?? [])];
const pathParams = allParams.filter((param) => param.in === "path");
const queryParams = allParams.filter((param) => param.in === "query");
const bodySchema = schemaFromContent(operation.requestBody?.content);
const { code: status, response } = successResponse(operation);
const responseSchema = schemaFromContent(response?.content);

const args = [];
if (pathParams.length) {
const fields = pathParams
.map((param) => {
const schema = param.schema ?? { type: "string" };
return ` ${param.name}: ${tsType(schema, spec)};`;
})
.join("\n");
args.push(`path: {\n${fields}\n }`);
}
if (queryParams.length) {
const fields = queryParams
.map((param) => {
const schema = param.schema ?? { type: "string" };
const optional = param.required ? "" : "?";
return ` ${param.name}${optional}: ${tsType(schema, spec)};`;
})
.join("\n");
args.push(`query?: {\n${fields}\n }`);
}
if (bodySchema) {
args.push(`body: ${tsType(bodySchema, spec)}`);
}

const argList = args.length ? `args: {\n ${args.join(";\n ")}\n}` : "";
const returnType = responseSchema ? tsType(responseSchema, spec) : "void";
const pathExpr = pathToTemplate(
pathName,
pathParams.map((param) => param.name),
);
const queryLine = queryParams.length ? " query: args.query," : "";
const bodyLine = bodySchema ? " body: args.body," : "";

const requestFields = [
` method: "${method.toUpperCase()}",`,
` path: ${pathExpr},`,
queryLine,
bodyLine,
` expectedStatus: ${Number(status) || 200},`,
].filter(Boolean);

fnDecls.push(`export function ${name}(${argList}): Promise<${returnType}> {
return request<${returnType}>({
${requestFields.join("\n")}
});
}`);
}
}

const banner = `/* eslint-disable */
/* generated by scripts/generate-api.mjs — do not edit */
import { request } from "./client";

`;

const output = `${banner}${typeDecls.join("\n\n")}\n\n${fnDecls.join("\n\n")}\n`;
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, output);
console.log(`wrote ${outPath}`);

Carregando…
Cancelar
Salvar