Skip to main content

max / makenotwork

9.9 KB · 274 lines History Blame Raw
1 //! HTTP client for the MNW internal API.
2 //!
3 //! One client, one method per endpoint, split into a module per endpoint
4 //! family. Each family adds its own `impl MnwApiClient` block: a descendant
5 //! module sees the parent's private fields, so the token and base-URL state
6 //! stays private to this module and every family still reaches it.
7
8 use serde::{Deserialize, Serialize};
9 use std::collections::BTreeMap;
10
11 use crate::currency::{Currency, RevenueByCurrency};
12
13 mod analytics;
14 mod domains;
15 mod git;
16 mod items;
17 mod projects;
18 mod storefront;
19 mod uploads;
20
21 pub(crate) use analytics::{AnalyticsData, Transaction};
22 pub(crate) use git::SshKeyInfo;
23 pub(crate) use items::{ItemDetail, TagInfo, Version};
24 pub(crate) use projects::{CreatorStats, Item, Project, StorageInfo};
25 pub(crate) use storefront::{BlogPost, CollectionInfo, LicenseKey, PromoCode, TierInfo};
26 pub(crate) use uploads::MULTIPART_THRESHOLD_BYTES;
27
28 /// User info returned from the SSH key lookup endpoint.
29 #[derive(Debug, Clone, Deserialize, Serialize)]
30 pub(crate) struct UserInfo {
31 pub user_id: String,
32 pub username: String,
33 pub display_name: Option<String>,
34 pub creator_tier: Option<String>,
35 pub can_create_projects: bool,
36 pub suspended: bool,
37 /// Signed actor assertion the server mints at lookup; forwarded as
38 /// `X-MNW-Actor` on internal calls so the server derives identity from an
39 /// SSH-authenticated token rather than a caller-supplied `user_id`.
40 #[serde(default)]
41 pub actor_token: String,
42 /// The currency this creator is paid in. Every amount that is theirs —
43 /// their prices, their period totals — renders in it. Defaulted for the
44 /// window where a new CLI talks to a server that predates the field.
45 #[serde(default)]
46 pub settlement_currency: Currency,
47 /// The theme this creator picked, as `makeover::ThemeSelection` encodes it:
48 /// a theme id, or `"system"` to follow the terminal.
49 ///
50 /// `None` where the server did not send the field, which is every server
51 /// today — it is not stored yet, and adding it is filed as its own task.
52 /// Absent and unset are the same thing until then, and both follow the
53 /// terminal, so this reads correctly on both sides of that change.
54 #[serde(default)]
55 pub theme_id: Option<String>,
56 }
57
58 /// Read a revenue figure that arrives as both a dominant amount and a full
59 /// per-currency map.
60 ///
61 /// The map is authoritative when present. It is empty in two cases that must
62 /// not render blank: a server too old to send it, and a project with no sales.
63 /// Both fall back to the single pair.
64 ///
65 /// A zero amount then reduces to nothing, and the render falls through to the
66 /// viewer's own currency. That is the right symbol for it: with no sales there
67 /// is no currency the money is *in*, and the `currency` the server names for an
68 /// empty total is its own default rather than a fact about the project.
69 pub(super) fn revenue_of(
70 cents: i64,
71 currency: Currency,
72 by_currency: &BTreeMap<String, i64>,
73 ) -> RevenueByCurrency {
74 if by_currency.is_empty() {
75 RevenueByCurrency::from_rows([(currency, cents)])
76 } else {
77 RevenueByCurrency::from_wire_map(by_currency)
78 }
79 }
80
81 /// Bail with the server's own error detail unless the response succeeded.
82 ///
83 /// The body is worth the extra read: the API answers a rejected request with a
84 /// reason, and without this the operator sees a bare status code.
85 async fn bail_for_status(
86 resp: reqwest::Response,
87 context: &str,
88 ) -> anyhow::Result<reqwest::Response> {
89 if resp.status().is_success() {
90 return Ok(resp);
91 }
92 let status = resp.status();
93 let body = resp.text().await.unwrap_or_else(|e| {
94 tracing::warn!(error = %e, %context, "failed to read error response body");
95 String::new()
96 });
97 if body.is_empty() {
98 anyhow::bail!("{context} failed: HTTP {status}");
99 }
100 anyhow::bail!("{context} failed: HTTP {status}, {body}");
101 }
102
103 /// Check response status and deserialize JSON body, or bail with error details.
104 pub(super) async fn json_response<T: serde::de::DeserializeOwned>(
105 resp: reqwest::Response,
106 context: &str,
107 ) -> anyhow::Result<T> {
108 Ok(bail_for_status(resp, context).await?.json().await?)
109 }
110
111 /// Check response status for success, or bail with error details.
112 pub(super) async fn empty_response(resp: reqwest::Response, context: &str) -> anyhow::Result<()> {
113 bail_for_status(resp, context).await?;
114 Ok(())
115 }
116
117 /// Client for calling MNW internal API endpoints.
118 #[derive(Clone)]
119 pub(crate) struct MnwApiClient {
120 http: reqwest::Client,
121 base_url: String,
122 service_token: String,
123 /// Set once per session from the SSH-key-lookup response; forwarded on
124 /// internal creator calls as `X-MNW-Actor`.
125 actor_token: Option<String>,
126 }
127
128 impl MnwApiClient {
129 pub(crate) fn new(base_url: String, service_token: String) -> Self {
130 let http = crate::tls::builder()
131 .timeout(std::time::Duration::from_secs(5))
132 .build()
133 .expect("failed to build HTTP client");
134
135 Self {
136 http,
137 base_url,
138 service_token,
139 actor_token: None,
140 }
141 }
142
143 /// Record the actor assertion for the authenticated session. Subsequent
144 /// internal calls forward it so the server can verify the acting identity.
145 pub(crate) fn set_actor_token(&mut self, token: String) {
146 self.actor_token = Some(token);
147 }
148
149 /// The `X-MNW-Actor` header value for internal calls (empty before lookup).
150 fn actor_header(&self) -> &str {
151 self.actor_token.as_deref().unwrap_or("")
152 }
153
154 /// Look up a user by SSH key fingerprint.
155 /// Returns `Ok(Some(info))` if found, `Ok(None)` if not found.
156 pub(crate) async fn lookup_ssh_key(
157 &self,
158 fingerprint: &str,
159 ) -> anyhow::Result<Option<UserInfo>> {
160 let url = format!("{}/api/internal/ssh-key-lookup", self.base_url);
161 let resp = self
162 .http
163 .get(&url)
164 .bearer_auth(&self.service_token)
165 .header("X-MNW-Actor", self.actor_header())
166 .query(&[("fingerprint", fingerprint)])
167 .send()
168 .await?;
169
170 if resp.status() == reqwest::StatusCode::NOT_FOUND {
171 return Ok(None);
172 }
173
174 if !resp.status().is_success() {
175 anyhow::bail!("SSH key lookup failed: HTTP {}", resp.status());
176 }
177
178 let info: UserInfo = resp.json().await?;
179 Ok(Some(info))
180 }
181 }
182
183 #[cfg(test)]
184 mod tests {
185 use super::analytics::ProjectRevenue;
186 use super::*;
187
188 /// The shape `/api/internal/creator/projects` sends today.
189 fn project_json(extra: &str) -> String {
190 format!(
191 r#"{{"id":"p1","slug":"s","title":"T","project_type":"music",
192 "is_public":true,"item_count":2,"revenue_cents":90000{extra}}}"#
193 )
194 }
195
196 #[test]
197 fn a_project_renders_the_currency_the_server_named() {
198 let p: Project = serde_json::from_str(&project_json(
199 r#","currency":"gbp","revenue_cents_by_currency":{"gbp":90000}"#,
200 ))
201 .unwrap();
202 assert_eq!(p.currency, Currency::Gbp);
203 assert_eq!(p.revenue().display(Currency::Usd), "\u{a3}900.00");
204 }
205
206 #[test]
207 fn a_project_spanning_two_currencies_shows_both() {
208 // The whole point of the task: never one of them, never their sum.
209 let p: Project = serde_json::from_str(&project_json(
210 r#","currency":"gbp","revenue_cents_by_currency":{"gbp":90000,"usd":12000}"#,
211 ))
212 .unwrap();
213 assert_eq!(p.revenue().display(Currency::Usd), "\u{a3}900.00 + $120.00");
214 assert_eq!(
215 p.revenue().display_compact(Currency::Usd),
216 "\u{a3}900.00 +1"
217 );
218 }
219
220 #[test]
221 fn a_response_without_the_currency_fields_still_parses_as_usd() {
222 // A new CLI against a server that predates the settlement-currency pass
223 // must render exactly what it always did, not fail to load the screen.
224 let p: Project = serde_json::from_str(&project_json("")).unwrap();
225 assert_eq!(p.currency, Currency::Usd);
226 assert_eq!(p.revenue().display(Currency::Usd), "$900.00");
227 }
228
229 #[test]
230 fn a_project_with_no_sales_renders_zero_in_the_viewers_currency() {
231 // An empty cell here would read as "no data" rather than "no revenue".
232 // The `currency` the server names on an empty total is its own default,
233 // so the viewer's own is what the zero renders in.
234 let p: Project = serde_json::from_str(
235 r#"{"id":"p1","slug":"s","title":"T","project_type":"music","is_public":true,
236 "item_count":0,"revenue_cents":0,"currency":"usd","revenue_cents_by_currency":{}}"#,
237 )
238 .unwrap();
239 assert_eq!(p.revenue().display(Currency::Gbp), "\u{a3}0");
240 assert_eq!(p.revenue().display_compact(Currency::Gbp), "\u{a3}0");
241 }
242
243 #[test]
244 fn the_login_lookup_carries_the_viewers_currency() {
245 let u: UserInfo = serde_json::from_str(
246 r#"{"user_id":"u1","username":"max","display_name":null,"creator_tier":"basic",
247 "can_create_projects":true,"suspended":false,"actor_token":"t",
248 "settlement_currency":"cad"}"#,
249 )
250 .unwrap();
251 assert_eq!(u.settlement_currency, Currency::Cad);
252 }
253
254 #[test]
255 fn a_login_lookup_without_the_field_defaults_to_usd() {
256 let u: UserInfo = serde_json::from_str(
257 r#"{"user_id":"u1","username":"max","display_name":null,"creator_tier":null,
258 "can_create_projects":true,"suspended":false,"actor_token":"t"}"#,
259 )
260 .unwrap();
261 assert_eq!(u.settlement_currency, Currency::Usd);
262 }
263
264 #[test]
265 fn top_project_revenue_reads_the_same_contract() {
266 let p: ProjectRevenue = serde_json::from_str(
267 r#"{"id":"p1","title":"T","revenue_cents":5000,"currency":"nzd",
268 "revenue_cents_by_currency":{"nzd":5000}}"#,
269 )
270 .unwrap();
271 assert_eq!(p.revenue().display(Currency::Usd), "NZ$50.00");
272 }
273 }
274