//! HTTP client for the MNW internal API. //! //! One client, one method per endpoint, split into a module per endpoint //! family. Each family adds its own `impl MnwApiClient` block: a descendant //! module sees the parent's private fields, so the token and base-URL state //! stays private to this module and every family still reaches it. use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use crate::currency::{Currency, RevenueByCurrency}; mod analytics; mod domains; mod git; mod items; mod projects; mod storefront; mod uploads; pub(crate) use analytics::{AnalyticsData, Transaction}; pub(crate) use git::SshKeyInfo; pub(crate) use items::{ItemDetail, TagInfo, Version}; pub(crate) use projects::{CreatorStats, Item, Project, StorageInfo}; pub(crate) use storefront::{BlogPost, CollectionInfo, LicenseKey, PromoCode, TierInfo}; pub(crate) use uploads::MULTIPART_THRESHOLD_BYTES; /// User info returned from the SSH key lookup endpoint. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct UserInfo { pub user_id: String, pub username: String, pub display_name: Option, pub creator_tier: Option, pub can_create_projects: bool, pub suspended: bool, /// Signed actor assertion the server mints at lookup; forwarded as /// `X-MNW-Actor` on internal calls so the server derives identity from an /// SSH-authenticated token rather than a caller-supplied `user_id`. #[serde(default)] pub actor_token: String, /// The currency this creator is paid in. Every amount that is theirs — /// their prices, their period totals — renders in it. Defaulted for the /// window where a new CLI talks to a server that predates the field. #[serde(default)] pub settlement_currency: Currency, /// The theme this creator picked, as `makeover::ThemeSelection` encodes it: /// a theme id, or `"system"` to follow the terminal. /// /// `None` where the server did not send the field, which is every server /// today — it is not stored yet, and adding it is filed as its own task. /// Absent and unset are the same thing until then, and both follow the /// terminal, so this reads correctly on both sides of that change. #[serde(default)] pub theme_id: Option, } /// Read a revenue figure that arrives as both a dominant amount and a full /// per-currency map. /// /// The map is authoritative when present. It is empty in two cases that must /// not render blank: a server too old to send it, and a project with no sales. /// Both fall back to the single pair. /// /// A zero amount then reduces to nothing, and the render falls through to the /// viewer's own currency. That is the right symbol for it: with no sales there /// is no currency the money is *in*, and the `currency` the server names for an /// empty total is its own default rather than a fact about the project. pub(super) fn revenue_of( cents: i64, currency: Currency, by_currency: &BTreeMap, ) -> RevenueByCurrency { if by_currency.is_empty() { RevenueByCurrency::from_rows([(currency, cents)]) } else { RevenueByCurrency::from_wire_map(by_currency) } } /// Bail with the server's own error detail unless the response succeeded. /// /// The body is worth the extra read: the API answers a rejected request with a /// reason, and without this the operator sees a bare status code. async fn bail_for_status( resp: reqwest::Response, context: &str, ) -> anyhow::Result { if resp.status().is_success() { return Ok(resp); } let status = resp.status(); let body = resp.text().await.unwrap_or_else(|e| { tracing::warn!(error = %e, %context, "failed to read error response body"); String::new() }); if body.is_empty() { anyhow::bail!("{context} failed: HTTP {status}"); } anyhow::bail!("{context} failed: HTTP {status}, {body}"); } /// Check response status and deserialize JSON body, or bail with error details. pub(super) async fn json_response( resp: reqwest::Response, context: &str, ) -> anyhow::Result { Ok(bail_for_status(resp, context).await?.json().await?) } /// Check response status for success, or bail with error details. pub(super) async fn empty_response(resp: reqwest::Response, context: &str) -> anyhow::Result<()> { bail_for_status(resp, context).await?; Ok(()) } /// Client for calling MNW internal API endpoints. #[derive(Clone)] pub(crate) struct MnwApiClient { http: reqwest::Client, base_url: String, service_token: String, /// Set once per session from the SSH-key-lookup response; forwarded on /// internal creator calls as `X-MNW-Actor`. actor_token: Option, } impl MnwApiClient { pub(crate) fn new(base_url: String, service_token: String) -> Self { let http = crate::tls::builder() .timeout(std::time::Duration::from_secs(5)) .build() .expect("failed to build HTTP client"); Self { http, base_url, service_token, actor_token: None, } } /// Record the actor assertion for the authenticated session. Subsequent /// internal calls forward it so the server can verify the acting identity. pub(crate) fn set_actor_token(&mut self, token: String) { self.actor_token = Some(token); } /// The `X-MNW-Actor` header value for internal calls (empty before lookup). fn actor_header(&self) -> &str { self.actor_token.as_deref().unwrap_or("") } /// Look up a user by SSH key fingerprint. /// Returns `Ok(Some(info))` if found, `Ok(None)` if not found. pub(crate) async fn lookup_ssh_key( &self, fingerprint: &str, ) -> anyhow::Result> { let url = format!("{}/api/internal/ssh-key-lookup", self.base_url); let resp = self .http .get(&url) .bearer_auth(&self.service_token) .header("X-MNW-Actor", self.actor_header()) .query(&[("fingerprint", fingerprint)]) .send() .await?; if resp.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } if !resp.status().is_success() { anyhow::bail!("SSH key lookup failed: HTTP {}", resp.status()); } let info: UserInfo = resp.json().await?; Ok(Some(info)) } } #[cfg(test)] mod tests { use super::analytics::ProjectRevenue; use super::*; /// The shape `/api/internal/creator/projects` sends today. fn project_json(extra: &str) -> String { format!( r#"{{"id":"p1","slug":"s","title":"T","project_type":"music", "is_public":true,"item_count":2,"revenue_cents":90000{extra}}}"# ) } #[test] fn a_project_renders_the_currency_the_server_named() { let p: Project = serde_json::from_str(&project_json( r#","currency":"gbp","revenue_cents_by_currency":{"gbp":90000}"#, )) .unwrap(); assert_eq!(p.currency, Currency::Gbp); assert_eq!(p.revenue().display(Currency::Usd), "\u{a3}900.00"); } #[test] fn a_project_spanning_two_currencies_shows_both() { // The whole point of the task: never one of them, never their sum. let p: Project = serde_json::from_str(&project_json( r#","currency":"gbp","revenue_cents_by_currency":{"gbp":90000,"usd":12000}"#, )) .unwrap(); assert_eq!(p.revenue().display(Currency::Usd), "\u{a3}900.00 + $120.00"); assert_eq!( p.revenue().display_compact(Currency::Usd), "\u{a3}900.00 +1" ); } #[test] fn a_response_without_the_currency_fields_still_parses_as_usd() { // A new CLI against a server that predates the settlement-currency pass // must render exactly what it always did, not fail to load the screen. let p: Project = serde_json::from_str(&project_json("")).unwrap(); assert_eq!(p.currency, Currency::Usd); assert_eq!(p.revenue().display(Currency::Usd), "$900.00"); } #[test] fn a_project_with_no_sales_renders_zero_in_the_viewers_currency() { // An empty cell here would read as "no data" rather than "no revenue". // The `currency` the server names on an empty total is its own default, // so the viewer's own is what the zero renders in. let p: Project = serde_json::from_str( r#"{"id":"p1","slug":"s","title":"T","project_type":"music","is_public":true, "item_count":0,"revenue_cents":0,"currency":"usd","revenue_cents_by_currency":{}}"#, ) .unwrap(); assert_eq!(p.revenue().display(Currency::Gbp), "\u{a3}0"); assert_eq!(p.revenue().display_compact(Currency::Gbp), "\u{a3}0"); } #[test] fn the_login_lookup_carries_the_viewers_currency() { let u: UserInfo = serde_json::from_str( r#"{"user_id":"u1","username":"max","display_name":null,"creator_tier":"basic", "can_create_projects":true,"suspended":false,"actor_token":"t", "settlement_currency":"cad"}"#, ) .unwrap(); assert_eq!(u.settlement_currency, Currency::Cad); } #[test] fn a_login_lookup_without_the_field_defaults_to_usd() { let u: UserInfo = serde_json::from_str( r#"{"user_id":"u1","username":"max","display_name":null,"creator_tier":null, "can_create_projects":true,"suspended":false,"actor_token":"t"}"#, ) .unwrap(); assert_eq!(u.settlement_currency, Currency::Usd); } #[test] fn top_project_revenue_reads_the_same_contract() { let p: ProjectRevenue = serde_json::from_str( r#"{"id":"p1","title":"T","revenue_cents":5000,"currency":"nzd", "revenue_cents_by_currency":{"nzd":5000}}"#, ) .unwrap(); assert_eq!(p.revenue().display(Currency::Usd), "NZ$50.00"); } }