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}