Workspace starter for RustRover: Axum, sqlx, PostgreSQL, Vue 3, TypeScript, Tailwind, shared TOML config, request logging, and OpenAPI-generated frontend client.master
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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). | |||||
| @@ -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/ | |||||
| @@ -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"] } | |||||
| @@ -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. | |||||
| @@ -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 | |||||
| ``` | |||||
| @@ -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"] } | |||||
| @@ -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); | |||||
| @@ -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(()) | |||||
| } | |||||
| @@ -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(".")) | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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>; | |||||
| @@ -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" })) | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| @@ -0,0 +1,2 @@ | |||||
| pub mod health; | |||||
| pub mod items; | |||||
| @@ -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; | |||||
| @@ -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"); | |||||
| } | |||||
| @@ -0,0 +1 @@ | |||||
| pub mod request_log; | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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, | |||||
| } | |||||
| @@ -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>, | |||||
| } | |||||
| @@ -0,0 +1,3 @@ | |||||
| pub mod health; | |||||
| pub mod item; | |||||
| pub mod pagination; | |||||
| @@ -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 } | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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(()) | |||||
| } | |||||
| @@ -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()); | |||||
| } | |||||
| @@ -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}"); | |||||
| } | |||||
| @@ -0,0 +1,4 @@ | |||||
| node_modules | |||||
| dist | |||||
| .vite | |||||
| *.tsbuildinfo | |||||
| @@ -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> | |||||
| @@ -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" | |||||
| } | |||||
| } | |||||
| @@ -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> | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| @@ -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, | |||||
| }); | |||||
| } | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -0,0 +1,6 @@ | |||||
| import { createApp } from "vue"; | |||||
| import App from "./App.vue"; | |||||
| import "./style.css"; | |||||
| createApp(App).mount("#app"); | |||||
| @@ -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; | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| @@ -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"] | |||||
| } | |||||
| @@ -0,0 +1,7 @@ | |||||
| { | |||||
| "files": [], | |||||
| "references": [ | |||||
| { "path": "./tsconfig.app.json" }, | |||||
| { "path": "./tsconfig.node.json" } | |||||
| ] | |||||
| } | |||||
| @@ -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"] | |||||
| } | |||||
| @@ -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 }, | |||||
| }, | |||||
| }, | |||||
| }); | |||||
| @@ -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" | |||||
| @@ -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 | |||||
| @@ -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"] | |||||
| @@ -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 | |||||
| @@ -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`. | |||||
| @@ -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. | |||||
| @@ -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). | |||||
| @@ -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: | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -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"] | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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`. | |||||
| @@ -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" | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -0,0 +1,3 @@ | |||||
| [toolchain] | |||||
| channel = "stable" | |||||
| components = ["rustfmt", "clippy"] | |||||
| @@ -0,0 +1,5 @@ | |||||
| edition = "2024" | |||||
| max_width = 100 | |||||
| use_field_init_shorthand = true | |||||
| use_small_heuristics = "Max" | |||||
| newline_style = "Unix" | |||||
| @@ -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}`); | |||||