chore: scaffold moments workspace
Cargo workspace with five crates per architecture conventions:
- moments-entities: Source enum, Event, EventQuery, SourceSummary
- moments-core: EventReader / EventWriter ports
- moments-data: PgStore (sqlx postgres adapter) + 0001_init.sql
- moments-api: axum binary; /v1/{healthz,events,sources}
- moments-worker: skeleton; pollers land in step 2
Sources committed-to for ingestion: github, gitea, hg, bugzilla.
Workstation events explicitly retired (not deferred).
Build + clippy clean. sqlx queries use the runtime API for now;
will switch to compile-time-checked macros + .sqlx offline cache
once magrathea has the moments_{ro,rw} roles and database created.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
22
crates/moments-api/Cargo.toml
Normal file
22
crates/moments-api/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "moments-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
moments-entities.workspace = true
|
||||
moments-core.workspace = true
|
||||
moments-data.workspace = true
|
||||
tokio.workspace = true
|
||||
axum.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
146
crates/moments-api/src/main.rs
Normal file
146
crates/moments-api/src/main.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::Parser;
|
||||
use moments_core::EventReader;
|
||||
use moments_data::PgStore;
|
||||
use moments_entities::{Event, EventQuery, Source, SourceSummary};
|
||||
use serde::Deserialize;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about = "moments read-only HTTP API")]
|
||||
struct Args {
|
||||
#[arg(long, env = "BIND_ADDR", default_value = "127.0.0.1:8080")]
|
||||
bind: SocketAddr,
|
||||
|
||||
#[arg(long, env = "DATABASE_URL")]
|
||||
database_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
store: Arc<PgStore>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
init_tracing();
|
||||
let args = Args::parse();
|
||||
|
||||
let store = PgStore::connect(&args.database_url).await?;
|
||||
store.migrate().await?;
|
||||
let state = AppState {
|
||||
store: Arc::new(store),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/v1/healthz", get(healthz))
|
||||
.route("/v1/events", get(list_events))
|
||||
.route("/v1/sources", get(list_sources))
|
||||
.with_state(state)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
info!(addr = %args.bind, "listening");
|
||||
let listener = tokio::net::TcpListener::bind(args.bind).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
let json = std::env::var("JOURNAL_STREAM").is_ok();
|
||||
if json {
|
||||
fmt().with_env_filter(filter).json().init();
|
||||
} else {
|
||||
fmt().with_env_filter(filter).init();
|
||||
}
|
||||
}
|
||||
|
||||
async fn healthz() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventsQueryParams {
|
||||
from: Option<DateTime<Utc>>,
|
||||
to: Option<DateTime<Utc>>,
|
||||
/// Comma-separated list, e.g. `source=github,gitea`.
|
||||
source: Option<String>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
async fn list_events(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<EventsQueryParams>,
|
||||
) -> Result<Json<Vec<Event>>, ApiError> {
|
||||
let sources = params
|
||||
.source
|
||||
.as_deref()
|
||||
.map(parse_sources)
|
||||
.transpose()?;
|
||||
|
||||
let limit = params.limit.unwrap_or(100).clamp(1, 1000);
|
||||
|
||||
let query = EventQuery {
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
sources,
|
||||
limit,
|
||||
};
|
||||
|
||||
let events = state.store.list_events(&query).await.map_err(internal)?;
|
||||
Ok(Json(events))
|
||||
}
|
||||
|
||||
async fn list_sources(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<SourceSummary>>, ApiError> {
|
||||
let summaries = state.store.source_summaries().await.map_err(internal)?;
|
||||
Ok(Json(summaries))
|
||||
}
|
||||
|
||||
fn parse_sources(raw: &str) -> Result<Vec<Source>, ApiError> {
|
||||
raw.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.parse::<Source>().map_err(|e| ApiError::bad_request(e.to_string())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct ApiError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn bad_request(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn internal<E: std::fmt::Display>(e: E) -> ApiError {
|
||||
ApiError {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
(self.status, Json(serde_json::json!({ "error": self.message }))).into_response()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user