1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
use std::time::Duration;

use anyhow::{anyhow, bail, Context, Result};
use async_compat::Compat;
use bevy::{
    ecs::system::SystemParam,
    prelude::*,
    tasks::{IoTaskPool, Task},
};
use reqwest::{header::HeaderValue, redirect::Policy, Client, Request};
use url::Url;

use crate::requestable::LobbyRequestCreator;

const USER_AGENT: &str = concat!("DigitalExtinction/", env!("CARGO_PKG_VERSION"));

#[derive(SystemParam)]
pub(super) struct AuthenticatedClient<'w> {
    auth: Res<'w, Authentication>,
    client: Option<Res<'w, LobbyClient>>,
}

impl<'w> AuthenticatedClient<'w> {
    pub(super) fn fire<T: LobbyRequestCreator>(
        &self,
        requestable: &T,
    ) -> Result<Task<Result<T::Response>>> {
        let Some(client) = self.client.as_ref() else {
            bail!("Client not yet set up.")
        };
        let request = client.create(self.auth.token(), requestable)?;
        Ok(client.fire::<T>(request))
    }
}

/// Lobby client authentication object. It should be used to get current
/// authentication state.
#[derive(Resource, Default)]
pub struct Authentication {
    token: Option<String>,
}

impl Authentication {
    pub fn is_authenticated(&self) -> bool {
        self.token.is_some()
    }

    fn token(&self) -> Option<&str> {
        self.token.as_deref()
    }

    pub(super) fn set_token(&mut self, token: String) {
        self.token = Some(token)
    }
}

#[derive(Resource)]
pub(super) struct LobbyClient {
    server_url: Url,
    client: Client,
}

impl LobbyClient {
    pub(super) fn build(server_url: Url) -> Self {
        let client = Client::builder()
            .user_agent(USER_AGENT)
            .redirect(Policy::none())
            .timeout(Duration::from_secs(10))
            .build()
            .unwrap();

        Self { server_url, client }
    }

    fn create<T: LobbyRequestCreator>(
        &self,
        token: Option<&str>,
        requestable: &T,
    ) -> Result<Request> {
        let path = requestable.path();
        let url = self
            .server_url
            .join(path.as_ref())
            .context("Endpoint URL construction error")?;
        let mut request = requestable.create(url);

        // All authenticated endpoints start with /a all public endpoints start
        // with /p per DE Lobby API design.
        let authenticated = path.starts_with("/a");
        if authenticated {
            match token {
                Some(token) => {
                    let mut value = HeaderValue::try_from(format!("Bearer {token}"))
                        .context("Failed crate Authorization header value from the JWT")?;
                    value.set_sensitive(true);
                    request.headers_mut().insert("Authorization", value);
                }
                None => bail!("The client is not yet authenticated."),
            }
        }

        Ok(request)
    }

    fn fire<T: LobbyRequestCreator>(&self, request: Request) -> Task<Result<T::Response>> {
        info!("Requesting {} {}", request.method(), request.url());
        let client = self.client.clone();

        IoTaskPool::get().spawn(Compat::new(async move {
            let resonse = client
                .execute(request)
                .await
                .context("Failed to execute the request")?;

            let status = resonse.status();
            if status.is_success() {
                let text = resonse
                    .text()
                    .await
                    .context("Failed to load server response")?;
                let response = serde_json::from_str(text.as_str())
                    .context("Failed to parse server response")?;
                Ok(response)
            } else if status.is_server_error() {
                Err(anyhow!("Server side error occurred."))
            } else {
                let reason = status.canonical_reason().unwrap_or_else(|| status.as_str());
                let text = resonse
                    .text()
                    .await
                    .context("Failed to load server error response")?;
                Err(anyhow!("{}: {}", reason, text))
            }
        }))
    }
}

#[cfg(test)]
mod tests {
    use de_lobby_model::UsernameAndPassword;

    use super::*;
    use crate::{ListGamesRequest, SignInRequest};

    #[test]
    fn test_create() {
        let client = LobbyClient::build(Url::parse("https://example.com").unwrap());

        let sign_in = SignInRequest::new(UsernameAndPassword::new(
            "Indy".to_owned(),
            "123456".to_owned(),
        ));
        let request = client.create(None, &sign_in).unwrap();
        assert!(request.headers().get("Authorization").is_none());

        let request = client
            .create(Some("some-token"), &ListGamesRequest)
            .unwrap();
        assert_eq!(
            request
                .headers()
                .get("Authorization")
                .unwrap()
                .to_str()
                .unwrap(),
            "Bearer some-token"
        );
    }
}