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}`); | |||