From ccac5b984270f23d666ee0551a606f69909153e1 Mon Sep 17 00:00:00 2001 From: askiiart Date: Sat, 30 Nov 2024 01:12:37 -0600 Subject: [PATCH] finish implementing the capabilities (`caps`) api endpoint --- src/api.rs | 130 +++++++++++++++++++++++++++++++++++++++++++++------ src/data.rs | 22 ++------- src/dummy.rs | 59 ++++++++++++++--------- 3 files changed, 158 insertions(+), 53 deletions(-) diff --git a/src/api.rs b/src/api.rs index a1856d6..d0ed508 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,4 @@ //! Contains the actual Torznab API -use std::io::stdout; - use crate::data::*; use crate::dummy::create_empty_config; use lazy_static::lazy_static; @@ -8,7 +6,7 @@ use rocket::http::Status; use rocket::response::status; use rocket::FromForm; use rocket::{get, response::content::RawXml}; -use std::collections::HashMap; +use std::str; use xml::writer::{EmitterConfig, XmlEvent}; #[derive(Debug, Clone, PartialEq, Eq, FromForm)] @@ -106,12 +104,11 @@ lazy_static! { /// Capabilities API endpoint (`/api?t=caps`) /// /// Note that an apikey is *not* required for this function, regardless of whether it's required for the rest. -// FIXME: VERY incomplete #[get("/api?t=caps")] pub(crate) fn caps() -> status::Custom> { // The compiler won't let you get a field from a struct in the Option here, since the default is None // So this is needed - let mut conf = create_empty_config(); + let conf; unsafe { if CONFIG.is_none() { return (*STATUS_CONFIG_NOT_SPECIFIED).clone(); @@ -120,15 +117,122 @@ pub(crate) fn caps() -> status::Custom> { } } - let output = stdout(); - let mut writer = EmitterConfig::new().create_writer(output); + let buffer = Vec::new(); + let mut writer = EmitterConfig::new().create_writer(buffer); writer.write(XmlEvent::start_element("caps")).unwrap(); - writer.write(XmlEvent::start_element("server")).unwrap(); - writer.write(XmlEvent::end_element()).unwrap(); - writer.write(XmlEvent::start_element("caps")).unwrap(); - writer.write(XmlEvent::end_element()).unwrap(); - return status::Custom(Status::Ok, RawXml(stringify!(writer).to_string())); + + // add the server info + // TODO: Clean up the code by making the elements a Vec (to be used as a stack), rather than manually keeping track of them + let mut element = XmlEvent::start_element("server"); + match &conf.caps.server_info { + Some(server_info) => { + // needs to be a vec since if i just `.as_str()` them, they don't live long enough + let server_info_vec: Vec<(&String, &String)> = server_info.iter().collect(); + for (key, value) in server_info_vec { + element = element.attr(key.as_str(), value.as_str()); + } + } + None => {} + } + writer.write(element).unwrap(); + writer.write(XmlEvent::end_element()).unwrap(); // close `server` + + // add the limits + writer + .write( + XmlEvent::start_element("limits") + .attr("max", conf.caps.limits.max.to_string().as_str()) + .attr("default", conf.caps.limits.default.to_string().as_str()), + ) + .unwrap(); + writer.write(XmlEvent::end_element()).unwrap(); // close `limits` + + // Add the search types + writer.write(XmlEvent::start_element("searching")).unwrap(); + for item in conf.caps.searching { + let mut available = "yes"; + if !item.available { + available = "no"; + } + writer + .write( + XmlEvent::start_element(item.search_type.as_str()) + .attr("available", available) + .attr("supportedParams", item.supported_params.join(",").as_str()), + ) + .unwrap(); + writer.write(XmlEvent::end_element()).unwrap(); // close element + } + writer.write(XmlEvent::end_element()).unwrap(); // close `searching` + + writer.write(XmlEvent::start_element("categories")).unwrap(); + for i in conf.caps.categories { + writer + .write( + XmlEvent::start_element("category") + .attr("id", i.id.to_string().as_str()) + .attr("name", i.name.as_str()), + ) + .unwrap(); + for j in i.subcategories { + writer + .write( + XmlEvent::start_element("subcat") + .attr("id", j.id.to_string().as_str()) + .attr("name", j.name.as_str()), + ) + .unwrap(); + writer.write(XmlEvent::end_element()).unwrap(); // close `subcat` element + } + writer.write(XmlEvent::end_element()).unwrap(); // close `category` element + } + writer.write(XmlEvent::end_element()).unwrap(); // close `categories` + + match conf.caps.genres { + Some(genres) => { + writer.write(XmlEvent::start_element("genres")).unwrap(); + + for genre in genres { + writer + .write( + XmlEvent::start_element("genre") + .attr("id", genre.id.to_string().as_str()) + .attr("categoryid", genre.category_id.to_string().as_str()) + .attr("name", genre.name.as_str()), + ) + .unwrap(); + writer.write(XmlEvent::end_element()).unwrap(); // close `genre` element + } + writer.write(XmlEvent::end_element()).unwrap(); // close `genres` element + } + None => {} + } + + match conf.caps.tags { + Some(tags) => { + writer.write(XmlEvent::start_element("tags")).unwrap(); + + for tag in tags { + writer + .write( + XmlEvent::start_element("tag") + .attr("name", tag.name.as_str()) + .attr("description", tag.description.as_str()), + ) + .unwrap(); + } + writer.write(XmlEvent::end_element()).unwrap(); // close `tags` element + } + None => {} + } + + writer.write(XmlEvent::end_element()).unwrap(); // close `caps` + let result = str::from_utf8(writer.into_inner().as_slice()) + .unwrap() + .to_string(); // Convert buffer to a String + + return status::Custom(Status::Ok, RawXml(result)); } #[get("/api?t=search&")] @@ -137,7 +241,7 @@ pub(crate) fn caps() -> status::Custom> { pub(crate) fn search(form: SearchForm) -> status::Custom> { // The compiler won't let you get a field from a struct in the Option here, since the default is None // So this is needed - let mut conf = create_empty_config(); + let conf; unsafe { if CONFIG.is_none() { return (*STATUS_CONFIG_NOT_SPECIFIED).clone(); diff --git a/src/data.rs b/src/data.rs index 177f04d..1e68418 100644 --- a/src/data.rs +++ b/src/data.rs @@ -7,22 +7,6 @@ pub(crate) type AuthFunc = fn(String) -> Result; // TODO: Figure out what the arguments should be for a search function and what it should return pub(crate) type SearchFunc = fn(String, Vec) -> Result; -#[derive(Debug, Clone, PartialEq, Eq)] -/// Specify the ServerInfo to be listed in for `/api?t=caps` -/// -/// These fields are just those listed in the example on [torznab.github.io](https://torznab.github.io), there's no actual specification for thse fields. -/// TODO: Update this to have customizable fields instead -pub struct ServerInfo { - /// The title of the server - pub title: Option, - /// The email for the server info - pub email: Option, - /// The URL to the server's image (e.g. logo) - pub image: Option, - /// What version the server is - unrelated to torznab-toolkit's version, but may be used by the program - pub version: Option, -} - #[derive(Debug, Clone, PartialEq, Eq)] /// The maximum and defaults for the `limit` parameter in queries /// `max` is the maximum number of results the program can return @@ -47,6 +31,8 @@ pub struct SearchInfo { /// Whether this search type is available pub available: bool, /// The supported parameters for this search type + /// + /// Highly recommended: `q` (free text query) pub supported_params: Vec, } @@ -106,7 +92,9 @@ pub struct Tag { /// TODO: Add a way to partially(?) generate automatically from the Config pub struct Caps { /// The server info, like title - optional - pub server_info: Option, + /// + /// Examples: `version`, `title`, `email`, `url`, `image` + pub server_info: Option>, /// The max and default number of items to be returned by queries - see [`Limits`] pub limits: Limits, /// Info about each type of search diff --git a/src/dummy.rs b/src/dummy.rs index 4b88fcb..6f1f7b5 100644 --- a/src/dummy.rs +++ b/src/dummy.rs @@ -1,5 +1,24 @@ //! Some dummy stuff for testing the API use crate::data::*; +use std::collections::HashMap; + +#[macro_export] +/// HashMap equivalent of vec![] +/// +/// Example: +/// ```rs +/// hashmap!(("key", "value")) +/// ``` +macro_rules! hashmap { + ($(($value1:expr, $value2:expr)),*) => {{ + let mut hm = HashMap::new(); + $( + hm.insert($value1, $value2); + )* + + hm + }}; +} fn dummy_search_func(_a: String, _b: Vec) -> Result { return Ok("hi".to_string()); @@ -11,49 +30,43 @@ fn dummy_auth_func(_a: String) -> Result { /// Creates a bare-minimum config pub(crate) fn create_empty_config() -> Config { - let mut searching = Vec::new(); - searching.push(SearchInfo { + let searching = vec![SearchInfo { search_type: "search".to_string(), available: true, - supported_params: vec!["id".to_string()], - }); + supported_params: vec!["q".to_string()], + }]; - let mut subcategories = Vec::new(); - subcategories.push(Subcategory { + let subcategories = vec![Subcategory { id: 1010, name: "b".to_string(), - }); + }]; - let mut categories = Vec::new(); - categories.push(Category { + let categories = vec![Category { id: 1000, - name: "b".to_string(), + name: "a".to_string(), subcategories: subcategories, - }); + }]; - let mut genres = Vec::new(); - genres.push(Genre { + let genres = vec![Genre { id: 1, category_id: 1000, name: "c".to_string(), - }); + }]; - let mut tags = Vec::new(); - tags.push(Tag { + let tags = vec![Tag { name: "a".to_string(), description: "b".to_string(), - }); + }]; return Config { search: dummy_search_func, auth: Some(dummy_auth_func), caps: Caps { - server_info: Some(ServerInfo { - title: Some("Test Torznab server".to_string()), - email: Some("test@example.com".to_string()), - image: None, - version: Some("1.0".to_string()), - }), + server_info: Some(hashmap!( + ("title".to_string(), "Test Torznab server".to_string()), + ("email".to_string(), "test@example.com".to_string()), + ("version".to_string(), "1.0".to_string()) + )), limits: Limits { max: 100, default: 20,