Skip to main content

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–2026   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::{http::Response, Body};
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, mut res: Response<Body>) -> 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().as_u16();
96    let status_text = res
97        .status()
98        .canonical_reason()
99        .unwrap_or("Unknown")
100        .to_owned();
101
102    serde_json::from_reader::<_, Value>(res.body_mut().as_reader()) // First, parse the JSON.
103        .map_err(Error::from)
104        .and_then(|json| {
105            // Let's first try to deserialize the outer response, which contains the type of the
106            // response (success or error) and the error type and message in case of an error.
107            let outer = serde_json::from_value::<OuterResponse>(json.clone())?;
108            match outer {
109                OuterResponse::Success => Ok(json), // Pass the JSON object to the next step.
110                OuterResponse::Error {
111                    // If the response is an error, return an `Error::Api`.
112                    error_type,
113                    message,
114                } => Err(Error::Api {
115                    kind: error_type
116                        .unwrap_or_default()
117                        .parse()
118                        .unwrap_or(ErrorKind::Unknown),
119                    message: message.unwrap_or("No error message provided".to_owned()),
120                }),
121            }
122        })
123        .and_then(|json| {
124            // Now that we know the response is successful, let's try to deserialize the inner
125            // response, which contains the actual data we want.
126            json.get(field)
127                .ok_or_else(|| serde_json::Error::missing_field(field))
128                .and_then(|v| serde_json::from_value::<T>(v.clone()))
129                .map_err(Error::from)
130        })
131        .map_err(|err| {
132            // If we can't parse the error response from the API, return the status instead.
133            if matches!(err, Error::Json { .. }) && (400..=599).contains(&status) {
134                Error::Api {
135                    kind: ErrorKind::Status,
136                    message: format!("{} {}", status, status_text),
137                }
138            } else {
139                err
140            }
141        })
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn make_response(status: u16, body: &'static str) -> Response<Body> {
149        let body = Body::builder().mime_type("application/json").data(body);
150        ureq::http::Response::builder()
151            .status(status)
152            .body(body)
153            .unwrap()
154    }
155
156    #[test]
157    fn parse_success() {
158        #[derive(Deserialize)]
159        struct Foobar {
160            foo: String,
161            bar: String,
162        }
163        let res = make_response(
164            200,
165            r#"
166                {
167                    "result": "success",
168                    "foobar": {
169                        "foo": "qux",
170                        "bar": "baz"
171                    },
172                    "we": ["don't", "care", "about", "other", "fields"]
173                }
174            "#,
175        );
176        let foo = parse_response::<Foobar>("foobar", res).unwrap();
177        assert_eq!(foo.foo, "qux");
178        assert_eq!(foo.bar, "baz");
179    }
180
181    #[test]
182    fn parse_error() {
183        // Here we should get an `Error::Api` with `kind` set to `ErrorKind::InvalidAuth`, since
184        // even though we are getting a 401 status code, the response is still a valid JSON object.
185        let res = make_response(
186            401,
187            r#"
188                {
189                    "result": "error",
190                    "error_type": "invalid_auth",
191                    "message": "Invalid API key"
192                }
193            "#,
194        );
195        let err = parse_response::<String>("foobar", res).unwrap_err();
196        assert!(matches!(
197            err,
198            Error::Api {
199                kind: ErrorKind::InvalidAuth,
200                ..
201            }
202        ));
203    }
204
205    #[test]
206    fn parse_invalid_json() {
207        // Here we should get an `Error::Json`, since the response is not a valid JSON object, and
208        // the status code is not 4xx or 5xx.
209        let res = make_response(200, "not json");
210        let err = parse_response::<String>("foobar", res).unwrap_err();
211        assert!(matches!(err, Error::Json { .. }));
212    }
213
214    #[test]
215    fn parse_invalid_json_error() {
216        // Here we should get an `Error::Api` with `kind` set to `ErrorKind::Status`, since the
217        // response is not a valid JSON object, and the status code is 4xx or 5xx.
218        let res = make_response(401, "not json");
219        let err = parse_response::<String>("foobar", res).unwrap_err();
220        let Error::Api { message, kind } = err else {
221            panic!("Expected an Error::Api {{ .. }}, got {:?}", err);
222        };
223        assert_eq!(kind, ErrorKind::Status);
224        assert_eq!(message, "401 Unauthorized");
225    }
226}