neocities_client/
response.rs

1////////       This file is part of the source code for neocities-client, a Rust           ////////
2////////       library for interacting with the https://neocities.org/ API.                ////////
3////////                                                                                   ////////
4////////                           Copyright © 2024  André Kugland                         ////////
5////////                                                                                   ////////
6////////       This program is free software: you can redistribute it and/or modify        ////////
7////////       it under the terms of the GNU General Public License as published by        ////////
8////////       the Free Software Foundation, either version 3 of the License, or           ////////
9////////       (at your option) any later version.                                         ////////
10////////                                                                                   ////////
11////////       This program is distributed in the hope that it will be useful,             ////////
12////////       but WITHOUT ANY WARRANTY; without even the implied warranty of              ////////
13////////       MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the                ////////
14////////       GNU General Public License for more details.                                ////////
15////////                                                                                   ////////
16////////       You should have received a copy of the GNU General Public License           ////////
17////////       along with this program. If not, see https://www.gnu.org/licenses/.         ////////
18
19//! This module contains the types used to deserialize the JSON responses from the Neocities API.
20
21use crate::{Error, ErrorKind, Result};
22use serde::{de::Error as SerdeError, Deserialize};
23use serde_json::Value;
24use ureq::Response;
25
26/// Type for the response of the `/api/info` endpoint.
27///
28/// *Note:* the documentation doesn't clearly define which of the following fields are nullable.
29/// If any of the fields that are not of the [`Option`] type here happen to come with a null value,
30/// we will have a panic situation. This is easily solved by making the offending field optional.
31#[derive(Deserialize, Debug)]
32pub struct Info {
33    /// Name of the site
34    pub sitename: String,
35    /// Number of views
36    pub views: u64,
37    /// Number of hits
38    pub hits: u64,
39    /// Date and time of the creation of the site
40    pub created_at: String,
41    /// Date and time of the last update of the site (*sometimes not present*)
42    pub last_updated: Option<String>,
43    /// Optional custom domain (*only for paid accounts*)
44    pub domain: Option<String>,
45    /// List of tags
46    pub tags: Vec<String>,
47    /// Latest IPFS hash (*if IPFS archiving is enabled*)
48    pub latest_ipfs_hash: Option<String>,
49}
50
51/// Type for an item of the array for the response of the `/api/list` endpoint.
52///
53/// *Note:* This represents a directory entry, which can be either a file or a directory. For
54/// files, all fields should be present; for directories, `size` and `sha1_hash` will be absent.
55#[derive(Deserialize, Debug)]
56pub struct ListEntry {
57    /// Path of the file
58    pub path: String,
59    /// True if the file is a directory, false otherwise
60    pub is_directory: bool,
61    /// Date and time of the last update of the file
62    pub updated_at: String,
63    /// Size of the file in bytes (*not present for directories*)
64    pub size: Option<u64>,
65    /// Hash of the file (*not present for directories*)
66    pub sha1_hash: Option<String>,
67}
68
69// --------------------------------------------------------------------------------------------- //
70//       Beyond this point lie implementation details that are not exported from the crate       //
71// --------------------------------------------------------------------------------------------- //
72
73/// Extract a struct representing the API’s response from a HTTP response.
74#[allow(clippy::result_large_err)]
75pub(crate) fn parse_response<T>(field: &'static str, res: Response) -> Result<T>
76where
77    T: serde::de::DeserializeOwned,
78{
79    /// The basic response structure returned by the API. It contains a `result` field that
80    /// indicates whether the request was successful or not, and gives the error kind and
81    /// message in case of an error.
82    #[derive(Deserialize)]
83    #[serde(tag = "result")]
84    enum OuterResponse {
85        #[serde(rename = "success")]
86        Success,
87        #[serde(rename = "error")]
88        Error {
89            error_type: Option<String>,
90            message: Option<String>,
91        },
92    }
93
94    // Save these for later.
95    let status = res.status();
96    let status_text = res.status_text().to_owned();
97
98    serde_json::from_reader::<_, Value>(res.into_reader()) // First, parse the JSON.
99        .map_err(Error::from)
100        .and_then(|json| {
101            // Let's first try to deserialize the outer response, which contains the type of the
102            // response (success or error) and the error type and message in case of an error.
103            let outer = serde_json::from_value::<OuterResponse>(json.clone())?;
104            match outer {
105                OuterResponse::Success => Ok(json), // Pass the JSON object to the next step.
106                OuterResponse::Error {
107                    // If the response is an error, return an `Error::Api`.
108                    error_type,
109                    message,
110                } => Err(Error::Api {
111                    kind: error_type
112                        .unwrap_or_default()
113                        .parse()
114                        .unwrap_or(ErrorKind::Unknown),
115                    message: message.unwrap_or("No error message provided".to_owned()),
116                }),
117            }
118        })
119        .and_then(|json| {
120            // Now that we know the response is successful, let's try to deserialize the inner
121            // response, which contains the actual data we want.
122            json.get(field)
123                .ok_or_else(|| serde_json::Error::missing_field(field))
124                .and_then(|v| serde_json::from_value::<T>(v.clone()))
125                .map_err(Error::from)
126        })
127        .map_err(|err| {
128            // If we can't parse the error response from the API, return the status instead.
129            if matches!(err, Error::Json { .. }) && (400..=599).contains(&status) {
130                Error::Api {
131                    kind: ErrorKind::Status,
132                    message: format!("{} {}", status, status_text),
133                }
134            } else {
135                err
136            }
137        })
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn parse_success() {
146        #[derive(Deserialize)]
147        struct Foobar {
148            foo: String,
149            bar: String,
150        }
151        let res = ureq::Response::new(
152            200,
153            "OK",
154            r#"
155                {
156                    "result": "success",
157                    "foobar": {
158                        "foo": "qux",
159                        "bar": "baz"
160                    },
161                    "we": ["don't", "care", "about", "other", "fields"]
162                }
163            "#,
164        )
165        .unwrap();
166        let foo = parse_response::<Foobar>("foobar", res).unwrap();
167        assert_eq!(foo.foo, "qux");
168        assert_eq!(foo.bar, "baz");
169    }
170
171    #[test]
172    fn parse_error() {
173        // Here we should get an `Error::Api` with `kind` set to `ErrorKind::InvalidAuth`, since
174        // even though we are getting a 401 status code, the response is still a valid JSON object.
175        let res = ureq::Response::new(
176            401,
177            "Unauthorized",
178            r#"
179                {
180                    "result": "error",
181                    "error_type": "invalid_auth",
182                    "message": "Invalid API key"
183                }
184            "#,
185        )
186        .unwrap();
187        let err = parse_response::<String>("foobar", res).unwrap_err();
188        assert!(matches!(
189            err,
190            Error::Api {
191                kind: ErrorKind::InvalidAuth,
192                ..
193            }
194        ));
195    }
196
197    #[test]
198    fn parse_invalid_json() {
199        // Here we should get an `Error::Json`, since the response is not a valid JSON object, and
200        // the status code is not 4xx or 5xx.
201        let res = ureq::Response::new(200, "OK", "not json").unwrap();
202        let err = parse_response::<String>("foobar", res).unwrap_err();
203        assert!(matches!(err, Error::Json { .. }));
204    }
205
206    #[test]
207    fn parse_invalid_json_error() {
208        // Here we should get an `Error::Api` with `kind` set to `ErrorKind::Status`, since the
209        // response is not a valid JSON object, and the status code is 4xx or 5xx.
210        let res = ureq::Response::new(401, "Unauthorized", "not json").unwrap();
211        let err = parse_response::<String>("foobar", res).unwrap_err();
212        let Error::Api { message, kind } = err else {
213            panic!("Expected an Error::Api {{ .. }}, got {:?}", err);
214        };
215        assert_eq!(kind, ErrorKind::Status);
216        assert_eq!(message, "401 Unauthorized");
217    }
218}