Skip to main content

allsource_core/infrastructure/web/
config_api.rs

1use crate::{
2    error::{AllSourceError, Result},
3    infrastructure::{
4        repositories::event_sourced_config_repository::ConfigCondition,
5        security::middleware::Admin, web::api_v1::AppState,
6    },
7};
8use axum::{Json, extract::State, http::StatusCode};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12// ============================================================================
13// Request/Response Types
14// ============================================================================
15
16#[derive(Debug, Deserialize)]
17pub struct SetConfigRequest {
18    pub key: String,
19    pub value: serde_json::Value,
20    pub changed_by: Option<String>,
21    pub condition: Option<ConfigCondition>,
22}
23
24#[derive(Debug, Deserialize)]
25pub struct UpdateConfigRequest {
26    pub value: serde_json::Value,
27    pub changed_by: Option<String>,
28}
29
30#[derive(Debug, Serialize)]
31pub struct ConfigEntryResponse {
32    pub key: String,
33    pub value: serde_json::Value,
34    pub updated_at: DateTime<Utc>,
35    pub updated_by: Option<String>,
36    pub revision: uuid::Uuid,
37}
38
39#[derive(Debug, Serialize)]
40pub struct ListConfigsResponse {
41    pub configs: Vec<ConfigEntryResponse>,
42    pub total: usize,
43}
44
45// ============================================================================
46// Handlers
47// ============================================================================
48
49/// List all config entries
50/// GET /api/v1/config
51pub async fn list_configs(
52    State(state): State<AppState>,
53    Admin(_): Admin,
54) -> Result<Json<ListConfigsResponse>> {
55    let config_repo = state
56        .service_container
57        .config_repository()
58        .ok_or_else(|| AllSourceError::InternalError("Config repository not configured".into()))?;
59
60    let entries = config_repo.list();
61    let total = entries.len();
62
63    let configs: Vec<ConfigEntryResponse> = entries
64        .into_iter()
65        .map(|e| ConfigEntryResponse {
66            key: e.key,
67            value: e.value,
68            updated_at: e.updated_at,
69            updated_by: e.updated_by,
70            revision: e.revision,
71        })
72        .collect();
73
74    Ok(Json(ListConfigsResponse { configs, total }))
75}
76
77/// Get a config entry by key
78/// GET /api/v1/config/:key
79pub async fn get_config(
80    State(state): State<AppState>,
81    Admin(_): Admin,
82    axum::extract::Path(key): axum::extract::Path<String>,
83) -> Result<Json<ConfigEntryResponse>> {
84    let config_repo = state
85        .service_container
86        .config_repository()
87        .ok_or_else(|| AllSourceError::InternalError("Config repository not configured".into()))?;
88
89    let entry = config_repo
90        .get(&key)
91        .ok_or_else(|| AllSourceError::EntityNotFound(format!("Config key not found: {key}")))?;
92
93    Ok(Json(ConfigEntryResponse {
94        key: entry.key,
95        value: entry.value,
96        updated_at: entry.updated_at,
97        updated_by: entry.updated_by,
98        revision: entry.revision,
99    }))
100}
101
102/// Set a config entry (upsert)
103/// POST /api/v1/config
104pub async fn set_config(
105    State(state): State<AppState>,
106    Admin(_): Admin,
107    Json(req): Json<SetConfigRequest>,
108) -> Result<(StatusCode, Json<serde_json::Value>)> {
109    let config_repo = state
110        .service_container
111        .config_repository()
112        .ok_or_else(|| AllSourceError::InternalError("Config repository not configured".into()))?;
113
114    if req.key.is_empty() {
115        return Err(AllSourceError::InvalidInput(
116            "Config key cannot be empty".into(),
117        ));
118    }
119
120    let entry = config_repo.set_conditionally(
121        &req.key,
122        req.value,
123        req.changed_by.as_deref(),
124        req.condition.as_ref(),
125    )?;
126
127    tracing::debug!("Config set: {}", req.key);
128
129    Ok((
130        StatusCode::OK,
131        Json(serde_json::json!({
132            "key": req.key,
133            "saved": true,
134            "revision": entry.revision,
135        })),
136    ))
137}
138
139/// Set a config entry with a required atomic precondition.
140/// POST /api/v1/config/conditional/set
141/// Conditional clients use this distinct route so older servers cannot silently
142/// ignore an unknown condition field and perform an unconditional upsert.
143pub async fn set_config_conditionally(
144    state: State<AppState>,
145    admin: Admin,
146    Json(req): Json<SetConfigRequest>,
147) -> Result<(StatusCode, Json<serde_json::Value>)> {
148    if req.condition.is_none() {
149        return Err(AllSourceError::InvalidInput(
150            "A configuration condition is required".into(),
151        ));
152    }
153    set_config(state, admin, Json(req)).await
154}
155
156/// Update a config entry
157/// PUT /api/v1/config/:key
158pub async fn update_config(
159    State(state): State<AppState>,
160    Admin(_): Admin,
161    axum::extract::Path(key): axum::extract::Path<String>,
162    Json(req): Json<UpdateConfigRequest>,
163) -> Result<Json<ConfigEntryResponse>> {
164    let config_repo = state
165        .service_container
166        .config_repository()
167        .ok_or_else(|| AllSourceError::InternalError("Config repository not configured".into()))?;
168
169    // Verify key exists
170    if config_repo.get(&key).is_none() {
171        return Err(AllSourceError::EntityNotFound(format!(
172            "Config key not found: {key}"
173        )));
174    }
175
176    config_repo.set(&key, req.value.clone(), req.changed_by.as_deref())?;
177
178    let entry = config_repo.get(&key).ok_or_else(|| {
179        AllSourceError::InternalError("Config entry disappeared after update".into())
180    })?;
181
182    tracing::debug!("Config updated: {}", key);
183
184    Ok(Json(ConfigEntryResponse {
185        key: entry.key,
186        value: entry.value,
187        updated_at: entry.updated_at,
188        updated_by: entry.updated_by,
189        revision: entry.revision,
190    }))
191}
192
193/// Delete a config entry
194/// DELETE /api/v1/config/:key
195pub async fn delete_config(
196    State(state): State<AppState>,
197    Admin(_): Admin,
198    axum::extract::Path(key): axum::extract::Path<String>,
199) -> Result<StatusCode> {
200    let config_repo = state
201        .service_container
202        .config_repository()
203        .ok_or_else(|| AllSourceError::InternalError("Config repository not configured".into()))?;
204
205    let deleted = config_repo.delete(&key, None)?;
206
207    if !deleted {
208        return Err(AllSourceError::EntityNotFound(format!(
209            "Config key not found: {key}"
210        )));
211    }
212
213    tracing::debug!("Config deleted: {}", key);
214
215    Ok(StatusCode::NO_CONTENT)
216}