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
532 changes: 528 additions & 4 deletions Cargo.lock

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ tracing-subscriber = { workspace = true }

[workspace]
members = [
"crates/studio-types",
"crates/studio-core",
"crates/studio-store",
"crates/studio-buzz",
Expand All @@ -44,9 +45,15 @@ axum = { version = "0.8", features = ["macros"] }
# Storage — SQLite projection, rebuildable by design (ARCHITECTURE.md §1)
sqlx = { version = "0.9", features = ["runtime-tokio", "tls-rustls", "sqlite", "migrate", "chrono", "json"] }

# Serialization
# Serialization — schemars pin matches the buzz workspace (version = "1")
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "1"

# Domain
bech32 = "0.11"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }

# Errors + observability
anyhow = "1"
Expand All @@ -55,6 +62,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

# Intra-workspace
studio-types = { path = "crates/studio-types" }
studio-core = { path = "crates/studio-core" }
studio-store = { path = "crates/studio-store" }
studio-buzz = { path = "crates/studio-buzz" }
Expand Down
4 changes: 4 additions & 0 deletions crates/studio-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ license.workspace = true
publish = false

[dependencies]
studio-core = { workspace = true }
studio-store = { workspace = true }
studio-types = { workspace = true }

axum = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }

[dev-dependencies]
tokio = { workspace = true }
Expand Down
29 changes: 29 additions & 0 deletions crates/studio-api/src/endpoints/api_index.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//! `GET /api/v1` — the discovery index. A client (human, CLI, or agent) that
//! knows only the base URL can enumerate every endpoint and fetch the JSON
//! Schema of every wire type from here.

use axum::{http::StatusCode, response::IntoResponse, Json};

pub async fn handler() -> impl IntoResponse {
let schemas: Vec<serde_json::Value> = studio_types::schemas::all()
.into_iter()
.map(|(name, _)| serde_json::json!({ "name": name, "href": format!("/api/v1/schemas/{name}") }))
.collect();

(
StatusCode::OK,
Json(serde_json::json!({
"service": "scarce-studio",
"version": env!("CARGO_PKG_VERSION"),
"endpoints": [
{ "method": "GET", "path": "/api/v1", "description": "this index" },
{ "method": "GET", "path": "/api/v1/schemas/{name}", "description": "JSON Schema of a wire type" },
{ "method": "POST", "path": "/api/v1/rfqs", "description": "capture a demand record (schema: rfq)" },
{ "method": "GET", "path": "/api/v1/rfqs", "description": "list captured RFQs, oldest first (?since=<rfc3339>)" },
{ "method": "GET", "path": "/api/v1/rfqs/{id}", "description": "fetch one captured RFQ" },
],
"schemas": schemas,
"errors": "validation failures return 422 with { errors: [{ field, message }] }",
})),
)
}
60 changes: 60 additions & 0 deletions crates/studio-api/src/endpoints/create_rfq.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//! `POST /api/v1/rfqs` — demand capture. Free, unsigned, frictionless: the
//! miss record is the studio's order book; never tax it (ARCHITECTURE.md §4).

use std::sync::Arc;

use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use studio_types::NewRfq;

use crate::AppState;

pub async fn handler(
State(state): State<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
// Deserialize by hand so shape errors come back as 422 field errors,
// matching the validation contract, instead of axum's opaque rejection.
let new_rfq: NewRfq = match serde_json::from_value(body) {
Ok(rfq) => rfq,
Err(e) => {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({
"errors": [{ "field": "body", "message": e.to_string() }]
})),
)
}
};

// The handler only supplies identity and time; validation and assembly
// are the core's single capture path, shared with any future CLI/MCP.
let rfq = match studio_core::rfq::capture(
new_rfq,
uuid::Uuid::new_v4().to_string(),
chrono::Utc::now(),
) {
Ok(rfq) => rfq,
Err(errors) => {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({ "errors": errors })),
)
}
};

match studio_store::rfqs::insert(&state.db, &rfq).await {
Ok(()) => {
tracing::info!(rfq_id = %rfq.id, buyer = %rfq.buyer_npub, "rfq captured");
(StatusCode::CREATED, Json(serde_json::json!(rfq)))
}
Err(e) => {
tracing::error!(error = %e, "rfq insert failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(
serde_json::json!({ "errors": [{ "field": "server", "message": "storage failure" }] }),
),
)
}
}
}
32 changes: 32 additions & 0 deletions crates/studio-api/src/endpoints/get_rfq.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//! `GET /api/v1/rfqs/{id}` — free read of a captured demand record.

use std::sync::Arc;

use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};

use crate::AppState;

pub async fn handler(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
match studio_store::rfqs::get(&state.db, &id).await {
Ok(Some(rfq)) => (StatusCode::OK, Json(serde_json::json!(rfq))),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "rfq not found" })),
),
Err(e) => {
tracing::error!(error = %e, rfq_id = %id, "rfq read failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "storage failure" })),
)
}
}
}
24 changes: 24 additions & 0 deletions crates/studio-api/src/endpoints/get_schema.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//! `GET /api/v1/schemas/{name}` — serves the generated JSON Schemas, so the
//! wire contract is discoverable from the running service itself (same values
//! as the checked-in `schemas/*.json`).

use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json};

pub async fn handler(Path(name): Path<String>) -> impl IntoResponse {
match studio_types::schemas::get(&name) {
Some(schema) => (StatusCode::OK, Json(schema)),
None => {
let available: Vec<&str> = studio_types::schemas::all()
.into_iter()
.map(|(n, _)| n)
.collect();
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("unknown schema {name:?}"),
"available": available,
})),
)
}
}
}
51 changes: 51 additions & 0 deletions crates/studio-api/src/endpoints/list_rfqs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! `GET /api/v1/rfqs?since=<rfc3339>` — the order book, oldest first.

use std::sync::Arc;

use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use chrono::{DateTime, Utc};
use serde::Deserialize;

use crate::AppState;

#[derive(Deserialize)]
pub struct ListParams {
/// RFC 3339 timestamp; only RFQs captured at or after it are returned.
pub since: Option<String>,
}

pub async fn handler(
State(state): State<Arc<AppState>>,
Query(params): Query<ListParams>,
) -> impl IntoResponse {
let since: Option<DateTime<Utc>> = match params.since.as_deref() {
None => None,
Some(raw) => match DateTime::parse_from_rfc3339(raw) {
Ok(ts) => Some(ts.with_timezone(&Utc)),
Err(e) => {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({
"errors": [{ "field": "since", "message": format!("must be RFC 3339: {e}") }]
})),
)
}
},
};

match studio_store::rfqs::list_since(&state.db, since).await {
Ok(rfqs) => (StatusCode::OK, Json(serde_json::json!({ "rfqs": rfqs }))),
Err(e) => {
tracing::error!(error = %e, "rfq list failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "storage failure" })),
)
}
}
}
11 changes: 8 additions & 3 deletions crates/studio-api/src/endpoints/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
//! One module per endpoint (SF API conventions). M1 opens with the RFQ
//! routes; `/healthz` lives in the crate root until there is a second
//! resident.
//! One module per endpoint (SF API conventions). `/healthz` lives in the
//! crate root.

pub mod api_index;
pub mod create_rfq;
pub mod get_rfq;
pub mod get_schema;
pub mod list_rfqs;
27 changes: 24 additions & 3 deletions crates/studio-api/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
//! HTTP surface of `scarced` — serves projections, never asserts a state it
//! cannot evidence (ARCHITECTURE.md §1). Routes accrete per milestone; M0 is
//! `GET /healthz` only.
//! cannot evidence (ARCHITECTURE.md §1). Routes accrete per milestone under
//! `/api/v1`; the surface is self-describing (`GET /api/v1` lists endpoints,
//! `GET /api/v1/schemas/{name}` serves the generated JSON Schemas). Handlers
//! stay thin: parse, call `studio-core`, serialize — the logic they invoke is
//! reusable from a CLI or MCP surface without HTTP.

use std::sync::Arc;

use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use sqlx::SqlitePool;

pub mod endpoints;
Expand All @@ -16,8 +25,20 @@ pub struct AppState {
}

pub fn router(state: Arc<AppState>) -> Router {
// `/healthz` stays unversioned (ops convention); everything else is
// `/api/v1` so the contract can evolve without breaking callers.
Router::new()
.route("/healthz", get(healthz))
.route("/api/v1", get(endpoints::api_index::handler))
.route(
"/api/v1/schemas/{name}",
get(endpoints::get_schema::handler),
)
.route(
"/api/v1/rfqs",
post(endpoints::create_rfq::handler).get(endpoints::list_rfqs::handler),
)
.route("/api/v1/rfqs/{id}", get(endpoints::get_rfq::handler))
.with_state(state)
}

Expand Down
Loading
Loading