Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

`waveflow-server` is the self-hosted backend for [WaveFlow](https://github.com/InstaZDLL/WaveFlow): an axum (Rust) + PostgreSQL service for multi-device library sync, browser playback, and public shareable playlists. It is a single binary crate (`name = "waveflow-server"`), a Linux/macOS daemon by design — *not* the desktop binary.

The authoritative design lives in [RFC-001](https://github.com/InstaZDLL/WaveFlow/blob/main/docs/rfcs/RFC-001-waveflow-server.md) in the main repo. **Read it before adding a new module.** Work is phased against the main repo's Phase 1 milestone; the README's status line tracks the current sub-phase.

## Commands

```bash
cargo run # connect pool, run migrations, serve on WAVEFLOW_BIND
cargo fmt --all --check # CI gate
cargo clippy --all-targets --all-features -- -D warnings # CI gate; warnings are errors
cargo check --all-targets --all-features
cargo test --all-features # needs a reachable Postgres on DATABASE_URL
cargo test ready # run a single test file / filter by name
```

`cp .env.example .env` first for local dev (`dotenvy` loads `.env` best-effort at boot; release deploys use real env vars). `Config::from_env` (`src/config.rs`) is the single source of truth for the env surface — every tunable is a field there with its env var documented in the doc comment.

### Tests need real Postgres

The integration suite uses `#[sqlx::test]`, which creates a fresh per-test database from `DATABASE_URL`, runs the migrations, and drops it on exit — no manual fixtures. Spin one up:

```bash
docker run --name waveflow-pg -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=postgres -p 5432:5432 -d postgres:17
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres cargo test
```

CI runs the full suite only on Linux (service container); the Windows leg is a compile/clippy guard only (Actions service containers are Linux-only).

## Architecture & conventions

- **Library/binary split.** `src/main.rs` is only runtime plumbing (load `.env`, init tracing, connect pool, run migrations, bind, serve with graceful shutdown). The router is built by `waveflow_server::app(config, state)` in `src/lib.rs` so integration tests spawn the *same* app in-process (`tests/support.rs::spawn_app`). Put logic behind `app()`, not in `main`.
- **`AppState`** (`src/lib.rs`) holds the shared singletons threaded through every handler — currently just the `PgPool` (cheap to clone, `Arc`-backed). Add new singletons here.
- **API is one file per resource** under `src/api/`, each exposing a `router()` merged in `src/api/mod.rs`. `/health` (liveness, no DB) and `/ready` (DB-aware readiness) are unversioned infra probes; every real resource mounts under `/api/v1/`.
- **No SQL in handlers.** SQL lives in the DB layer (`src/db.rs`); handlers stay pure HTTP orchestration. `db::ping` is the pattern — `/ready`'s `SELECT 1` lives there, not in the handler. This mirrors the desktop's Tauri-command ↔ `waveflow-core` boundary.
- **Don't leak DB errors to unauthenticated probes.** `/ready` logs the sqlx error via `tracing::warn!` but returns a fixed sentinel body (`{status, db}`) so a load balancer never sees the connection-URL host or credentials. Apply the same discipline to any other unauthenticated endpoint.
- **Migrations are immutable once merged.** They're embedded at compile time via `sqlx::migrate!("./migrations")` (`db::MIGRATOR`); the `_sqlx_migrations` table stores each file's checksum, so editing an applied migration makes the server refuse to start. Schema changes = a new dated migration file (`YYYYMMDDHHMMSS_name.sql`). Boot applies pending migrations *before* opening the listener, which is what makes `/ready` trustworthy.
- **Schema parity with the desktop SQLite migrations.** Postgres tables mirror the shapes in the desktop repo's `src-tauri/migrations/app/` so `PostgresProfileRepository` and `SqliteProfileRepository` (in `waveflow-core`) satisfy the same trait against identical rows. Keep types compatible (e.g. `BIGSERIAL` ↔ SQLite `INTEGER PK`, epoch-millis `BIGINT` for timestamps).
- **`waveflow-core` is a git dependency pinned by `rev`** (not branch) in `Cargo.toml` for reproducible builds — bump the rev in-tree to pick up a new core release. It provides the repository traits + Postgres impls.
- **Error handling:** `anyhow` at the binary edges (`main`, `Config::from_env`, tests); prefer `thiserror`-typed errors inside modules once the domain is richer than "boot failed."
- **Logging:** `tracing` + `RUST_LOG` filter; `WAVEFLOW_LOG_FORMAT=json` switches to JSON (CI/prod), pretty otherwise (dev). Every request carries an `x-request-id` (generated if absent) as a structured span field — never log full headers (they'd leak Authorization/Cookie).

## Contributing rules that affect commits

- **DCO sign-off is mandatory** — every commit needs a `Signed-off-by:` trailer (`git commit -s`). CI rejects unsigned PRs. The git `user.email` must match a verified GitHub identity. Fix a miss with `git commit --amend -s --no-edit`.
- **Conventional Commits** with kebab-case scopes, lowercase subject — e.g. `feat(api): add /api/v1/playlists endpoint`, `refactor(db): factor pool wiring out of main`.
- License is **AGPL-3.0-only** (the server hosts a network service); `waveflow-core` and the desktop app are GPL-3.0-only.
52 changes: 52 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ waveflow-core = { git = "https://github.com/InstaZDLL/WaveFlow", rev = "3ab43f07
# pulls in the migration runner used by `db::run_migrations`.
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "postgres", "macros", "migrate", "chrono"] }

# OpenAPI 3.1 spec generation. `utoipa-axum` exposes an
# `OpenApiRouter` that picks up every handler tagged with
# `#[utoipa::path]` so the spec stays in lockstep with the routes
# without a parallel paths(...) list to maintain. `utoipa-scalar`
# serves the Scalar API reference (Stripe-style — modern, ~500 KB
# bundle, dark mode, integrated search) from `/reference`.
utoipa = { version = "5", features = ["axum_extras"] }
utoipa-axum = "0.2"
utoipa-scalar = { version = "0.3", features = ["axum"] }

# HTTP layer. axum 0.8 is the line current for Tokio 1.45+ — the same
# generation used by the desktop's reqwest 0.12, so we're not pulling
# two competing hyper trees once `waveflow-core` lands as a dependency
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Self-hosted backend for [WaveFlow](https://github.com/InstaZDLL/WaveFlow). Powers multi-device library sync, browser playback, public shareable playlists, and (later) the mobile app.

> **Status:** Phase 1.b.2 — axum skeleton + Postgres pool + first migration + `/ready` probe. CRUD endpoints (1.b.4) and OpenAPI spec (1.b.3) land in the following PRs. Track progress against the Phase 1 milestone on the main repo.
> **Status:** Phase 1.b.3 — axum skeleton + Postgres pool + first migration + `/ready` probe + OpenAPI 3.1 spec at `/openapi.json` + Scalar UI at `/reference`. CRUD endpoints (1.b.4) land in the following PR. Track progress against the Phase 1 milestone on the main repo.

## Architecture

Expand Down Expand Up @@ -31,6 +31,8 @@ cargo run

- `GET /health` — liveness, always returns `200 {status, version}`. Doesn't touch the DB.
- `GET /ready` — readiness, `200 {status: "ready", db: "ok"}` when `SELECT 1` round-trips, `503 {status: "not_ready", db: "unavailable"}` otherwise. The sqlx error detail stays in the `tracing::warn!` log so an unauthenticated probe (e.g. a load balancer) doesn't see the connection-URL host or credentials.
- `GET /openapi.json` — OpenAPI 3.1 spec built from the handlers that carry both a `#[utoipa::path(...)]` annotation and a `routes!()` registration on the per-module `OpenApiRouter`. A plain `Router::route()` would mount the handler but leave it absent from the spec, so make sure new endpoints follow the same `routes!()` pattern as `/health` and `/ready`.
- `GET /reference` — [Scalar](https://github.com/scalar/scalar) API reference UI. Modern, dark-mode-native, integrated search. The OpenAPI spec it renders is the same one served at `/openapi.json`.

CRUD endpoints under `/api/v1/*` (1.b.4) ride on the same pool.

Expand Down
32 changes: 25 additions & 7 deletions src/api/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,39 @@
//! balancer's healthcheck log can correlate "node restarted" with
//! "node upgraded".

use axum::{routing::get, Json, Router};
use axum::Json;
use serde::Serialize;
use utoipa::ToSchema;
use utoipa_axum::{router::OpenApiRouter, routes};

const VERSION: &str = env!("CARGO_PKG_VERSION");

#[derive(Debug, Serialize)]
struct HealthResponse {
status: &'static str,
version: &'static str,
#[derive(Debug, Serialize, ToSchema)]
pub struct HealthResponse {
/// Always `"ok"` while the process is serving requests.
#[schema(example = "ok")]
pub status: &'static str,
/// Mirrors `CARGO_PKG_VERSION` of the running binary. Useful for
/// correlating a healthcheck restart with a deploy.
#[schema(example = "0.0.0")]
pub version: &'static str,
}

pub fn router() -> Router {
Router::new().route("/health", get(health))
pub fn router() -> OpenApiRouter {
OpenApiRouter::new().routes(routes!(health))
}

/// Liveness probe — always succeeds while the process is serving
/// requests. Doesn't touch the database; use `/ready` for a probe
/// that confirms downstream dependencies are reachable.
#[utoipa::path(
get,
path = "/health",
tag = "probes",
responses(
(status = 200, description = "Process is alive", body = HealthResponse),
),
)]
async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok",
Expand Down
13 changes: 9 additions & 4 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,23 @@
//! Versioning policy: every resource module mounts under `/api/v1/`
//! (except `/health` and `/ready`, which are unversioned by convention
//! — they're infrastructure probes, not part of the public API contract).
//!
//! Each module returns a `utoipa_axum::OpenApiRouter` so endpoints
//! tagged with `#[utoipa::path]` show up in the generated OpenAPI spec
//! automatically — no parallel `paths(...)` list to keep in sync.

use axum::Router;
use utoipa_axum::router::OpenApiRouter;

use crate::AppState;

mod health;
mod ready;

/// Combined router for every API module. Mounted at the root by
/// [`crate::app`]; sub-routers prefix their own paths.
pub fn router(state: AppState) -> Router {
Router::new()
/// [`crate::app`]; sub-routers prefix their own paths and contribute
/// their `#[utoipa::path]` declarations to the merged OpenAPI spec.
pub fn router(state: AppState) -> OpenApiRouter {
OpenApiRouter::new()
.merge(health::router())
.merge(ready::router(state))
}
41 changes: 34 additions & 7 deletions src/api/ready.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,48 @@
//! to keep waiting / stop routing traffic; the process keeps
//! running so a transient Postgres blip self-heals.

use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::Serialize;
use utoipa::ToSchema;
use utoipa_axum::{router::OpenApiRouter, routes};

use crate::{db, AppState};

#[derive(Debug, Serialize)]
struct ReadyResponse {
status: &'static str,
db: &'static str,
#[derive(Debug, Serialize, ToSchema)]
pub struct ReadyResponse {
/// `"ready"` when every probed dependency is reachable, `"not_ready"`
/// otherwise.
#[schema(example = "ready")]
pub status: &'static str,
/// `"ok"` when the Postgres pool responded to the connectivity
/// probe, `"unavailable"` otherwise. The sqlx error detail is
/// emitted to `tracing::warn!` only — never returned in the body,
/// since unauthenticated probes (load balancers, healthcheckers)
/// shouldn't see the connection URL host / credentials.
#[schema(example = "ok")]
pub db: &'static str,
}

pub fn router(state: AppState) -> Router {
Router::new().route("/ready", get(ready)).with_state(state)
pub fn router(state: AppState) -> OpenApiRouter {
OpenApiRouter::new()
.routes(routes!(ready))
.with_state(state)
}

/// Readiness probe — confirms every downstream dependency (Postgres
/// today, plugin host + background-job runner later) is reachable.
/// Returns 503 when degraded so a Kubernetes / systemd-style probe
/// stops routing traffic without crashing the process — a transient
/// Postgres blip self-heals.
#[utoipa::path(
get,
path = "/ready",
tag = "probes",
responses(
(status = 200, description = "Every probed dependency is healthy", body = ReadyResponse),
(status = 503, description = "At least one dependency is degraded", body = ReadyResponse),
),
)]
async fn ready(State(state): State<AppState>) -> impl IntoResponse {
// The actual `SELECT 1` lives in `db::ping` so this handler stays
// pure HTTP orchestration — same boundary the project enforces
Expand Down
75 changes: 73 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//!
//! [rfc]: https://github.com/InstaZDLL/WaveFlow/blob/main/docs/rfcs/RFC-001-waveflow-server.md

use axum::{extract::Request, Router};
use axum::{extract::Request, response::IntoResponse, Router};
use sqlx::PgPool;
use std::time::Duration;
use tower::ServiceBuilder;
Expand All @@ -19,13 +19,38 @@ use tower_http::{
trace::TraceLayer,
};
use tracing::field::Empty;
use utoipa::OpenApi;
use utoipa_scalar::{Scalar, Servable};

pub mod api;
pub mod config;
pub mod db;

pub use config::Config;

/// OpenAPI document shell. Tagged endpoints come from each module via
/// `OpenApiRouter::routes(routes!(handler))`, so this struct only
/// declares the shared metadata (title, version, description, tags).
/// The actual `paths(...)` list is filled by [`utoipa_axum`] at router-
/// build time — no parallel list to keep in sync when adding handlers.
#[derive(OpenApi)]
#[openapi(
info(
title = "waveflow-server",
description = "Self-hosted backend for WaveFlow. \
See https://github.com/InstaZDLL/WaveFlow/blob/main/docs/rfcs/RFC-001-waveflow-server.md \
for the architectural intent.",
license(
name = "AGPL-3.0-only",
url = "https://www.gnu.org/licenses/agpl-3.0.html",
),
),
tags(
(name = "probes", description = "Liveness / readiness endpoints for orchestrators."),
),
)]
pub struct ApiDoc;

/// State threaded through the axum router. Holds the singletons that
/// every handler needs — currently just the Postgres pool. Cheap to
/// clone (the pool is `Arc`-backed).
Expand All @@ -39,11 +64,18 @@ pub struct AppState {
/// its own — useful for stitching traces across multiple services.
const REQUEST_ID_HEADER: &str = "x-request-id";

/// Path the generated OpenAPI 3.1 document is served at.
pub const OPENAPI_JSON_PATH: &str = "/openapi.json";

/// Path the Scalar UI (`utoipa-scalar`) is mounted at.
pub const SCALAR_PATH: &str = "/reference";

/// Build the axum router. Wired with:
/// - per-request UUID via `x-request-id` (generated if absent, echoed back).
/// - structured access logging keyed on the request id.
/// - configurable timeout (default 30 s, set via `WAVEFLOW_REQUEST_TIMEOUT_SECS`).
/// - shared [`AppState`] (Postgres pool) attached via `with_state`.
/// - OpenAPI doc at [`OPENAPI_JSON_PATH`] and Scalar UI at [`SCALAR_PATH`].
///
/// `Config` is consumed at build time for the middleware bounds;
/// runtime singletons live in the [`AppState`] threaded through the
Expand Down Expand Up @@ -83,5 +115,44 @@ pub fn app(config: Config, state: AppState) -> Router {
Duration::from_secs(config.request_timeout_secs),
));

Router::new().merge(api::router(state)).layer(middleware)
// Seed the API router with the ApiDoc shell so every module's
// `#[utoipa::path]` declarations merge into it, then split into
// `(Router, OpenApi)` for axum + spec consumption. The doc is
// serialised under `/openapi.json` and rendered by Scalar at
// `/reference`; both stay outside the `/api/v1/*` namespace so a
// future Better-Auth middleware (1.d) gates only the data routes.
let (api_router, openapi) = utoipa_axum::router::OpenApiRouter::with_openapi(ApiDoc::openapi())
.merge(api::router(state))
.split_for_parts();

Router::new()
.merge(api_router)
.merge(Router::from(Scalar::with_url(SCALAR_PATH, openapi.clone())))
.route(
OPENAPI_JSON_PATH,
axum::routing::get(move || {
// `serde_json::to_string` could fail in theory; in
// practice utoipa-built specs always serialise (every
// type comes from a derive macro). Surface the error
// as a 500 if it ever happens so an integration test
// catches a regression.
let openapi = openapi.clone();
async move {
serde_json::to_string(&openapi)
.map(|s| {
([(axum::http::header::CONTENT_TYPE, "application/json")], s)
.into_response()
})
.unwrap_or_else(|err| {
tracing::error!(error = %err, "openapi serialize failed");
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"openapi serialize failed",
)
.into_response()
})
}
}),
)
.layer(middleware)
}
Loading
Loading