Browse Source

first commit

main
Jared Bell 3 weeks ago
commit
9280add110
73 changed files with 9600 additions and 0 deletions
  1. +15
    -0
      .editorconfig
  2. +11
    -0
      .env.example
  3. +3
    -0
      .gitea/template
  4. +19
    -0
      .gitignore
  5. +3367
    -0
      Cargo.lock
  6. +31
    -0
      Cargo.toml
  7. +21
    -0
      LICENSE
  8. +72
    -0
      README.md
  9. +43
    -0
      apps/server/Cargo.toml
  10. +11
    -0
      apps/server/migrations/0001_init.sql
  11. +12
    -0
      apps/server/migrations/0002_queries.sql
  12. +26
    -0
      apps/server/src/bin/export-openapi.rs
  13. +230
    -0
      apps/server/src/config.rs
  14. +18
    -0
      apps/server/src/db.rs
  15. +93
    -0
      apps/server/src/error.rs
  16. +34
    -0
      apps/server/src/handlers/health.rs
  17. +174
    -0
      apps/server/src/handlers/items.rs
  18. +198
    -0
      apps/server/src/handlers/llm.rs
  19. +4
    -0
      apps/server/src/handlers/mod.rs
  20. +61
    -0
      apps/server/src/handlers/queries.rs
  21. +12
    -0
      apps/server/src/lib.rs
  22. +71
    -0
      apps/server/src/main.rs
  23. +1
    -0
      apps/server/src/middleware/mod.rs
  24. +36
    -0
      apps/server/src/middleware/request_log.rs
  25. +14
    -0
      apps/server/src/models/health.rs
  26. +60
    -0
      apps/server/src/models/item.rs
  27. +107
    -0
      apps/server/src/models/llm.rs
  28. +4
    -0
      apps/server/src/models/mod.rs
  29. +48
    -0
      apps/server/src/models/pagination.rs
  30. +111
    -0
      apps/server/src/routes/mod.rs
  31. +37
    -0
      apps/server/src/state.rs
  32. +38
    -0
      apps/server/src/telemetry.rs
  33. +44
    -0
      apps/server/tests/config.rs
  34. +10
    -0
      apps/server/tests/openapi.rs
  35. +4
    -0
      apps/web/.gitignore
  36. +18
    -0
      apps/web/index.html
  37. +2237
    -0
      apps/web/package-lock.json
  38. +31
    -0
      apps/web/package.json
  39. +31
    -0
      apps/web/src/App.vue
  40. +79
    -0
      apps/web/src/api/client.ts
  41. +176
    -0
      apps/web/src/api/generated.ts
  42. +41
    -0
      apps/web/src/components/HealthBadge.vue
  43. +131
    -0
      apps/web/src/components/ItemBoard.vue
  44. +84
    -0
      apps/web/src/components/QueryBox.vue
  45. +28
    -0
      apps/web/src/components/QueryList.vue
  46. +15
    -0
      apps/web/src/main.ts
  47. +8
    -0
      apps/web/src/routes/index.ts
  48. +18
    -0
      apps/web/src/stores/prompt.ts
  49. +34
    -0
      apps/web/src/style.css
  50. +38
    -0
      apps/web/src/views/HomeView.vue
  51. +273
    -0
      apps/web/src/views/ResponseView.vue
  52. +7
    -0
      apps/web/src/vite-env.d.ts
  53. +24
    -0
      apps/web/tsconfig.app.json
  54. +7
    -0
      apps/web/tsconfig.json
  55. +19
    -0
      apps/web/tsconfig.node.json
  56. +125
    -0
      apps/web/vite.config.ts
  57. +7
    -0
      cargo-generate.toml
  58. +39
    -0
      config/default.toml
  59. +7
    -0
      config/development.toml
  60. +17
    -0
      config/production.toml
  61. +30
    -0
      docs/architecture.md
  62. +8
    -0
      docs/decisions/0001-stack.md
  63. +54
    -0
      docs/runbook.md
  64. +19
    -0
      infra/compose/docker-compose.yml
  65. +22
    -0
      infra/docker/nginx.conf
  66. +18
    -0
      infra/docker/server.Dockerfile
  67. +14
    -0
      infra/docker/web.Dockerfile
  68. +32
    -0
      justfile
  69. +11
    -0
      packages/contracts/README.md
  70. +620
    -0
      packages/contracts/openapi.json
  71. +3
    -0
      rust-toolchain.toml
  72. +5
    -0
      rustfmt.toml
  73. +230
    -0
      scripts/generate-api.mjs

+ 15
- 0
.editorconfig View File

@@ -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

+ 11
- 0
.env.example View File

@@ -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

+ 3
- 0
.gitea/template View File

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

+ 19
- 0
.gitignore View File

@@ -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/

+ 3367
- 0
Cargo.lock
File diff suppressed because it is too large
View File


+ 31
- 0
Cargo.toml View File

@@ -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"] }

+ 21
- 0
LICENSE View File

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

Copyright (c) 2026

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

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

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

+ 72
- 0
README.md View File

@@ -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
```

+ 43
- 0
apps/server/Cargo.toml View File

@@ -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"] }

+ 11
- 0
apps/server/migrations/0001_init.sql View File

@@ -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);

+ 12
- 0
apps/server/migrations/0002_queries.sql View File

@@ -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()
);

+ 26
- 0
apps/server/src/bin/export-openapi.rs View File

@@ -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(())
}

+ 230
- 0
apps/server/src/config.rs View File

@@ -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("."))
}

+ 18
- 0
apps/server/src/db.rs View File

@@ -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
}

+ 93
- 0
apps/server/src/error.rs View File

@@ -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>;

+ 34
- 0
apps/server/src/handlers/health.rs View File

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

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

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

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

+ 174
- 0
apps/server/src/handlers/items.rs View File

@@ -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)
}

+ 198
- 0
apps/server/src/handlers/llm.rs View File

@@ -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)
}

+ 4
- 0
apps/server/src/handlers/mod.rs View File

@@ -0,0 +1,4 @@
pub mod health;
pub mod items;
pub mod llm;
pub mod queries;

+ 61
- 0
apps/server/src/handlers/queries.rs View File

@@ -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(),
}))
}

+ 12
- 0
apps/server/src/lib.rs View File

@@ -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;

+ 71
- 0
apps/server/src/main.rs View File

@@ -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");
}

+ 1
- 0
apps/server/src/middleware/mod.rs View File

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

+ 36
- 0
apps/server/src/middleware/request_log.rs View File

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

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

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

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

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

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

response
}

+ 14
- 0
apps/server/src/models/health.rs View File

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

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

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

+ 60
- 0
apps/server/src/models/item.rs View File

@@ -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>,
}

+ 107
- 0
apps/server/src/models/llm.rs View File

@@ -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>,
}

+ 4
- 0
apps/server/src/models/mod.rs View File

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

+ 48
- 0
apps/server/src/models/pagination.rs View File

@@ -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 }
}
}

+ 111
- 0
apps/server/src/routes/mod.rs View File

@@ -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
}

+ 37
- 0
apps/server/src/state.rs View File

@@ -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
}
}

+ 38
- 0
apps/server/src/telemetry.rs View File

@@ -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(())
}

+ 44
- 0
apps/server/tests/config.rs View File

@@ -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"
);
}

+ 10
- 0
apps/server/tests/openapi.rs View File

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

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

+ 4
- 0
apps/web/.gitignore View File

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

+ 18
- 0
apps/web/index.html View File

@@ -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>

+ 2237
- 0
apps/web/package-lock.json
File diff suppressed because it is too large
View File


+ 31
- 0
apps/web/package.json View File

@@ -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"
}
}

+ 31
- 0
apps/web/src/App.vue View File

@@ -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>

+ 79
- 0
apps/web/src/api/client.ts View File

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

+ 176
- 0
apps/web/src/api/generated.ts View File

@@ -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,
});
}

+ 41
- 0
apps/web/src/components/HealthBadge.vue View File

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

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

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

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

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

+ 131
- 0
apps/web/src/components/ItemBoard.vue View File

@@ -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>

+ 84
- 0
apps/web/src/components/QueryBox.vue View File

@@ -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>

+ 28
- 0
apps/web/src/components/QueryList.vue View File

@@ -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>

+ 15
- 0
apps/web/src/main.ts View File

@@ -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");

+ 8
- 0
apps/web/src/routes/index.ts View File

@@ -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 },
]

+ 18
- 0
apps/web/src/stores/prompt.ts View File

@@ -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 = '';
}
}
});

+ 34
- 0
apps/web/src/style.css View File

@@ -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;
}

+ 38
- 0
apps/web/src/views/HomeView.vue View File

@@ -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>

+ 273
- 0
apps/web/src/views/ResponseView.vue View File

@@ -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>

+ 7
- 0
apps/web/src/vite-env.d.ts View File

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

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

+ 24
- 0
apps/web/tsconfig.app.json View File

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

+ 7
- 0
apps/web/tsconfig.json View File

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

+ 19
- 0
apps/web/tsconfig.node.json View File

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

+ 125
- 0
apps/web/vite.config.ts View File

@@ -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 },
},
},
});

+ 7
- 0
cargo-generate.toml View File

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

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

+ 39
- 0
config/default.toml View File

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

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

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

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

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

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

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

+ 7
- 0
config/development.toml View File

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

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

+ 17
- 0
config/production.toml View File

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

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

[database]
max_connections = 20
run_migrations = true

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

+ 30
- 0
docs/architecture.md View File

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

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

## Why this shape

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

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

## Request path

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

## Adding an endpoint

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

+ 8
- 0
docs/decisions/0001-stack.md View File

@@ -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.

+ 54
- 0
docs/runbook.md View File

@@ -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.

+ 19
- 0
infra/compose/docker-compose.yml View File

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

volumes:
rust_template_pg:

+ 22
- 0
infra/docker/nginx.conf View File

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

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

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

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

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

+ 18
- 0
infra/docker/server.Dockerfile View File

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

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

+ 14
- 0
infra/docker/web.Dockerfile View File

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

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

+ 32
- 0
justfile View File

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

default:
@just --list

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

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

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

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

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

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

+ 11
- 0
packages/contracts/README.md View File

@@ -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`.

+ 620
- 0
packages/contracts/openapi.json View File

@@ -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"
}
]
}

+ 3
- 0
rust-toolchain.toml View File

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

+ 5
- 0
rustfmt.toml View File

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

+ 230
- 0
scripts/generate-api.mjs View File

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

Loading…
Cancel
Save