neocities_client/response.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
//////// This file is part of the source code for neocities-client, a Rust ////////
//////// library for interacting with the https://neocities.org/ API. ////////
//////// ////////
//////// Copyright © 2024 André Kugland ////////
//////// ////////
//////// This program is free software: you can redistribute it and/or modify ////////
//////// it under the terms of the GNU General Public License as published by ////////
//////// the Free Software Foundation, either version 3 of the License, or ////////
//////// (at your option) any later version. ////////
//////// ////////
//////// This program is distributed in the hope that it will be useful, ////////
//////// but WITHOUT ANY WARRANTY; without even the implied warranty of ////////
//////// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ////////
//////// GNU General Public License for more details. ////////
//////// ////////
//////// You should have received a copy of the GNU General Public License ////////
//////// along with this program. If not, see https://www.gnu.org/licenses/. ////////
//! This module contains the types used to deserialize the JSON responses from the Neocities API.
use crate::{Error, ErrorKind, Result};
use serde::{de::Error as SerdeError, Deserialize};
use serde_json::Value;
use ureq::Response;
/// Type for the response of the `/api/info` endpoint.
///
/// *Note:* the documentation doesn't clearly define which of the following fields are nullable.
/// If any of the fields that are not of the [`Option`] type here happen to come with a null value,
/// we will have a panic situation. This is easily solved by making the offending field optional.
#[derive(Deserialize, Debug)]
pub struct Info {
/// Name of the site
pub sitename: String,
/// Number of views
pub views: u64,
/// Number of hits
pub hits: u64,
/// Date and time of the creation of the site
pub created_at: String,
/// Date and time of the last update of the site (*sometimes not present*)
pub last_updated: Option<String>,
/// Optional custom domain (*only for paid accounts*)
pub domain: Option<String>,
/// List of tags
pub tags: Vec<String>,
/// Latest IPFS hash (*if IPFS archiving is enabled*)
pub latest_ipfs_hash: Option<String>,
}
/// Type for an item of the array for the response of the `/api/list` endpoint.
///
/// *Note:* This represents a directory entry, which can be either a file or a directory. For
/// files, all fields should be present; for directories, `size` and `sha1_hash` will be absent.
#[derive(Deserialize, Debug)]
pub struct ListEntry {
/// Path of the file
pub path: String,
/// True if the file is a directory, false otherwise
pub is_directory: bool,
/// Date and time of the last update of the file
pub updated_at: String,
/// Size of the file in bytes (*not present for directories*)
pub size: Option<u64>,
/// Hash of the file (*not present for directories*)
pub sha1_hash: Option<String>,
}
// --------------------------------------------------------------------------------------------- //
// Beyond this point lie implementation details that are not exported from the crate //
// --------------------------------------------------------------------------------------------- //
/// Extract a struct representing the API’s response from a HTTP response.
#[allow(clippy::result_large_err)]
pub(crate) fn parse_response<T>(field: &'static str, res: Response) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
/// The basic response structure returned by the API. It contains a `result` field that
/// indicates whether the request was successful or not, and gives the error kind and
/// message in case of an error.
#[derive(Deserialize)]
#[serde(tag = "result")]
enum OuterResponse {
#[serde(rename = "success")]
Success,
#[serde(rename = "error")]
Error {
error_type: Option<String>,
message: Option<String>,
},
}
// Save these for later.
let status = res.status();
let status_text = res.status_text().to_owned();
serde_json::from_reader::<_, Value>(res.into_reader()) // First, parse the JSON.
.map_err(Error::from)
.and_then(|json| {
// Let's first try to deserialize the outer response, which contains the type of the
// response (success or error) and the error type and message in case of an error.
let outer = serde_json::from_value::<OuterResponse>(json.clone())?;
match outer {
OuterResponse::Success => Ok(json), // Pass the JSON object to the next step.
OuterResponse::Error {
// If the response is an error, return an `Error::Api`.
error_type,
message,
} => Err(Error::Api {
kind: error_type
.unwrap_or_default()
.parse()
.unwrap_or(ErrorKind::Unknown),
message: message.unwrap_or("No error message provided".to_owned()),
}),
}
})
.and_then(|json| {
// Now that we know the response is successful, let's try to deserialize the inner
// response, which contains the actual data we want.
json.get(field)
.ok_or_else(|| serde_json::Error::missing_field(field))
.and_then(|v| serde_json::from_value::<T>(v.clone()))
.map_err(Error::from)
})
.map_err(|err| {
// If we can't parse the error response from the API, return the status instead.
if matches!(err, Error::Json { .. }) && (400..=599).contains(&status) {
Error::Api {
kind: ErrorKind::Status,
message: format!("{} {}", status, status_text),
}
} else {
err
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_success() {
#[derive(Deserialize)]
struct Foobar {
foo: String,
bar: String,
}
let res = ureq::Response::new(
200,
"OK",
r#"
{
"result": "success",
"foobar": {
"foo": "qux",
"bar": "baz"
},
"we": ["don't", "care", "about", "other", "fields"]
}
"#,
)
.unwrap();
let foo = parse_response::<Foobar>("foobar", res).unwrap();
assert_eq!(foo.foo, "qux");
assert_eq!(foo.bar, "baz");
}
#[test]
fn parse_error() {
// Here we should get an `Error::Api` with `kind` set to `ErrorKind::InvalidAuth`, since
// even though we are getting a 401 status code, the response is still a valid JSON object.
let res = ureq::Response::new(
401,
"Unauthorized",
r#"
{
"result": "error",
"error_type": "invalid_auth",
"message": "Invalid API key"
}
"#,
)
.unwrap();
let err = parse_response::<String>("foobar", res).unwrap_err();
assert!(matches!(
err,
Error::Api {
kind: ErrorKind::InvalidAuth,
..
}
));
}
#[test]
fn parse_invalid_json() {
// Here we should get an `Error::Json`, since the response is not a valid JSON object, and
// the status code is not 4xx or 5xx.
let res = ureq::Response::new(200, "OK", "not json").unwrap();
let err = parse_response::<String>("foobar", res).unwrap_err();
assert!(matches!(err, Error::Json { .. }));
}
#[test]
fn parse_invalid_json_error() {
// Here we should get an `Error::Api` with `kind` set to `ErrorKind::Status`, since the
// response is not a valid JSON object, and the status code is 4xx or 5xx.
let res = ureq::Response::new(401, "Unauthorized", "not json").unwrap();
let err = parse_response::<String>("foobar", res).unwrap_err();
let Error::Api { message, kind } = err else {
panic!("Expected an Error::Api {{ .. }}, got {:?}", err);
};
assert_eq!(kind, ErrorKind::Status);
assert_eq!(message, "401 Unauthorized");
}
}