lacoctelera/routes/author/
get.rs

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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
// Copyright 2024 Felipe Torres González
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

use crate::{
    authentication::{check_access, AuthData},
    domain::{AuthorBuilder, DataDomainError},
    routes::author::utils::{get_author_from_db, search_author_from_db},
};
use actix_web::{
    get,
    web::{Data, Path, Query},
    HttpResponse,
};
use serde::Deserialize;
use sqlx::MySqlPool;
use std::error::Error;
use tracing::{debug, info, instrument};
use utoipa::IntoParams;

/// Object that includes the allowed tokens for a search of the `/author` resource.
///
/// # Description
///
/// All the members are optional, which means clients are free to choose what token to use for a search. The current
/// search logic of the `/author` collection resource allows only to use a single token per search. This means that if
/// multiple tokens are given, the one with the highest priority will be used.
/// The **email** hash the highest priority, followed by **name** and **surname**.
#[derive(Debug, Deserialize, IntoParams)]
pub struct AuthorQueryParams {
    pub name: Option<String>,
    pub surname: Option<String>,
    pub email: Option<String>,
}

impl AuthorQueryParams {
    /// Returns the token that hash the highest priority in a search.
    ///
    /// # Description
    ///
    /// [AuthorQueryParams] includes all the accepted tokens when a client requests a search of an author entry in the DB.
    /// All the tokens are marked as optional, to allow clients use the token they prefer. The current search logic
    /// only allows a single token search, which means that if multiple tokens are provided within the same request,
    /// only one will be considered.
    ///
    /// The **email** hash the highest priority, followed by **name** and **surname**. This method inspects what tokens
    /// where provided to the `struct`, and returns the one that hash the highest priority. If no token was provided,
    /// an error is returned instead.
    pub fn search_token(&self) -> Result<(&str, &str), DataDomainError> {
        if self.email.is_some() {
            Ok(("email", self.email.as_deref().unwrap()))
        } else if self.name.is_some() {
            Ok(("name", self.name.as_deref().unwrap()))
        } else if self.surname.is_some() {
            Ok(("surname", self.surname.as_deref().unwrap()))
        } else {
            info!("The given search params do not contain any valid token");
            Err(DataDomainError::InvalidSearch)
        }
    }
}

/// Search recipe's authors either by email, name or surname.
///
/// # Description
///
/// This collection resource receives some search criteria via URL params, and performs a search in the DB to find
/// all the authors that match such criteria. Clients of the API with no API token would retrieve some author entries
/// with muted data. Authors specify whether their profiles are public or not. If a profile is not public, only
/// the authorised clients of the API (with a token) will get the whole profile information.
#[utoipa::path(
    tag = "Author",
    path = "/author",
    security(
        ("api_key" = [])
    ),
    params(AuthorQueryParams),
    responses(
        (
            status = 200,
            description = "Some author profiles were found using the given search criteria.",
            body = [Author],
            headers(
                ("Content-Length"),
                ("Content-Type"),
                ("Date"),
                ("Vary", description = "Origin,Access-Control-Request-Method,Access-Control-Request-Headers")
            ),
            examples(
                ("Existing author" = (
                    summary = "Returned JSON for an existing author",
                    value = json!([
                        AuthorBuilder::default()
                            .set_name("Jane")
                            .set_surname("Doe")
                            .set_email("jane_doe@mail.com")
                            .set_website("http://janedoe.com")
                            .set_shareable(true)
                            .build()
                            .unwrap()
                        ])
                ))
            ),
        ),
        (
            status = 404,
            description = "The given author's ID was not found in the DB.",
            headers(
                ("Content-Length"),
                ("Date"),
                ("Vary", description = "Origin,Access-Control-Request-Method,Access-Control-Request-Headers")
            ),
        ),
        (
            status = 429, description = "**Too many requests.**",
            headers(
                ("Cache-Control", description = "Cache control is set to *no-cache*."),
                ("Access-Control-Allow-Origin"),
                ("Retry-After", description = "Amount of time between requests (seconds).")
            )
        )
    )
)]
#[instrument(
    skip(token, pool, req),
    fields(
        author_email = %req.0.email.as_deref().unwrap_or_default(),
        author_name = %req.0.name.as_deref().unwrap_or_default(),
        author_surname =  %req.0.surname.as_deref().unwrap_or_default(),
    )
)]
#[get("")]
pub async fn search_author(
    req: Query<AuthorQueryParams>,
    token: Option<Query<AuthData>>,
    pool: Data<MySqlPool>,
) -> Result<HttpResponse, Box<dyn Error>> {
    let mut authors = search_author_from_db(&pool, req.0).await?;

    debug!("Author descriptors found: {:?}", authors);

    // Access control
    let client_auth = match token {
        Some(token) => {
            debug!("The client included an API token to access the restricted resources.");
            check_access(&pool, &token.api_key).await?;
            debug!("Access granted");
            true
        }
        None => false,
    };

    if !client_auth {
        debug!("The client hash no API token to access the restricted resources. Private data will be muted.");
        authors.iter_mut().for_each(|e| e.mute_private_data());
    }

    Ok(HttpResponse::Ok().json(authors))
}

/// Retrieve an author descriptor using the author's ID.
///
/// # Description
///
/// This singleton resource allows clients of the API to retrieve the details of a recipe's author. Check out the
/// **Author** schema to obtain a detailed description of all the attributes of the Author object.
///
/// If the author sets the profile as non-public (_non-shareable_), only clients with an API access token will retrieve
/// the full author's descriptor. Unauthenticated clients will get the author's name, the personal website, and the
/// social profiles when that data was given to the system. Authors only are required to provide a valid email.
#[utoipa::path(
    get,
    context_path = "/author/",
    tag = "Author",
    security(
        ("api_key" = [])
    ),
    responses(
        (
            status = 200,
            description = "The Author descriptor was found using the given ID.",
            body = Author,
            headers(
                ("Content-Length"),
                ("Content-Type"),
                ("Date"),
                ("Vary", description = "Origin,Access-Control-Request-Method,Access-Control-Request-Headers")
            ),
            examples(
                ("Existing author" = (
                    summary = "Returned JSON for an existing author.",
                    value = json!(
                        AuthorBuilder::default()
                            .set_name("Jane")
                            .set_surname("Doe")
                            .set_email("jane_doe@mail.com")
                            .set_website("http://janedoe.com")
                            .set_shareable(true)
                            .build()
                            .unwrap()
                    )
                ))
            ),
        ),
        (
            status = 404,
            description = "The given author's ID was not found in the DB.",
            headers(
                ("Content-Length"),
                ("Date"),
                ("Vary", description = "Origin,Access-Control-Request-Method,Access-Control-Request-Headers")
            ),
        ),
        (
            status = 429, description = "**Too many requests.**",
            headers(
                ("Cache-Control", description = "Cache control is set to *no-cache*."),
                ("Access-Control-Allow-Origin"),
                ("Retry-After", description = "Amount of time between requests (seconds).")
            )
        )
    )
)]
#[instrument(skip(token, pool, path), fields(author_id = %path.0))]
#[get("{id}")]
pub async fn get_author(
    path: Path<(String,)>,
    token: Option<Query<AuthData>>,
    pool: Data<MySqlPool>,
) -> Result<HttpResponse, Box<dyn Error>> {
    // First: does the author exists?
    let author_id = &path.0;
    let mut author = match get_author_from_db(&pool, author_id).await {
        Ok(author) => author,
        Err(e) => match e.downcast_ref() {
            Some(DataDomainError::InvalidId) => return Ok(HttpResponse::NotFound().finish()),
            _ => return Err(e),
        },
    };

    debug!("Author descriptor found: {:?}", author);

    // Check if the client hash privileges to retrieve the full description of the Author.
    if token.is_some() {
        debug!("The client included an API token to access the restricted resources.");
        check_access(&pool, &token.unwrap().api_key).await?;
        debug!("Access granted");
    } else {
        debug!("The client hash no API token to access the restricted resources. Private data will be muted.");
        if !author.shareable() {
            author.mute_private_data();
        }
    }

    Ok(HttpResponse::Ok().json(author))
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use rstest::*;

    #[rstest]
    #[case(None, None, None, true, "")]
    #[case(Some("Jane"), None, None, false, "name")]
    #[case(None, Some("Doe"), None, false, "surname")]
    #[case(None, None, Some("jane@mail.com"), false, "email")]
    #[case(Some("Jane"), Some("Doe"), None, false, "name")]
    #[case(None, Some("Doe"), Some("jane@mail.com"), false, "email")]
    #[case(Some("Jane"), None, Some("jane@mail.com"), false, "email")]
    #[case(Some("Jane"), Some("Doe"), Some("jane@mail.com"), false, "email")]
    fn query_params(
        #[case] name: Option<&str>,
        #[case] surname: Option<&str>,
        #[case] email: Option<&str>,
        #[case] is_err: bool,
        #[case] expected_token: &str,
    ) {
        let query_params = AuthorQueryParams {
            name: name.map(String::from),
            surname: surname.map(String::from),
            email: email.map(String::from),
        };

        let token = query_params.search_token();
        assert_eq!(token.is_err(), is_err);
        if let Ok(token) = token {
            assert_eq!(token.0, expected_token);
        }
    }
}