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}