Skip to main content

edda_client/
admin.rs

1//! Client for the services' admin API (see `edda_server::admin`), for
2//! whoever runs them, such as a hosting's control plane: creating
3//! campaigns and their DMs' setup codes, exporting a whole campaign to keep
4//! or move elsewhere and importing it there, and deleting campaigns.
5
6use edda_auth::protocol::{AuthExport, CampaignSettings, CreatedCampaign, NewCampaign, SetupCode};
7use edda_types::{
8    Edition,
9    admin::{CharactersExport, CombatExport, Deleted, Imported, MonstersExport},
10};
11use serde::{Deserialize, Serialize, de::DeserializeOwned};
12use uuid::Uuid;
13
14use crate::{ClientError, send};
15
16/// One service's admin API: at its `ADMIN_ADDR`, with its `ADMIN_TOKEN`.
17#[derive(Debug, Clone)]
18pub struct AdminClient {
19    base_url: String,
20    http: reqwest::Client,
21}
22
23impl AdminClient {
24    /// The admin API at `base_url`, authorised by `token`.
25    pub fn new(base_url: impl Into<String>, token: &str) -> Self {
26        Self {
27            base_url: base_url.into().trim_end_matches('/').to_string(),
28            http: crate::http_client(Some(token)),
29        }
30    }
31
32    fn url(&self, path: &str) -> String {
33        format!("{}{path}", self.base_url)
34    }
35
36    /// The auth service's: creates a campaign, with the one-time code its
37    /// DM sets up their first device with.
38    pub async fn create_campaign(
39        &self,
40        name: &str,
41        edition: Edition,
42    ) -> Result<CreatedCampaign, ClientError> {
43        let new = NewCampaign {
44            name: name.to_owned(),
45            edition,
46        };
47        send(self.http.post(self.url("/admin/campaigns")).json(&new)).await
48    }
49
50    /// The auth service's: a new setup code for a DM device, as when every
51    /// DM device is lost.
52    pub async fn setup_code(&self, campaign: Uuid) -> Result<String, ClientError> {
53        let url = self.url(&format!("/admin/campaigns/{campaign}/setup-code"));
54        let code: SetupCode = send(self.http.post(url)).await?;
55        Ok(code.code)
56    }
57
58    /// The auth service's: what the campaign may keep and do.
59    pub async fn get_settings(&self, campaign: Uuid) -> Result<CampaignSettings, ClientError> {
60        let url = self.url(&format!("/admin/campaigns/{campaign}/settings"));
61        send(self.http.get(url)).await
62    }
63
64    /// The auth service's: sets what the campaign may keep and do (say,
65    /// read-only until a bill is paid, or a plan's limits). Its devices'
66    /// tokens carry it from their next login, within 15 minutes.
67    pub async fn settings(
68        &self,
69        campaign: Uuid,
70        settings: &CampaignSettings,
71    ) -> Result<CampaignSettings, ClientError> {
72        let url = self.url(&format!("/admin/campaigns/{campaign}/settings"));
73        send(self.http.put(url).json(settings)).await
74    }
75
76    /// Any service's: deletes its part of a campaign, and says how many of
77    /// its things went. Safe to repeat.
78    pub async fn delete_campaign(&self, campaign: Uuid) -> Result<u64, ClientError> {
79        let url = self.url(&format!("/admin/campaigns/{campaign}"));
80        let deleted: Deleted = send(self.http.delete(url)).await?;
81        Ok(deleted.deleted)
82    }
83
84    /// Any service's: its part of a campaign, `T` being that service's
85    /// export (`AuthExport`, `CharactersExport` and so on).
86    pub async fn export<T: DeserializeOwned>(&self, campaign: Uuid) -> Result<T, ClientError> {
87        let url = self.url(&format!("/admin/campaigns/{campaign}/export"));
88        send(self.http.get(url)).await
89    }
90
91    /// Any service's: takes in its part of a campaign exported elsewhere,
92    /// and says how many of its things came. [`ClientError::Conflict`] if
93    /// the campaign has some there already.
94    pub async fn import<T: Serialize>(&self, campaign: Uuid, part: &T) -> Result<u64, ClientError> {
95        let url = self.url(&format!("/admin/campaigns/{campaign}/import"));
96        let imported: Imported =
97            send(self.http.put(url).json(part))
98                .await
99                .map_err(|e| match e {
100                    // A 409 from the admin API is a conflict, not slots run out.
101                    ClientError::Exhausted(message) => ClientError::Conflict(message),
102                    other => other,
103                })?;
104        Ok(imported.imported)
105    }
106}
107
108/// A whole campaign: every service's part of it, as exported to keep or to
109/// move to another server.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct CampaignBundle {
112    /// Which version of this format: [`CampaignBundle::FORMAT`].
113    pub format: u32,
114    pub auth: AuthExport,
115    pub characters: CharactersExport,
116    pub combat: CombatExport,
117    pub monsters: MonstersExport,
118}
119
120impl CampaignBundle {
121    /// The format this version writes and reads.
122    pub const FORMAT: u32 = 1;
123
124    /// Whose it is.
125    pub fn campaign(&self) -> Uuid {
126        self.auth.campaign.id
127    }
128}
129
130/// The four services' admin APIs, to act on a whole campaign at once.
131#[derive(Debug, Clone)]
132pub struct Services {
133    pub auth: AdminClient,
134    pub characters: AdminClient,
135    pub combat: AdminClient,
136    pub monsters: AdminClient,
137}
138
139impl Services {
140    /// Every service's part of `campaign`. Make it read-only first, or
141    /// changes made meanwhile may be in one part and not another.
142    pub async fn export_campaign(&self, campaign: Uuid) -> Result<CampaignBundle, ClientError> {
143        Ok(CampaignBundle {
144            format: CampaignBundle::FORMAT,
145            auth: self.auth.export(campaign).await?,
146            characters: self.characters.export(campaign).await?,
147            combat: self.combat.export(campaign).await?,
148            monsters: self.monsters.export(campaign).await?,
149        })
150    }
151
152    /// Takes in a whole campaign, the auth service's part first. Should a
153    /// part fail, those already in are deleted again, so it's all or
154    /// nothing; [`ClientError::Conflict`] if the campaign is there already.
155    pub async fn import_campaign(&self, bundle: &CampaignBundle) -> Result<(), ClientError> {
156        if bundle.format != CampaignBundle::FORMAT {
157            return Err(ClientError::Rejected(format!(
158                "a campaign of format {}; this version reads {}",
159                bundle.format,
160                CampaignBundle::FORMAT
161            )));
162        }
163        let campaign = bundle.campaign();
164        self.auth.import(campaign, &bundle.auth).await?;
165        let rest = async {
166            self.characters.import(campaign, &bundle.characters).await?;
167            self.combat.import(campaign, &bundle.combat).await?;
168            self.monsters.import(campaign, &bundle.monsters).await
169        };
170        if let Err(e) = rest.await {
171            // Best effort: what's left over is deleted with the campaign.
172            let _ = self.delete_campaign(campaign).await;
173            return Err(e);
174        }
175        Ok(())
176    }
177
178    /// Deletes a campaign from every service, the auth service last, so
179    /// that one cut short leaves it known, to delete again. Safe to repeat.
180    pub async fn delete_campaign(&self, campaign: Uuid) -> Result<(), ClientError> {
181        self.characters.delete_campaign(campaign).await?;
182        self.combat.delete_campaign(campaign).await?;
183        self.monsters.delete_campaign(campaign).await?;
184        self.auth.delete_campaign(campaign).await?;
185        Ok(())
186    }
187}