1use std::path::PathBuf;
2
3use anyhow::{Ok, Result, bail};
4use serde::{Deserialize, Serialize};
5pub use types::billing::BillingType;
6pub use types::dcs_api::{
7 AddMissionsResponse, BanPlayerRequest, BanPlayerResponse, DeleteMissionsResponse,
8 GetPauseServerResponse, GetResumeServerResponse, KickPlayerRequest, KickPlayerResponse,
9 SendChatRequest, SendChatResponse, SetServerSettingsRequest, SetServerSettingsResponse,
10 StartMissionResponse, StartServerResponse,
11};
12pub use types::dcs_chat::DcsChat;
13pub use types::dcs_runtime::{
14 AdvancedSettings, BannedPlayer, CurrentRuntimeAction, DcsRuntime, GetMissionInfoResponse,
15 GetMissionListResponse, GetPlayersResponse, GetServerSettingsResponse, Player, Players,
16 Settings,
17};
18pub use types::dcs_settings::{DcsSettings, DcsSettingsPayload, DcsSettingsUpdatePayload};
19pub use types::files::{
20 FileDownloadResponse, FileInfo, FileListResponse, FileUploadRequest, FmRoot, MoveFileRequest,
21};
22pub use types::instance::{
23 ApiError, GameRuntime, GameType, Instance, InstanceNodeResource, InstancePermissions,
24 InstanceResource, InstanceStatus, InstanceStoppedReason, InstancesResponse, Permission,
25 UserResource,
26};
27pub use types::logs::{GameServerLogRow, GameServerLogsResponse, InstanceLogRequest};
28pub use types::mods::{ChangeModsRequest, ModConfigType};
29pub use types::products::{
30 ACE_TIER, CADET_TIER, COMMANDER_TIER, PRODUCTS, Product, STRATEGIST_TIER, VETERAN_TIER,
31 WINGMAN_TIER, product_by_id,
32};
33pub use types::region::Region;
34pub use types::srs::{SrsClient, SrsModRequest, SrsServerInfo};
35pub use types::system_resources::{
36 PrometheusSeries, ResourceKind, ResourceUsage, ServerResourcesResponse,
37};
38pub use types::system_resources_periode::SystemResourcesPeriod;
39pub use types::triggers::{
40 ComparisonOperator, CreateTriggerRequest, Trigger, TriggerAction, TriggerCondition,
41};
42pub use types::webconsole::WebConsoleExecuteRequest;
43
44use reqwest::multipart::{Form, Part};
45use tokio::fs::File;
46use tokio::io::AsyncWriteExt;
47use types::files::FilePathQuery;
48
49pub use uuid::Uuid;
50
51mod types;
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct CreateInstanceRequest {
55 pub product_id: Uuid,
56 pub region: Region,
57 pub billing_type: BillingType,
58 pub partner_code: Option<String>,
59 pub settings: DcsSettingsPayload,
60 pub active_mods: Vec<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct CreateInstanceMonthlyResponse {
65 pub url: String,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69#[serde(untagged)]
70pub enum CreateInstanceResponse {
71 Hourly(Instance),
72 Monthly(CreateInstanceMonthlyResponse),
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(tag = "game_type", content = "settings", rename_all = "snake_case")]
77pub enum EditInstanceRequest {
78 Dcs(DcsSettingsUpdatePayload),
79}
80
81impl EditInstanceRequest {
82 pub fn dcs(settings: DcsSettingsUpdatePayload) -> Self {
83 Self::Dcs(settings)
84 }
85}
86
87impl From<DcsSettingsUpdatePayload> for EditInstanceRequest {
88 fn from(settings: DcsSettingsUpdatePayload) -> Self {
89 Self::dcs(settings)
90 }
91}
92
93#[derive(Debug, Clone)]
94pub struct Client {
95 api_key: String,
96 reqwest_client: reqwest::Client,
97}
98
99impl Client {
100 const BASE_URL: &'static str = "https://coordinator.nimbuspulse.com";
101
102 pub fn new(api_key: impl Into<String>) -> Self {
103 Self {
104 reqwest_client: reqwest::Client::new(),
105 api_key: api_key.into(),
106 }
107 }
108
109 pub fn set_api_key(&mut self, api_key: impl Into<String>) {
110 self.api_key = api_key.into();
111 }
112
113 async fn send(&self, request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
114 Ok(request.bearer_auth(self.api_key.clone()).send().await?)
115 }
116
117 async fn send_json<T>(&self, request: reqwest::RequestBuilder) -> Result<T>
118 where
119 T: serde::de::DeserializeOwned,
120 {
121 let response = self.send(request).await?;
122 if !response.status().is_success() {
123 bail!(
124 response
125 .text()
126 .await
127 .unwrap_or_else(|_| "request failed".to_string())
128 );
129 }
130
131 Ok(response.json::<T>().await?)
132 }
133
134 async fn send_unit(&self, request: reqwest::RequestBuilder, context: &str) -> Result<()> {
135 let response = self.send(request).await?;
136 if !response.status().is_success() {
137 bail!("{context}: {}", response.text().await.unwrap_or_default());
138 }
139
140 Ok(())
141 }
142
143 pub async fn create_server(
144 &self,
145 region: Region,
146 billing_type: BillingType,
147 name: impl Into<String>,
148 password: Option<impl Into<String>>,
149 max_players: u32,
150 product: impl Into<Uuid>,
151 active_mods: Vec<impl Into<String>>,
152 use_voice_chat: bool,
153 enable_io: bool,
154 enable_os: bool,
155 enable_lfs: bool,
156 allow_external_loading: bool,
157 partner_code: Option<String>,
158 ) -> Result<CreateInstanceResponse> {
159 let payload = CreateInstanceRequest {
160 product_id: product.into(),
161 region,
162 billing_type,
163 partner_code,
164 settings: DcsSettingsPayload {
165 initial_server_name: name.into(),
166 initial_server_password: password.map(|p| p.into()).unwrap_or_default(),
167 initial_max_players: max_players,
168 initial_use_voice_chat: use_voice_chat,
169 enable_io,
170 enable_os,
171 enable_lfs,
172 allow_external_loading,
173 },
174 active_mods: active_mods.into_iter().map(|m| m.into()).collect(),
175 };
176
177 self.send_json(
178 self.reqwest_client
179 .post(format!("{}/game_servers", Self::BASE_URL))
180 .json(&payload),
181 )
182 .await
183 }
184
185 pub async fn get_runtime(&self, id: &Uuid) -> Result<DcsRuntime> {
186 let server = self.get_server(id).await?;
187
188 match server.runtime {
189 Some(GameRuntime::Dcs(runtime)) => Ok(runtime),
190 None => bail!("server runtime is not available"),
191 }
192 }
193
194 pub async fn get_server_resources(
195 &self,
196 id: &Uuid,
197 period: SystemResourcesPeriod,
198 ) -> Result<ServerResourcesResponse> {
199 self.send_json(self.reqwest_client.get(format!(
200 "{}/game_servers/{}/resources?periode={}",
201 Self::BASE_URL,
202 id,
203 period
204 )))
205 .await
206 }
207
208 pub async fn health(&self) -> Result<()> {
209 self.send_unit(
210 self.reqwest_client
211 .get(format!("{}/health", Self::BASE_URL)),
212 "health check failed",
213 )
214 .await
215 }
216
217 pub async fn get_servers(&self) -> Result<InstancesResponse> {
218 self.send_json(
219 self.reqwest_client
220 .get(format!("{}/game_servers", Self::BASE_URL)),
221 )
222 .await
223 }
224
225 pub async fn get_server(&self, id: &Uuid) -> Result<InstanceResource> {
226 self.send_json(
227 self.reqwest_client
228 .get(format!("{}/game_servers/{}", Self::BASE_URL, id)),
229 )
230 .await
231 }
232
233 pub async fn update_server(
234 &self,
235 id: &Uuid,
236 payload: &EditInstanceRequest,
237 ) -> Result<InstanceResource> {
238 self.send_json(
239 self.reqwest_client
240 .put(format!("{}/game_servers/{}", Self::BASE_URL, id))
241 .json(payload),
242 )
243 .await
244 }
245
246 pub async fn get_server_logs(
247 &self,
248 id: &Uuid,
249 request: &InstanceLogRequest,
250 ) -> Result<GameServerLogsResponse> {
251 self.send_json(
252 self.reqwest_client
253 .get(format!("{}/game_servers/{}/logs", Self::BASE_URL, id))
254 .query(request),
255 )
256 .await
257 }
258
259 pub async fn get_chat(&self, id: &Uuid) -> Result<Vec<DcsChat>> {
260 self.send_json(self.reqwest_client.get(format!(
261 "{}/game_servers/{}/chat",
262 Self::BASE_URL,
263 id
264 )))
265 .await
266 }
267
268 pub async fn start_server(&self, id: &Uuid) -> Result<Instance> {
269 self.send_json(self.reqwest_client.post(format!(
270 "{}/game_servers/{}/start",
271 Self::BASE_URL,
272 id
273 )))
274 .await
275 }
276
277 pub async fn stop_server(&self, id: &Uuid) -> Result<Instance> {
278 self.send_json(self.reqwest_client.post(format!(
279 "{}/game_servers/{}/stop",
280 Self::BASE_URL,
281 id
282 )))
283 .await
284 }
285
286 pub async fn full_restart_server(&self, id: &Uuid) -> Result<Instance> {
287 self.send_json(self.reqwest_client.post(format!(
288 "{}/game_servers/{}/full_restart",
289 Self::BASE_URL,
290 id
291 )))
292 .await
293 }
294
295 pub async fn restart_server(&self, id: &Uuid) -> Result<Instance> {
296 self.send_json(self.reqwest_client.post(format!(
297 "{}/game_servers/{}/restart",
298 Self::BASE_URL,
299 id
300 )))
301 .await
302 }
303
304 pub async fn update_game_server(&self, id: &Uuid) -> Result<Instance> {
305 self.send_json(self.reqwest_client.post(format!(
306 "{}/game_servers/{}/update",
307 Self::BASE_URL,
308 id
309 )))
310 .await
311 }
312
313 pub async fn delete_server(&self, id: &Uuid) -> Result<()> {
314 self.send_unit(
315 self.reqwest_client
316 .delete(format!("{}/game_servers/{}", Self::BASE_URL, id)),
317 "failed to delete server",
318 )
319 .await
320 }
321
322 pub async fn reactivate_server(&self, id: &Uuid) -> Result<()> {
323 self.send_unit(
324 self.reqwest_client
325 .post(format!("{}/game_servers/{}/reactivate", Self::BASE_URL, id)),
326 "failed to reactivate server",
327 )
328 .await
329 }
330
331 pub async fn change_mods(&self, id: &Uuid, request: &ChangeModsRequest) -> Result<()> {
332 self.send_unit(
333 self.reqwest_client
334 .put(format!(
335 "{}/game_servers/{}/mods/change",
336 Self::BASE_URL,
337 id
338 ))
339 .json(request),
340 "failed to change mods",
341 )
342 .await
343 }
344
345 pub async fn get_mod(&self, id: &Uuid, mod_id: ModConfigType) -> Result<serde_json::Value> {
346 self.send_json(self.reqwest_client.get(format!(
347 "{}/game_servers/{}/mods/{}",
348 Self::BASE_URL,
349 id,
350 mod_id
351 )))
352 .await
353 }
354
355 pub async fn list_files(
356 &self,
357 id: &Uuid,
358 root: FmRoot,
359 path: impl Into<String>,
360 ) -> Result<FileListResponse> {
361 self.send_json(
362 self.reqwest_client
363 .get(format!("{}/game_servers/{}/files", Self::BASE_URL, id))
364 .query(&FilePathQuery {
365 root,
366 path: path.into(),
367 }),
368 )
369 .await
370 }
371
372 pub async fn create_directory(
373 &self,
374 id: &Uuid,
375 root: FmRoot,
376 path: impl Into<String>,
377 ) -> Result<()> {
378 self.send_unit(
379 self.reqwest_client
380 .post(format!(
381 "{}/game_servers/{}/files/directory",
382 Self::BASE_URL,
383 id
384 ))
385 .query(&FilePathQuery {
386 root,
387 path: path.into(),
388 }),
389 "failed to create directory",
390 )
391 .await
392 }
393
394 pub async fn upload_file(
395 &self,
396 id: &Uuid,
397 root: FmRoot,
398 path: impl Into<String>,
399 file: Vec<u8>,
400 ) -> Result<()> {
401 let part = Part::bytes(file)
402 .file_name("upload.bin")
403 .mime_str("application/octet-stream")?;
404 let form = Form::new().part("file", part);
405
406 self.send_unit(
407 self.reqwest_client
408 .post(format!(
409 "{}/game_servers/{}/files/upload",
410 Self::BASE_URL,
411 id
412 ))
413 .query(&FilePathQuery {
414 root,
415 path: path.into(),
416 })
417 .multipart(form),
418 "failed to upload file",
419 )
420 .await
421 }
422
423 pub async fn upload_file_from(
424 &self,
425 id: &Uuid,
426 root: FmRoot,
427 path: impl Into<String>,
428 file: impl Into<PathBuf>,
429 ) -> Result<()> {
430 let form = Form::new().file("file", file.into()).await?;
431
432 self.send_unit(
433 self.reqwest_client
434 .post(format!(
435 "{}/game_servers/{}/files/upload",
436 Self::BASE_URL,
437 id
438 ))
439 .query(&FilePathQuery {
440 root,
441 path: path.into(),
442 })
443 .multipart(form),
444 "failed to upload file",
445 )
446 .await
447 }
448
449 pub async fn download_file(
450 &self,
451 id: &Uuid,
452 root: FmRoot,
453 path: impl Into<String>,
454 ) -> Result<Vec<u8>> {
455 let response = self
456 .send(
457 self.reqwest_client
458 .get(format!(
459 "{}/game_servers/{}/files/download",
460 Self::BASE_URL,
461 id
462 ))
463 .query(&FilePathQuery {
464 root,
465 path: path.into(),
466 }),
467 )
468 .await?;
469
470 if !response.status().is_success() {
471 bail!(format!("Failed to download file: {:?}", response));
472 }
473
474 let bytes = response.bytes().await?;
475 Ok(bytes.to_vec())
476 }
477
478 pub async fn download_file_to(
479 &self,
480 id: &Uuid,
481 root: FmRoot,
482 path: impl Into<String>,
483 destination: impl Into<PathBuf>,
484 ) -> Result<()> {
485 let bytes = self.download_file(id, root, path).await?;
486 let mut file = File::create(destination.into()).await?;
487 file.write_all(&bytes).await?;
488
489 Ok(())
490 }
491
492 pub async fn delete_file(
493 &self,
494 id: &Uuid,
495 root: FmRoot,
496 path: impl Into<String>,
497 ) -> Result<()> {
498 self.send_unit(
499 self.reqwest_client
500 .delete(format!("{}/game_servers/{}/files", Self::BASE_URL, id))
501 .query(&FilePathQuery {
502 root,
503 path: path.into(),
504 }),
505 "failed to delete file",
506 )
507 .await
508 }
509
510 pub async fn move_file(&self, id: &Uuid, request: &MoveFileRequest) -> Result<()> {
511 self.send_unit(
512 self.reqwest_client
513 .put(format!("{}/game_servers/{}/files/move", Self::BASE_URL, id))
514 .json(request),
515 "failed to move file",
516 )
517 .await
518 }
519
520 pub async fn add_missions(
521 &self,
522 id: &Uuid,
523 missions: &[String],
524 ) -> Result<AddMissionsResponse> {
525 self.send_json(
526 self.reqwest_client
527 .post(format!(
528 "{}/game_servers/{}/dcs-api/missions",
529 Self::BASE_URL,
530 id
531 ))
532 .json(missions),
533 )
534 .await
535 }
536
537 pub async fn delete_missions(
538 &self,
539 id: &Uuid,
540 missions: &[i32],
541 ) -> Result<DeleteMissionsResponse> {
542 self.send_json(
543 self.reqwest_client
544 .delete(format!(
545 "{}/game_servers/{}/dcs-api/missions",
546 Self::BASE_URL,
547 id
548 ))
549 .json(missions),
550 )
551 .await
552 }
553
554 pub async fn select_mission(
555 &self,
556 id: &Uuid,
557 mission_idx: i32,
558 ) -> Result<StartMissionResponse> {
559 self.send_json(self.reqwest_client.post(format!(
560 "{}/game_servers/{}/dcs-api/missions/{}/select",
561 Self::BASE_URL,
562 id,
563 mission_idx
564 )))
565 .await
566 }
567
568 pub async fn start_mission(&self, id: &Uuid, mission_idx: i32) -> Result<StartServerResponse> {
569 self.send_json(self.reqwest_client.post(format!(
570 "{}/game_servers/{}/dcs-api/missions/{}/start",
571 Self::BASE_URL,
572 id,
573 mission_idx
574 )))
575 .await
576 }
577
578 pub async fn pause_server(&self, id: &Uuid) -> Result<GetPauseServerResponse> {
579 self.send_json(self.reqwest_client.post(format!(
580 "{}/game_servers/{}/dcs-api/pause",
581 Self::BASE_URL,
582 id
583 )))
584 .await
585 }
586
587 pub async fn resume_server(&self, id: &Uuid) -> Result<GetResumeServerResponse> {
588 self.send_json(self.reqwest_client.post(format!(
589 "{}/game_servers/{}/dcs-api/resume",
590 Self::BASE_URL,
591 id
592 )))
593 .await
594 }
595
596 pub async fn save_settings(
597 &self,
598 id: &Uuid,
599 request: &SetServerSettingsRequest,
600 ) -> Result<SetServerSettingsResponse> {
601 self.send_json(
602 self.reqwest_client
603 .post(format!(
604 "{}/game_servers/{}/dcs-api/settings",
605 Self::BASE_URL,
606 id
607 ))
608 .json(request),
609 )
610 .await
611 }
612
613 pub async fn kick_player(
614 &self,
615 id: &Uuid,
616 request: &KickPlayerRequest,
617 ) -> Result<KickPlayerResponse> {
618 self.send_json(
619 self.reqwest_client
620 .post(format!(
621 "{}/game_servers/{}/dcs-api/kick",
622 Self::BASE_URL,
623 id
624 ))
625 .json(request),
626 )
627 .await
628 }
629
630 pub async fn ban_player(
631 &self,
632 id: &Uuid,
633 request: &BanPlayerRequest,
634 ) -> Result<BanPlayerResponse> {
635 self.send_json(
636 self.reqwest_client
637 .post(format!(
638 "{}/game_servers/{}/dcs-api/ban",
639 Self::BASE_URL,
640 id
641 ))
642 .json(request),
643 )
644 .await
645 }
646
647 pub async fn send_chat(
648 &self,
649 id: &Uuid,
650 request: &SendChatRequest,
651 ) -> Result<SendChatResponse> {
652 self.send_json(
653 self.reqwest_client
654 .post(format!(
655 "{}/game_servers/{}/dcs-api/sendChat",
656 Self::BASE_URL,
657 id
658 ))
659 .json(request),
660 )
661 .await
662 }
663
664 pub async fn get_srs_clients(&self, id: &Uuid) -> Result<SrsServerInfo> {
665 self.send_json(self.reqwest_client.get(format!(
666 "{}/game_servers/{}/mods/srs/clients",
667 Self::BASE_URL,
668 id
669 )))
670 .await
671 }
672
673 pub async fn kick_srs_client(&self, id: &Uuid, request: &SrsModRequest) -> Result<()> {
674 self.send_unit(
675 self.reqwest_client
676 .post(format!(
677 "{}/game_servers/{}/mods/srs/kick",
678 Self::BASE_URL,
679 id
680 ))
681 .json(request),
682 "failed to kick srs client",
683 )
684 .await
685 }
686
687 pub async fn ban_srs_client(&self, id: &Uuid, request: &SrsModRequest) -> Result<()> {
688 self.send_unit(
689 self.reqwest_client
690 .post(format!(
691 "{}/game_servers/{}/mods/srs/ban",
692 Self::BASE_URL,
693 id
694 ))
695 .json(request),
696 "failed to ban srs client",
697 )
698 .await
699 }
700
701 pub async fn execute_webconsole(
702 &self,
703 id: &Uuid,
704 request: &WebConsoleExecuteRequest,
705 ) -> Result<String> {
706 self.send_json(
707 self.reqwest_client
708 .post(format!(
709 "{}/game_servers/{}/mods/webconsole/execute",
710 Self::BASE_URL,
711 id
712 ))
713 .json(request),
714 )
715 .await
716 }
717
718 pub async fn create_trigger(
719 &self,
720 id: &Uuid,
721 request: &CreateTriggerRequest,
722 ) -> Result<Trigger> {
723 self.send_json(
724 self.reqwest_client
725 .post(format!("{}/game_servers/{}/triggers", Self::BASE_URL, id))
726 .json(request),
727 )
728 .await
729 }
730
731 pub async fn list_triggers(&self, id: &Uuid) -> Result<Vec<Trigger>> {
732 self.send_json(self.reqwest_client.get(format!(
733 "{}/game_servers/{}/triggers",
734 Self::BASE_URL,
735 id
736 )))
737 .await
738 }
739
740 pub async fn delete_trigger(&self, id: &Uuid, trigger_id: &Uuid) -> Result<()> {
741 self.send_unit(
742 self.reqwest_client.delete(format!(
743 "{}/game_servers/{}/triggers/{}",
744 Self::BASE_URL,
745 id,
746 trigger_id
747 )),
748 "failed to delete trigger",
749 )
750 .await
751 }
752}