| @@ -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,11 @@ | |||||
| # 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 after APP. | |||||
| # The running server reads APP__DATABASE__URL, not DATABASE_URL. | |||||
| # APP__SERVER__PORT=8080 | |||||
| APP__DATABASE__URL=postgres://app:app@127.0.0.1:5432/app | |||||
| # Used by sqlx-cli if you install it. The Axum process ignores this key. | |||||
| 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", "ws"] } | |||||
| 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,43 @@ | |||||
| [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 | |||||
| reqwest = { version = "0.13.4", features = ["json"] } | |||||
| 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 | |||||
| log = "0.4.33" | |||||
| [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,12 @@ | |||||
| CREATE TABLE IF NOT EXISTS queries ( | |||||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |||||
| title TEXT NOT NULL, | |||||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now() | |||||
| ); | |||||
| CREATE TABLE IF NOT EXISTS query_messages ( | |||||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |||||
| query_id UUID REFERENCES queries(id), | |||||
| message TEXT NOT NULL, | |||||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now() | |||||
| ); | |||||
| @@ -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,230 @@ | |||||
| 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. `config/local.toml` (gitignored) | |||||
| /// 5. `APP__SECTION__KEY` environment variables | |||||
| /// | |||||
| /// Figment prefix is `APP__` (two underscores). `APP__DATABASE__URL` maps to | |||||
| /// `database.url`. `APP_DATABASE__URL` does not. | |||||
| #[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: "Lyra".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>> { | |||||
| load_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(Toml::file(config_dir.join("local.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()) | |||||
| } | |||||
| /// Walk from cwd / crate dir so RustRover can start the binary from `apps/server`. | |||||
| fn load_dotenv() { | |||||
| let mut candidates = Vec::new(); | |||||
| if let Ok(cwd) = std::env::current_dir() { | |||||
| candidates.extend(cwd.ancestors().map(|dir| dir.join(".env"))); | |||||
| } | |||||
| if let Ok(manifest) = std::env::var("CARGO_MANIFEST_DIR") { | |||||
| let manifest = PathBuf::from(manifest); | |||||
| candidates.push(manifest.join(".env")); | |||||
| candidates.push(manifest.join("../../.env")); | |||||
| } | |||||
| for path in candidates { | |||||
| if path.is_file() { | |||||
| let _ = dotenvy::from_path(&path); | |||||
| return; | |||||
| } | |||||
| } | |||||
| } | |||||
| /// Drop the password so logs can show which role/host we actually used. | |||||
| pub fn redact_db_url(url: &str) -> String { | |||||
| let Some(scheme_end) = url.find("://") else { | |||||
| return url.to_string(); | |||||
| }; | |||||
| let rest = &url[scheme_end + 3..]; | |||||
| let Some(at) = rest.find('@') else { | |||||
| return url.to_string(); | |||||
| }; | |||||
| let user = rest[..at].split(':').next().unwrap_or(""); | |||||
| format!("{}{user}{}", &url[..=scheme_end + 2], &rest[at..]) | |||||
| } | |||||
| /// 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,93 @@ | |||||
| 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 From<reqwest::Error> for ApiError { | |||||
| fn from(err: reqwest::Error) -> Self { | |||||
| ApiError::Internal(anyhow::anyhow!(err)) | |||||
| } | |||||
| } | |||||
| 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,198 @@ | |||||
| use crate::models::llm::{LlmQuery, LlmRequest, LlmWsEvent, SavedQueriesResponse}; | |||||
| use crate::AppState; | |||||
| use axum::extract::State; | |||||
| use axum::{extract::{ | |||||
| ws::{Message, WebSocket}, | |||||
| WebSocketUpgrade, | |||||
| }, response::IntoResponse}; | |||||
| use sqlx::Row; | |||||
| use uuid::Uuid; | |||||
| use crate::error::ApiError; | |||||
| #[utoipa::path( | |||||
| get, | |||||
| path = "/api/v1/llm/ws", | |||||
| tag = "llm", | |||||
| description = "WebSocket upgrade. After 101, send LlmRequest as one text frame. Server sends LlmWsEvent frames.", | |||||
| responses((status = 101, description = "Switching Protocols")) | |||||
| )] | |||||
| pub async fn send( | |||||
| ws: WebSocketUpgrade, | |||||
| State(state): State<AppState>, | |||||
| ) -> impl IntoResponse { | |||||
| ws.on_upgrade(move |socket| handle_send(socket, state)) | |||||
| } | |||||
| async fn handle_send(mut socket: WebSocket, state: AppState) { | |||||
| let mut full_body = String::new(); | |||||
| let Some(Ok(Message::Text(text))) = socket.recv().await else { | |||||
| return; | |||||
| }; | |||||
| let mut body: LlmRequest = match serde_json::from_str(&text) { | |||||
| Ok(v) => v, | |||||
| Err(e) => { | |||||
| let _ = send_event( | |||||
| &mut socket, | |||||
| &LlmWsEvent::Error { | |||||
| message: e.to_string(), | |||||
| }, | |||||
| ) | |||||
| .await; | |||||
| return; | |||||
| } | |||||
| }; | |||||
| body.stream = Some(true); | |||||
| let mut resp = match state | |||||
| .0 | |||||
| .client | |||||
| .post("http://127.0.0.1:11434/api/chat") | |||||
| .json(&body) | |||||
| .send() | |||||
| .await | |||||
| { | |||||
| Ok(r) => r, | |||||
| Err(e) => { | |||||
| let _ = send_event( | |||||
| &mut socket, | |||||
| &LlmWsEvent::Error { | |||||
| message: e.to_string(), | |||||
| }, | |||||
| ) | |||||
| .await; | |||||
| return; | |||||
| } | |||||
| }; | |||||
| if !resp.status().is_success() { | |||||
| let status = resp.status(); | |||||
| let text = resp.text().await.unwrap_or_default(); | |||||
| let _ = send_event( | |||||
| &mut socket, | |||||
| &LlmWsEvent::Error { | |||||
| message: format!("{status}: {text}"), | |||||
| }, | |||||
| ) | |||||
| .await; | |||||
| return; | |||||
| } | |||||
| let mut buf = String::new(); | |||||
| loop { | |||||
| let chunk = match resp.chunk().await { | |||||
| Ok(Some(bytes)) => bytes, | |||||
| Ok(None) => break, | |||||
| Err(e) => { | |||||
| let _ = send_event( | |||||
| &mut socket, | |||||
| &LlmWsEvent::Error { | |||||
| message: e.to_string(), | |||||
| }, | |||||
| ) | |||||
| .await; | |||||
| return; | |||||
| } | |||||
| }; | |||||
| buf.push_str(&String::from_utf8_lossy(&chunk)); | |||||
| while let Some(i) = buf.find('\n') { | |||||
| let line = buf[..i].trim().to_string(); | |||||
| buf.drain(..=i); | |||||
| if line.is_empty() { | |||||
| continue; | |||||
| } | |||||
| let event: serde_json::Value = match serde_json::from_str(&line) { | |||||
| Ok(v) => v, | |||||
| Err(_) => continue, | |||||
| }; | |||||
| if let Some(err) = event.get("error").and_then(|v| v.as_str()) { | |||||
| let _ = send_event( | |||||
| &mut socket, | |||||
| &LlmWsEvent::Error { | |||||
| message: err.to_string(), | |||||
| }, | |||||
| ) | |||||
| .await; | |||||
| return; | |||||
| } | |||||
| if let Some(piece) = event | |||||
| .pointer("/message/content") | |||||
| .and_then(|v| v.as_str()) | |||||
| .filter(|s| !s.is_empty()) | |||||
| { | |||||
| full_body.push_str(piece); | |||||
| if send_event( | |||||
| &mut socket, | |||||
| &LlmWsEvent::Delta { | |||||
| content: piece.to_string(), | |||||
| }, | |||||
| ) | |||||
| .await | |||||
| .is_err() | |||||
| { | |||||
| return; | |||||
| } | |||||
| } | |||||
| if event.get("done").and_then(|v| v.as_bool()) == Some(true) { | |||||
| let reason = event | |||||
| .get("done_reason") | |||||
| .and_then(|v| v.as_str()) | |||||
| .unwrap_or("stop") | |||||
| .to_string(); | |||||
| let title = body | |||||
| .messages | |||||
| .last() | |||||
| .map(|m| m.content.as_str()) | |||||
| .unwrap_or("New Query"); | |||||
| if let Err(err) = save_query(&state, title, &full_body).await { | |||||
| let _ = send_event( | |||||
| &mut socket, | |||||
| &LlmWsEvent::Error { message: err.to_string() }, | |||||
| ) | |||||
| .await; | |||||
| return; | |||||
| } | |||||
| let _ = send_event(&mut socket, &LlmWsEvent::Done { reason }).await; | |||||
| return; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| async fn send_event( | |||||
| socket: &mut WebSocket, | |||||
| event: &LlmWsEvent, | |||||
| ) -> Result<(), axum::Error> { | |||||
| socket | |||||
| .send(Message::text(serde_json::to_string(event).unwrap())) | |||||
| .await | |||||
| } | |||||
| async fn save_query(state: &AppState, title: &str, body: &str) -> Result<Uuid, ApiError> { | |||||
| let result = sqlx::query("insert into queries (title) values ($1) returning id") | |||||
| .bind(title) | |||||
| .fetch_one(&state.db) | |||||
| .await | |||||
| .map_err(|_| ApiError::Internal(anyhow::Error::msg("Failed to save query")))?; | |||||
| let query_id: Uuid = result.get("id"); | |||||
| sqlx::query("insert into query_messages (query_id, message) values ($1, $2)") | |||||
| .bind(query_id) | |||||
| .bind(body) | |||||
| .execute(&state.db) | |||||
| .await | |||||
| .map_err(|_| ApiError::Internal(anyhow::Error::msg("Failed to save query")))?; | |||||
| Ok(query_id) | |||||
| } | |||||
| @@ -0,0 +1,4 @@ | |||||
| pub mod health; | |||||
| pub mod items; | |||||
| pub mod llm; | |||||
| pub mod queries; | |||||
| @@ -0,0 +1,61 @@ | |||||
| use axum::extract::{Path, Query, State}; | |||||
| use axum::Json; | |||||
| use uuid::Uuid; | |||||
| use crate::AppState; | |||||
| use crate::error::{ApiError, ApiResult}; | |||||
| use crate::models::llm::{LlmQuery, LlmQueryDetail, SavedQueriesRequest, SavedQueriesResponse}; | |||||
| #[utoipa::path( | |||||
| get, | |||||
| path = "/api/v1/queries/list", | |||||
| tag = "queries", | |||||
| description = "Get all saved queries", | |||||
| params( | |||||
| ("limit" = Option<i64>, Query, description = "Maximum number of queries to return") | |||||
| ), | |||||
| responses( | |||||
| (status = 200, description = "Fetched LLM queries", body = SavedQueriesResponse), | |||||
| (status = 400, description = "Validation error", body = crate::error::ErrorBody) | |||||
| ) | |||||
| )] | |||||
| pub async fn get_queries(State(state): State<AppState>, Query(params): Query<SavedQueriesRequest>) -> ApiResult<Json<SavedQueriesResponse>> { | |||||
| let limit = params.limit.unwrap_or(50); | |||||
| let result = sqlx::query_as::<_, LlmQuery>("select id, title from queries order by id desc limit $1") | |||||
| .bind(limit) | |||||
| .fetch_all(&state.db) | |||||
| .await | |||||
| .map_err(|_| ApiError::Internal(anyhow::Error::msg("Failed to get queries")))?; | |||||
| Ok(Json(SavedQueriesResponse { queries: result })) | |||||
| } | |||||
| #[utoipa::path( | |||||
| get, | |||||
| path = "/api/v1/queries/{id}", | |||||
| tag = "queries", | |||||
| description = "Get a single saved query by ID", | |||||
| params(("id" = Uuid,)), | |||||
| responses( | |||||
| (status = 200, description = "Fetched LLM query", body = LlmQueryDetail), | |||||
| (status = 404, description = "Query not found", body = crate::error::ErrorBody), | |||||
| (status = 400, description = "Validation error", body = crate::error::ErrorBody) | |||||
| ) | |||||
| )] | |||||
| pub async fn get_query(State(state): State<AppState>, Path(id): Path<Uuid>) -> ApiResult<Json<LlmQueryDetail>> { | |||||
| let query = sqlx::query!("select id, title from queries where id = $1", id) | |||||
| .fetch_optional(&state.db) | |||||
| .await | |||||
| .map_err(|_| ApiError::Internal(anyhow::Error::msg("Failed to fetch query")))? | |||||
| .ok_or_else(|| ApiError::NotFound(format!("Query {} not found", id)))?; | |||||
| let messages = sqlx::query!("select message from query_messages where query_id = $1 order by id asc", id) | |||||
| .fetch_all(&state.db) | |||||
| .await | |||||
| .map_err(|_| ApiError::Internal(anyhow::Error::msg("Failed to fetch query messages")))?; | |||||
| Ok(Json(LlmQueryDetail { | |||||
| id: query.id, | |||||
| title: query.title, | |||||
| messages: messages.into_iter().map(|m| m.message).collect(), | |||||
| })) | |||||
| } | |||||
| @@ -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,71 @@ | |||||
| 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 database = server::config::redact_db_url(&config.database.url); | |||||
| tracing::info!(%database, "connecting"); | |||||
| let pool = db::connect(&config) | |||||
| .await | |||||
| .map_err(|err| anyhow::anyhow!("database connect failed ({database}): {err}"))?; | |||||
| if config.database.run_migrations { | |||||
| db::migrate(&pool).await?; | |||||
| tracing::info!("migrations applied"); | |||||
| } | |||||
| // 4. HTTP client | |||||
| let client = reqwest::Client::builder() | |||||
| .timeout(std::time::Duration::from_secs(480)) | |||||
| .build()?; | |||||
| let state = AppState::new(config.clone(), pool, client); | |||||
| // 5. Router | |||||
| let addr = config.socket_addr()?; | |||||
| let app = routes::router(state); | |||||
| // 6. 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,107 @@ | |||||
| use serde::{Deserialize, Serialize}; | |||||
| use sqlx::FromRow; | |||||
| use uuid::Uuid; | |||||
| #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] | |||||
| #[serde(rename_all = "camelCase")] | |||||
| pub struct LlmResponse { | |||||
| pub model: String, | |||||
| #[serde(rename = "created_at")] | |||||
| pub created_at: String, | |||||
| pub message: ResponseMessage, | |||||
| pub done: bool, | |||||
| #[serde(rename = "done_reason")] | |||||
| pub done_reason: String, | |||||
| #[serde(rename = "total_duration")] | |||||
| pub total_duration: i64, | |||||
| #[serde(rename = "load_duration")] | |||||
| pub load_duration: i64, | |||||
| #[serde(rename = "prompt_eval_count")] | |||||
| pub prompt_eval_count: i64, | |||||
| #[serde(rename = "prompt_eval_duration")] | |||||
| pub prompt_eval_duration: i64, | |||||
| #[serde(rename = "eval_count")] | |||||
| pub eval_count: i64, | |||||
| #[serde(rename = "eval_duration")] | |||||
| pub eval_duration: i64, | |||||
| } | |||||
| #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] | |||||
| #[serde(rename_all = "camelCase")] | |||||
| pub struct ResponseMessage { | |||||
| pub role: String, | |||||
| pub content: String, | |||||
| pub thinking: String, | |||||
| } | |||||
| #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] | |||||
| #[serde(rename_all = "camelCase")] | |||||
| pub struct LlmRequest { | |||||
| pub model: String, | |||||
| pub messages: Vec<RequestMessage>, | |||||
| pub stream: Option<bool>, | |||||
| pub options: Options, | |||||
| } | |||||
| #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] | |||||
| #[serde(rename_all = "camelCase")] | |||||
| pub struct RequestMessage { | |||||
| pub role: String, | |||||
| pub content: String, | |||||
| } | |||||
| #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] | |||||
| #[serde(rename_all = "camelCase")] | |||||
| pub struct Options { | |||||
| pub temperature: f64, | |||||
| #[serde(rename = "num_predict")] | |||||
| pub num_predict: Option<i64>, | |||||
| } | |||||
| #[derive(serde::Serialize, utoipa::ToSchema)] | |||||
| #[serde(tag = "type", rename_all = "snake_case")] | |||||
| pub enum LlmWsEvent { | |||||
| Delta { content: String }, | |||||
| Done { reason: String }, | |||||
| Error { message: String }, | |||||
| } | |||||
| #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] | |||||
| #[serde(tag = "type", rename_all = "snake_case")] | |||||
| pub struct SaveQueryRequest { | |||||
| pub title: String, | |||||
| pub body: String, | |||||
| } | |||||
| #[derive(Serialize, Deserialize, utoipa::ToSchema)] | |||||
| #[serde(tag = "type", rename_all = "snake_case")] | |||||
| pub struct SaveQueryResponse { | |||||
| pub id: Uuid, | |||||
| } | |||||
| #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] | |||||
| #[serde(tag = "type", rename_all = "snake_case")] | |||||
| pub struct SavedQueriesRequest { | |||||
| pub limit: Option<i64>, | |||||
| } | |||||
| #[derive(Serialize, Deserialize, utoipa::ToSchema)] | |||||
| #[serde(tag = "type", rename_all = "snake_case")] | |||||
| pub struct SavedQueriesResponse { | |||||
| pub queries: Vec<LlmQuery>, | |||||
| } | |||||
| #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema, FromRow)] | |||||
| #[serde(tag = "type", rename_all = "snake_case")] | |||||
| pub struct LlmQuery { | |||||
| pub id: Uuid, | |||||
| pub title: String, | |||||
| } | |||||
| #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] | |||||
| #[serde(tag = "type", rename_all = "snake_case")] | |||||
| pub struct LlmQueryDetail { | |||||
| pub id: Uuid, | |||||
| pub title: String, | |||||
| pub messages: Vec<String>, | |||||
| } | |||||
| @@ -0,0 +1,4 @@ | |||||
| pub mod health; | |||||
| pub mod item; | |||||
| pub mod pagination; | |||||
| pub mod llm; | |||||
| @@ -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,111 @@ | |||||
| use crate::routes::llm::__path_send; | |||||
| use axum::Router; | |||||
| use axum::http::{HeaderValue, Method, header}; | |||||
| use axum::middleware; | |||||
| use axum::routing::get; | |||||
| 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, llm, queries}; | |||||
| use crate::handlers::llm::send; | |||||
| 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)) | |||||
| .routes(routes!(send)) | |||||
| .routes(routes!(queries::get_queries)) | |||||
| .routes(routes!(queries::get_query)) | |||||
| } | |||||
| 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,37 @@ | |||||
| use std::ops::Deref; | |||||
| use std::sync::Arc; | |||||
| use sqlx::PgPool; | |||||
| use crate::config::AppConfig; | |||||
| #[derive(Clone)] | |||||
| pub struct AppState(pub(crate) Arc<InnerState>); | |||||
| pub struct InnerState { | |||||
| pub config: AppConfig, | |||||
| pub db: PgPool, | |||||
| pub client: reqwest::Client, | |||||
| } | |||||
| impl AppState { | |||||
| pub fn new(config: AppConfig, db: PgPool, client: reqwest::Client) -> Self { | |||||
| Self(Arc::new(InnerState { config, db, client })) | |||||
| } | |||||
| 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,44 @@ | |||||
| use std::sync::Mutex; | |||||
| use server::AppConfig; | |||||
| use server::config::redact_db_url; | |||||
| static ENV_LOCK: Mutex<()> = Mutex::new(()); | |||||
| #[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 _guard = ENV_LOCK.lock().expect("env lock"); | |||||
| 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()); | |||||
| } | |||||
| #[test] | |||||
| fn app_double_underscore_overrides_database_url() { | |||||
| let _guard = ENV_LOCK.lock().expect("env lock"); | |||||
| unsafe { | |||||
| std::env::set_var("APP__DATABASE__URL", "postgres://jared:secret@127.0.0.1:5432/mydb"); | |||||
| } | |||||
| let config = AppConfig::load().expect("load config with env override"); | |||||
| unsafe { | |||||
| std::env::remove_var("APP__DATABASE__URL"); | |||||
| } | |||||
| assert_eq!(config.database.url, "postgres://jared:secret@127.0.0.1:5432/mydb"); | |||||
| } | |||||
| #[test] | |||||
| fn redact_db_url_strips_password() { | |||||
| assert_eq!( | |||||
| redact_db_url("postgres://app:hunter2@127.0.0.1:5432/app"), | |||||
| "postgres://app@127.0.0.1:5432/app" | |||||
| ); | |||||
| } | |||||
| @@ -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,31 @@ | |||||
| { | |||||
| "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", | |||||
| "vue-router": "^4.2.2", | |||||
| "pinia": "^2.1.5", | |||||
| "marked": "^4.3.0", | |||||
| "dompurify": "^3.0.1" | |||||
| }, | |||||
| "devDependencies": { | |||||
| "@tailwindcss/vite": "^4.1.11", | |||||
| "@types/marked": "^4.3.0", | |||||
| "@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,31 @@ | |||||
| <script setup lang="ts"> | |||||
| import HealthBadge from "@/components/HealthBadge.vue"; | |||||
| import QueryList from "@/components/QueryList.vue"; | |||||
| </script> | |||||
| <template> | |||||
| <div class="flex min-h-screen"> | |||||
| <aside class="w-64 border-r border-line p-6 overflow-y-auto"> | |||||
| <QueryList /> | |||||
| </aside> | |||||
| <div class="flex w-full flex-col px-6 py-10"> | |||||
| <div class="mx-auto flex w-full max-w-5xl flex-1 flex-col"> | |||||
| <header class="flex flex-wrap items-end justify-between gap-4 border-b border-line pb-8"> | |||||
| <h1 class="text-3xl font-bold"><RouterLink to="/">Lyra</RouterLink></h1> | |||||
| </header> | |||||
| <RouterView /> | |||||
| <footer class="flex flex-row justify-between border-t border-line pt-6 text-xs text-muted mt-auto"> | |||||
| <span> | |||||
| Docs live at | |||||
| <a class="text-accent hover:underline" href="/api/docs" target="_blank" rel="noreferrer"> | |||||
| /api/docs | |||||
| </a> | |||||
| </span> | |||||
| <span> | |||||
| <HealthBadge /> | |||||
| </span> | |||||
| </footer> | |||||
| </div> | |||||
| </div> | |||||
| </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,176 @@ | |||||
| /* 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 LlmQuery = { | |||||
| id: string; | |||||
| title: string; | |||||
| }; | |||||
| export type LlmQueryDetail = { | |||||
| id: string; | |||||
| messages: Array<string>; | |||||
| title: string; | |||||
| }; | |||||
| export type ReadyResponse = { | |||||
| database: string; | |||||
| status: string; | |||||
| }; | |||||
| export type SavedQueriesResponse = { | |||||
| queries: Array<LlmQuery>; | |||||
| }; | |||||
| 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 send(): Promise<void> { | |||||
| return request<void>({ | |||||
| method: "GET", | |||||
| path: "/api/v1/llm/ws", | |||||
| expectedStatus: 200, | |||||
| }); | |||||
| } | |||||
| export function getQueries(args: { | |||||
| query?: { | |||||
| limit?: number; | |||||
| } | |||||
| }): Promise<SavedQueriesResponse> { | |||||
| return request<SavedQueriesResponse>({ | |||||
| method: "GET", | |||||
| path: "/api/v1/queries/list", | |||||
| query: args.query, | |||||
| expectedStatus: 200, | |||||
| }); | |||||
| } | |||||
| export function getQuery(args: { | |||||
| path: { | |||||
| id: string; | |||||
| } | |||||
| }): Promise<LlmQueryDetail> { | |||||
| return request<LlmQueryDetail>({ | |||||
| method: "GET", | |||||
| path: "/api/v1/queries/" + encodeURIComponent(String(args.path.id)), | |||||
| 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,84 @@ | |||||
| <script setup lang="ts"> | |||||
| import { onMounted, onUnmounted, ref } from 'vue' | |||||
| const query = defineModel<string>({ default: '' }) | |||||
| let emit = defineEmits(['onChange', 'submit']); | |||||
| const inputRef = ref<HTMLInputElement | null>(null) | |||||
| const triggerChange = () => { | |||||
| emit('onChange', query.value) | |||||
| } | |||||
| const submit = () => { | |||||
| const value = query.value.trim() | |||||
| if (!value) return | |||||
| emit('submit', value) | |||||
| } | |||||
| const onGlobalKey = (event: KeyboardEvent) => { | |||||
| if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return | |||||
| const target = event.target as HTMLElement | null | |||||
| const tag = target?.tagName | |||||
| if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return | |||||
| event.preventDefault() | |||||
| inputRef.value?.focus() | |||||
| } | |||||
| onMounted(() => window.addEventListener('keydown', onGlobalKey)) | |||||
| onUnmounted(() => window.removeEventListener('keydown', onGlobalKey)) | |||||
| </script> | |||||
| <template> | |||||
| <form class="mx-auto w-full max-w-2xl" @submit.prevent="submit"> | |||||
| <label class="sr-only" for="query-box">Query</label> | |||||
| <div | |||||
| class="group flex h-14 items-center gap-3 rounded-2xl border border-line bg-white/[0.035] px-3.5 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.04)] transition-[border-color,box-shadow,background-color] duration-200 focus-within:border-accent/55 focus-within:bg-white/[0.05] focus-within:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.06),0_0_0_3px_color-mix(in_oklab,var(--color-accent,oklch(0.82_0.1_180))_18%,transparent)]" | |||||
| > | |||||
| <span class="grid h-8 w-8 shrink-0 place-items-center rounded-xl border border-line text-muted"> | |||||
| <svg | |||||
| class="h-3.5 w-3.5" | |||||
| viewBox="0 0 16 16" | |||||
| fill="none" | |||||
| stroke="currentColor" | |||||
| stroke-width="1.5" | |||||
| aria-hidden="true" | |||||
| > | |||||
| <circle cx="7" cy="7" r="4.25" /> | |||||
| <path d="M10.4 10.4 14 14" stroke-linecap="round" /> | |||||
| </svg> | |||||
| </span> | |||||
| <input | |||||
| id="query-box" | |||||
| ref="inputRef" | |||||
| v-model="query" | |||||
| type="text" | |||||
| autocomplete="off" | |||||
| spellcheck="false" | |||||
| placeholder="Ask something…" | |||||
| class="h-full min-w-0 flex-1 bg-transparent text-[15px] leading-none text-white caret-accent outline-none placeholder:text-muted/70" | |||||
| @input="triggerChange" | |||||
| /> | |||||
| <kbd | |||||
| class="hidden h-6 shrink-0 items-center rounded-md border border-line px-1.5 font-mono text-[10px] tracking-wide text-muted sm:inline-flex" | |||||
| > | |||||
| / | |||||
| </kbd> | |||||
| <button | |||||
| type="submit" | |||||
| class="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-xl bg-accent/15 px-3 text-xs font-medium text-accent transition-colors hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40" | |||||
| :disabled="!query.trim()" | |||||
| > | |||||
| Send | |||||
| <svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"> | |||||
| <path d="M2 6h8M7 3l3 3-3 3" stroke-linecap="round" stroke-linejoin="round" /> | |||||
| </svg> | |||||
| </button> | |||||
| </div> | |||||
| </form> | |||||
| </template> | |||||
| @@ -0,0 +1,28 @@ | |||||
| <script setup lang="ts"> | |||||
| import {onMounted, ref} from "vue"; | |||||
| import {getQueries, LlmQuery} from "@/api/generated.ts"; | |||||
| const savedQueries = ref([] as LlmQuery[]); | |||||
| onMounted(async () => { | |||||
| const resp = await getQueries({query: { limit: 10 }}); | |||||
| savedQueries.value = resp.queries; | |||||
| }) | |||||
| </script> | |||||
| <template> | |||||
| <div class="flex flex-col gap-4"> | |||||
| <h2 class="text-sm font-semibold uppercase tracking-wider text-muted/50">Recent Queries</h2> | |||||
| <ul class="flex flex-col gap-2"> | |||||
| <li v-for="query in savedQueries" :key="query.id" class="truncate text-sm text-ink hover:text-accent cursor-pointer"> | |||||
| <RouterLink :to="`/response/${query.id}`"> | |||||
| {{ query.title || 'Untitled Query' }} | |||||
| </RouterLink> | |||||
| </li> | |||||
| </ul> | |||||
| </div> | |||||
| </template> | |||||
| <style scoped> | |||||
| </style> | |||||
| @@ -0,0 +1,15 @@ | |||||
| import { createApp } from "vue"; | |||||
| import { createMemoryHistory, createRouter } from 'vue-router'; | |||||
| import {routes} from './routes'; | |||||
| import "./style.css"; | |||||
| import { createPinia } from 'pinia'; | |||||
| import App from "./App.vue"; | |||||
| const pinia = createPinia(); | |||||
| export const router = createRouter({ | |||||
| history: createMemoryHistory(), | |||||
| routes, | |||||
| }) | |||||
| createApp(App).use(router).use(pinia).mount("#app"); | |||||
| @@ -0,0 +1,8 @@ | |||||
| import HomeView from "@/views/HomeView.vue"; | |||||
| import ResponseView from "@/views/ResponseView.vue"; | |||||
| export const routes = [ | |||||
| { path: '/', component: HomeView }, | |||||
| { path: '/response', component: ResponseView }, | |||||
| { path: '/response/:id', component: ResponseView }, | |||||
| ] | |||||
| @@ -0,0 +1,18 @@ | |||||
| import { defineStore } from 'pinia'; | |||||
| export const usePromptStore = defineStore('prompt', { | |||||
| state: () => ({ | |||||
| prompt: '' | |||||
| }), | |||||
| actions: { | |||||
| setPrompt(prompt: string) { | |||||
| this.prompt = prompt; | |||||
| }, | |||||
| getPrompt() { | |||||
| return this.prompt; | |||||
| }, | |||||
| clearPrompt() { | |||||
| this.prompt = ''; | |||||
| } | |||||
| } | |||||
| }); | |||||
| @@ -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,38 @@ | |||||
| <script setup lang="ts"> | |||||
| import QueryBox from "@/components/QueryBox.vue"; | |||||
| import {ref} from "vue"; | |||||
| import {usePromptStore} from "@/stores/prompt.ts"; | |||||
| import {useRouter} from "vue-router"; | |||||
| const promptStore = usePromptStore(); | |||||
| const router = useRouter(); | |||||
| const query = ref(''); | |||||
| const submitQuery = async () => { | |||||
| promptStore.setPrompt(query.value); | |||||
| await router.push('/response'); | |||||
| } | |||||
| const updateQuery = (v: string) => { | |||||
| query.value = v; | |||||
| } | |||||
| </script> | |||||
| <template> | |||||
| <main class="relative flex flex-1"> | |||||
| <div class="absolute inset-x-0 top-[42%] -translate-y-1/2 px-1"> | |||||
| <QueryBox @on-change="updateQuery" @submit="submitQuery" /> | |||||
| <p class="mt-3 text-center text-[11px] text-muted"> | |||||
| Enter to send | |||||
| <span class="mx-1.5 text-muted/50">·</span> | |||||
| <kbd class="rounded border border-line px-1 py-px font-mono text-[10px]">/</kbd> | |||||
| to focus | |||||
| </p> | |||||
| </div> | |||||
| </main> | |||||
| </template> | |||||
| <style scoped> | |||||
| </style> | |||||
| @@ -0,0 +1,273 @@ | |||||
| <script setup lang="ts"> | |||||
| import {usePromptStore} from "@/stores/prompt.ts"; | |||||
| import {computed, onBeforeMount, ref} from "vue"; | |||||
| import {marked} from 'marked'; | |||||
| import DOMPurify from 'dompurify'; | |||||
| import {useRoute, useRouter} from "vue-router"; | |||||
| import {getQuery} from "@/api/generated.ts"; | |||||
| const promptStore = usePromptStore(); | |||||
| const route = useRoute(); | |||||
| const prompt = ref(''); | |||||
| const response = ref(''); | |||||
| const router = useRouter(); | |||||
| let ws: WebSocket | null = null; | |||||
| const initWebSocket = () => { | |||||
| const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; | |||||
| ws = new WebSocket(`${proto}//${location.host}/api/v1/llm/ws`); | |||||
| ws.onopen = () => { | |||||
| if (prompt.value === '') return; | |||||
| ws?.send(JSON.stringify({ | |||||
| model: 'qwen2.5:14b', | |||||
| messages: [{role: 'user', content: prompt.value}], | |||||
| stream: true, | |||||
| options: { | |||||
| temperature: 0.7, | |||||
| } | |||||
| })) | |||||
| } | |||||
| ws.onmessage = (e) => { | |||||
| const ev = JSON.parse(e.data); | |||||
| if (ev.type === 'delta') appendToken(ev.content); | |||||
| if (ev.type === 'done') finish(); | |||||
| if (ev.type === 'error') showError(ev.message); | |||||
| } | |||||
| } | |||||
| const appendToken = (token: string) => { | |||||
| response.value += token; | |||||
| } | |||||
| const finish = async () => { | |||||
| ws?.close(); | |||||
| } | |||||
| const showError = (msg: string) => { | |||||
| console.error(msg); | |||||
| } | |||||
| onBeforeMount(async () => { | |||||
| const queryId = route.params.id as string; | |||||
| if (queryId) { | |||||
| try { | |||||
| const query = await getQuery({path: {id: queryId}}); | |||||
| prompt.value = query.title; | |||||
| response.value = query.messages.join('\n'); | |||||
| } catch (e) { | |||||
| console.error("Failed to fetch query", e); | |||||
| router.push('/'); | |||||
| } | |||||
| return; | |||||
| } | |||||
| prompt.value = promptStore.getPrompt(); | |||||
| promptStore.clearPrompt(); | |||||
| if (prompt.value === '') { | |||||
| await router.push('/'); | |||||
| return; | |||||
| } | |||||
| initWebSocket(); | |||||
| }) | |||||
| const renderedResponse = computed(() => { | |||||
| return DOMPurify.sanitize(marked.parse(response.value, {sanitize: true})); | |||||
| }) | |||||
| </script> | |||||
| <template> | |||||
| <div class="py-2"> | |||||
| <h2 class="text-xl py-1 font-bold">{{ prompt }}</h2> | |||||
| <div class="markdown"> | |||||
| <div v-html="renderedResponse"></div> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <style scoped> | |||||
| .markdown { | |||||
| --text: #d9e1ec; | |||||
| --muted: #94a3b8; | |||||
| --heading: #f1f5f9; | |||||
| --border: #273449; | |||||
| --border-strong: #34445d; | |||||
| --surface: #151c27; | |||||
| --surface-raised: #1a2331; | |||||
| --code-bg: #112130; | |||||
| --accent: #5eead4; | |||||
| --link: #7dd3fc; | |||||
| color: var(--text); | |||||
| font-size: 0.95rem; | |||||
| line-height: 1.7; | |||||
| overflow-wrap: anywhere; | |||||
| } | |||||
| .markdown :deep(p) { | |||||
| margin: 0.65rem 0; | |||||
| } | |||||
| .markdown :deep(h1), | |||||
| .markdown :deep(h2), | |||||
| .markdown :deep(h3), | |||||
| .markdown :deep(h4) { | |||||
| color: var(--heading); | |||||
| font-weight: 650; | |||||
| letter-spacing: -0.015em; | |||||
| line-height: 1.25; | |||||
| } | |||||
| .markdown :deep(h1) { | |||||
| font-size: 1.5rem; | |||||
| margin: 1.5rem 0 0.7rem; | |||||
| } | |||||
| .markdown :deep(h2) { | |||||
| font-size: 1.25rem; | |||||
| margin: 1.3rem 0 0.55rem; | |||||
| padding-bottom: 0.35rem; | |||||
| border-bottom: 1px solid var(--border); | |||||
| } | |||||
| .markdown :deep(h3) { | |||||
| font-size: 1.06rem; | |||||
| margin: 1rem 0 0.35rem; | |||||
| } | |||||
| .markdown :deep(h4) { | |||||
| color: #cbd5e1; | |||||
| font-size: 0.94rem; | |||||
| margin: 0.85rem 0 0.25rem; | |||||
| } | |||||
| .markdown :deep(strong) { | |||||
| color: #f8fafc; | |||||
| font-weight: 700; | |||||
| } | |||||
| .markdown :deep(em) { | |||||
| color: #cbd5e1; | |||||
| } | |||||
| .markdown :deep(a) { | |||||
| color: var(--link); | |||||
| text-decoration: none; | |||||
| text-underline-offset: 0.18em; | |||||
| } | |||||
| .markdown :deep(a:hover) { | |||||
| color: #bae6fd; | |||||
| text-decoration: underline; | |||||
| } | |||||
| .markdown :deep(p > code), | |||||
| .markdown :deep(li > code), | |||||
| .markdown :deep(td > code) { | |||||
| color: #b8f5e8; | |||||
| font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, | |||||
| Menlo, Monaco, Consolas, monospace; | |||||
| font-size: 0.84em; | |||||
| line-height: 1; | |||||
| background: var(--code-bg); | |||||
| border: 1px solid #24506a; | |||||
| padding: 0.12rem 0.32rem; | |||||
| border-radius: 0.3rem; | |||||
| white-space: break-spaces; | |||||
| } | |||||
| .markdown :deep(pre) { | |||||
| margin: 0.9rem 0; | |||||
| padding: 0.85rem 1rem; | |||||
| overflow-x: auto; | |||||
| background: #0e1621; | |||||
| border: 1px solid var(--border-strong); | |||||
| border-radius: 0.55rem; | |||||
| box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.025); | |||||
| } | |||||
| .markdown :deep(pre code) { | |||||
| display: block; | |||||
| color: #dbeafe; | |||||
| font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, | |||||
| Menlo, Monaco, Consolas, monospace; | |||||
| font-size: 0.8rem; | |||||
| line-height: 1.65; | |||||
| background: transparent; | |||||
| border: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .markdown :deep(ul), | |||||
| .markdown :deep(ol) { | |||||
| margin: 0.65rem 0; | |||||
| padding-left: 1.35rem; | |||||
| } | |||||
| .markdown :deep(li) { | |||||
| margin: 0.25rem 0; | |||||
| padding-left: 0.15rem; | |||||
| } | |||||
| .markdown :deep(li::marker) { | |||||
| color: var(--accent); | |||||
| } | |||||
| .markdown :deep(blockquote) { | |||||
| margin: 0.85rem 0; | |||||
| padding: 0.25rem 0 0.25rem 0.9rem; | |||||
| color: #b7c4d6; | |||||
| border-left: 3px solid #2dd4bf; | |||||
| background: linear-gradient(90deg, rgb(45 212 191 / 0.08), transparent); | |||||
| } | |||||
| .markdown :deep(hr) { | |||||
| height: 1px; | |||||
| margin: 1.25rem 0; | |||||
| border: 0; | |||||
| background: var(--border); | |||||
| } | |||||
| .markdown :deep(table) { | |||||
| display: block; | |||||
| width: 100%; | |||||
| margin: 0.85rem 0; | |||||
| overflow-x: auto; | |||||
| border: 1px solid var(--border); | |||||
| border-radius: 0.45rem; | |||||
| border-spacing: 0; | |||||
| border-collapse: separate; | |||||
| } | |||||
| .markdown :deep(th), | |||||
| .markdown :deep(td) { | |||||
| padding: 0.55rem 0.7rem; | |||||
| text-align: left; | |||||
| border-bottom: 1px solid var(--border); | |||||
| } | |||||
| .markdown :deep(th) { | |||||
| color: #dbeafe; | |||||
| font-size: 0.82rem; | |||||
| font-weight: 650; | |||||
| background: var(--surface-raised); | |||||
| } | |||||
| .markdown :deep(tr:last-child td) { | |||||
| border-bottom: 0; | |||||
| } | |||||
| .markdown :deep(img) { | |||||
| display: block; | |||||
| max-width: 100%; | |||||
| margin: 0.85rem 0; | |||||
| border: 1px solid var(--border); | |||||
| border-radius: 0.55rem; | |||||
| } | |||||
| </style> | |||||
| @@ -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, ws: 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,54 @@ | |||||
| # 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` | |||||
| That user only exists if you start the bundled compose file. For a local cluster, override with `APP__DATABASE__URL` (two underscores after `APP`) or `config/local.toml`: | |||||
| ```toml | |||||
| [database] | |||||
| url = "postgres://YOUR_USER:YOUR_PASS@127.0.0.1:5432/YOUR_DB" | |||||
| ``` | |||||
| `DATABASE_URL` is for sqlx-cli only. The server does not read it. | |||||
| @@ -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,620 @@ | |||||
| { | |||||
| "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" | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| }, | |||||
| "/api/v1/llm/ws": { | |||||
| "get": { | |||||
| "tags": [ | |||||
| "llm" | |||||
| ], | |||||
| "description": "WebSocket upgrade. After 101, send LlmRequest as one text frame. Server sends LlmWsEvent frames.", | |||||
| "operationId": "send", | |||||
| "responses": { | |||||
| "101": { | |||||
| "description": "Switching Protocols" | |||||
| } | |||||
| } | |||||
| } | |||||
| }, | |||||
| "/api/v1/queries/list": { | |||||
| "get": { | |||||
| "tags": [ | |||||
| "queries" | |||||
| ], | |||||
| "description": "Get all saved queries", | |||||
| "operationId": "get_queries", | |||||
| "parameters": [ | |||||
| { | |||||
| "name": "limit", | |||||
| "in": "query", | |||||
| "description": "Maximum number of queries to return", | |||||
| "required": false, | |||||
| "schema": { | |||||
| "type": "integer", | |||||
| "format": "int64" | |||||
| } | |||||
| } | |||||
| ], | |||||
| "responses": { | |||||
| "200": { | |||||
| "description": "Fetched LLM queries", | |||||
| "content": { | |||||
| "application/json": { | |||||
| "schema": { | |||||
| "$ref": "#/components/schemas/SavedQueriesResponse" | |||||
| } | |||||
| } | |||||
| } | |||||
| }, | |||||
| "400": { | |||||
| "description": "Validation error", | |||||
| "content": { | |||||
| "application/json": { | |||||
| "schema": { | |||||
| "$ref": "#/components/schemas/ErrorBody" | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| }, | |||||
| "/api/v1/queries/{id}": { | |||||
| "get": { | |||||
| "tags": [ | |||||
| "queries" | |||||
| ], | |||||
| "description": "Get a single saved query by ID", | |||||
| "operationId": "get_query", | |||||
| "parameters": [ | |||||
| { | |||||
| "name": "id", | |||||
| "in": "path", | |||||
| "required": true, | |||||
| "schema": { | |||||
| "type": "string", | |||||
| "format": "uuid" | |||||
| } | |||||
| } | |||||
| ], | |||||
| "responses": { | |||||
| "200": { | |||||
| "description": "Fetched LLM query", | |||||
| "content": { | |||||
| "application/json": { | |||||
| "schema": { | |||||
| "$ref": "#/components/schemas/LlmQueryDetail" | |||||
| } | |||||
| } | |||||
| } | |||||
| }, | |||||
| "400": { | |||||
| "description": "Validation error", | |||||
| "content": { | |||||
| "application/json": { | |||||
| "schema": { | |||||
| "$ref": "#/components/schemas/ErrorBody" | |||||
| } | |||||
| } | |||||
| } | |||||
| }, | |||||
| "404": { | |||||
| "description": "Query not found", | |||||
| "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" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "LlmQuery": { | |||||
| "type": "object", | |||||
| "required": [ | |||||
| "id", | |||||
| "title" | |||||
| ], | |||||
| "properties": { | |||||
| "id": { | |||||
| "type": "string", | |||||
| "format": "uuid" | |||||
| }, | |||||
| "title": { | |||||
| "type": "string" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "LlmQueryDetail": { | |||||
| "type": "object", | |||||
| "required": [ | |||||
| "id", | |||||
| "title", | |||||
| "messages" | |||||
| ], | |||||
| "properties": { | |||||
| "id": { | |||||
| "type": "string", | |||||
| "format": "uuid" | |||||
| }, | |||||
| "messages": { | |||||
| "type": "array", | |||||
| "items": { | |||||
| "type": "string" | |||||
| } | |||||
| }, | |||||
| "title": { | |||||
| "type": "string" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "ReadyResponse": { | |||||
| "type": "object", | |||||
| "required": [ | |||||
| "status", | |||||
| "database" | |||||
| ], | |||||
| "properties": { | |||||
| "database": { | |||||
| "type": "string" | |||||
| }, | |||||
| "status": { | |||||
| "type": "string" | |||||
| } | |||||
| } | |||||
| }, | |||||
| "SavedQueriesResponse": { | |||||
| "type": "object", | |||||
| "required": [ | |||||
| "queries" | |||||
| ], | |||||
| "properties": { | |||||
| "queries": { | |||||
| "type": "array", | |||||
| "items": { | |||||
| "$ref": "#/components/schemas/LlmQuery" | |||||
| } | |||||
| } | |||||
| } | |||||
| }, | |||||
| "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,230 @@ | |||||
| #!/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, argPrefix) { | |||||
| let template = JSON.stringify(pathName); | |||||
| for (const name of paramNames) { | |||||
| template = template.replace(`{${name}}`, `" + encodeURIComponent(String(${argPrefix}.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 argPrefix = (pathParams.length || queryParams.length || bodySchema) ? "args" : "_args"; | |||||
| const argList = args.length ? `${argPrefix}: {\n ${args.join(";\n ")}\n}` : ""; | |||||
| const returnType = responseSchema ? tsType(responseSchema, spec) : "void"; | |||||
| const pathExpr = pathToTemplate( | |||||
| pathName, | |||||
| pathParams.map((param) => param.name), | |||||
| argPrefix, | |||||
| ); | |||||
| const queryLine = queryParams.length ? ` query: ${argPrefix}.query,` : ""; | |||||
| const bodyLine = bodySchema ? ` body: ${argPrefix}.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}`); | |||||