diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..8af59dd --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +RUST_TEST_THREADS = "1" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..8ce0959 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,31 @@ +on: [push, pull_request] + +name: CI + +jobs: + fmt: + name: cargo fmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + - run: cargo fmt --all -- --check + + clippy: + name: cargo clippy + runs-on: ubuntu-latest + strategy: + matrix: + features: ["", "app", "client", "full"] + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + - run: cargo clippy --features ${{ matrix.features }} + + test: + name: cargo test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --all-features diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8886ee1 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,19 @@ +name: Release + +permissions: + contents: write + +on: + push: + tags: + - v[0-9]+.* + +jobs: + create-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: taiki-e/create-gh-release-action@v1 + with: + branch: main + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7af7354 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "gotify" +version = "0.1.0" +edition = "2021" +description = "Idiomatic client for the Gotify API" +repository = "https://github.com/d-k-bo/gotify-rs" +authors = ["d-k-bo "] +license = "MIT" +categories = ["api-bindings"] +keywords = ["gotify", "notify", "notifications", "push-notifications"] + +[features] +default = [] +# Enable all features +full = ["app", "client"] +# Create messages +app = [] +# Manage the server, use `manage-*` or `websocket` for finer grained control +client = [ + "manage-applications", + "manage-clients", + "manage-messages", + "manage-plugins", + "manage-users", + "websocket", +] +client-core = [] +# Create, read, update and delete applications or modify application images +manage-applications = ["client-core"] +# List, create, update or delete clients +manage-clients = ["client-core"] +# List or delete messages +manage-messages = ["client-core"] +# List or configure plugins +manage-plugins = ["client-core"] +# List, create, update or delete users +manage-users = ["client-core"] +# Subscribe to newly created messages via a websocket +websocket = ["client-core", "async-stream", "futures-util", "tokio-tungstenite"] + +[dependencies] +async-stream = { version = "0.3.5", optional = true } +futures-util = { version = "0.3.28", optional = true } +paste = "1.0.14" +reqwest = { version = "0.11.12", features = ["json", "multipart"] } +serde = { version = "1.0.145", features = ["derive"] } +serde_json = "1.0.86" +thiserror = "1.0.37" +time = { version = "0.3.25", features = ["serde", "parsing", "formatting"] } +tokio-tungstenite = { version = "0.20.0", optional = true } +url = "2.3.1" + +[dev-dependencies] +eyre = "0.6.8" +futures-util = "0.3.28" +macro_rules_attribute = "0.2.0" +tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread", "time"] } +zip = { version = "0.6.6", default-features = false, features = ["deflate"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7d7d552 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 d-k-bo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9e961f --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# gotify-rs + +[![Build Status](https://github.com/d-k-bo/gotify/workflows/CI/badge.svg)](https://github.com/d-k-bo/gotify/actions?query=workflow%3ACI) +[![Crates.io](https://img.shields.io/crates/v/gotify)](https://lib.rs/crates/gotify) +[![Documentation](https://img.shields.io/docsrs/gotify)](https://docs.rs/gotify) +[![License: MIT](https://img.shields.io/crates/l/gotify)](LICENSE) + + + +An idiomatic Rust client for Gotify. + +### Overview + +By default, this crate only exposes the `Client::health()`, `Client::version()` methods. +All other categories of endpoints must be enabled by the correspondig feature flags. + +| Feature flag | Enabled methods | Note | +| ------------ | --------------- | ---- | +| `app` | `Client::create_message()` | | +| `manage-clients` | `Client::get_clients()`, `Client::create_client()`, `Client::update_client()`, `Client::delete_client()` | | +| `manage-messages` | `Client::get_application_messages()`, `Client::delete_application_messages()`, `Client::get_messages()`, `Client::delete_messages()`, `Client::delete_message()` | doesn't include `Client::create_message()` and `Client::message_stream()` | +| `manage-plugins` | `Client::get_plugins()`, `Client::get_plugin_config()`, `Client::update_plugin_config()`, `Client::disable_plugin()`, `Client::get_plugin_display()`, `Client::enable_plugin()` | | +| `websocket` | `Client::message_stream()` | enables additional dependencies (mainly [`tokio-tungstenite`](https://docs.rs/tokio-tungstenite)) | + +### Examples + +#### Creating a message + +```rust +let client: gotify::AppClient = gotify::Client::new(GOTIFY_URL, GOTIFY_APP_TOKEN)?; + +client.create_message("Lorem ipsum dolor sit amet").with_title("Lorem Ipsum").await?; +``` + +#### Listening for new messages + +```rust +use futures_util::StreamExt; + +let client: gotify::ClientClient = gotify::Client::new(GOTIFY_URL, GOTIFY_CLIENT_TOKEN)?; + +let mut messages = client.message_stream().await?; + +while let Some(result) = messages.next().await { + let message = result?; + + println!("{message:#?}") +} +``` + + + +## License + +This project is licensed under the MIT License. + +See [LICENSE](LICENSE) for more information. diff --git a/examples/create_message.rs b/examples/create_message.rs new file mode 100644 index 0000000..aed250b --- /dev/null +++ b/examples/create_message.rs @@ -0,0 +1,11 @@ +#[tokio::main] +async fn main() -> eyre::Result<()> { + let client: gotify::AppClient = + gotify::Client::new(env!("GOTIFY_URL"), env!("GOTIFY_APP_TOKEN"))?; + + client + .create_message("Lorem ipsum dolor sit amet") + .with_title("Lorem Ipsum") + .await?; + Ok(()) +} diff --git a/examples/websocket.rs b/examples/websocket.rs new file mode 100644 index 0000000..6078342 --- /dev/null +++ b/examples/websocket.rs @@ -0,0 +1,13 @@ +use futures_util::StreamExt; + +#[tokio::main] +async fn main() -> eyre::Result<()> { + let client: gotify::ClientClient = + gotify::Client::new(env!("GOTIFY_URL"), env!("GOTIFY_CLIENT_TOKEN"))?; + let mut messages = client.message_stream().await?; + while let Some(result) = messages.next().await { + let message = result?; + println!("{message:#?}") + } + Ok(()) +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..8db3fb1 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,65 @@ +use std::collections::HashMap; + +use reqwest::Method; + +use crate::{models::Message, utils::request_builder, AppClient}; + +/// Create messages. +impl AppClient { + /// Create a message. + pub fn create_message(&self, message: impl Into) -> MessageBuilder { + MessageBuilder::new(self, message) + } +} + +request_builder! { + name = MessageBuilder, + client_type = AppClient, + method = Method::POST, + uri = ["message"], + return_type = Message, + required_fields = { + message: impl Into => .into() => String, + }, + optional_fields = { + title: impl Into => .into() => String, + extras: impl Into> => .into() => HashMap, + priority: u8 => u8, + } +} + +#[cfg(test)] +pub(crate) mod tests { + use crate::testsuite::*; + + #[apply(run_test_server!)] + #[test] + async fn create_message() -> eyre::Result<()> { + let client = app_client(); + + let message = client.create_message("Hello World").await?; + assert_eq!(message.message, "Hello World"); + + let message = client + .create_message("Hello World") + .with_title("Hi") + .with_priority(7) + .with_extras([("foo".into(), "bar".into())]) + .await?; + assert_eq!(message.title.as_deref(), Some("Hi")); + assert_eq!(message.message, "Hello World"); + assert_eq!(message.priority, 7); + assert_eq!( + message + .extras + .unwrap() + .get("foo") + .unwrap() + .as_str() + .unwrap(), + "bar" + ); + + Ok(()) + } +} diff --git a/src/applications.rs b/src/applications.rs new file mode 100644 index 0000000..b544d59 --- /dev/null +++ b/src/applications.rs @@ -0,0 +1,175 @@ +use std::borrow::Cow; + +use reqwest::Method; + +use crate::{models::Application, utils::request_builder, ClientClient, Result}; + +/// Create, read, update and delete applications or modify application images. +impl ClientClient { + /// Return all applications. + pub async fn get_applications(&self) -> Result> { + self.request(Method::GET, ["application"]).await + } + /// Create an application. + pub fn create_application(&self, name: impl Into) -> ApplicationBuilder { + ApplicationBuilder::new(self, name) + } + /// Update an application. + pub fn update_application(&self, id: i64, name: impl Into) -> ApplicationUpdateBuilder { + ApplicationUpdateBuilder::new(self, id, name) + } + /// Delete an application. + pub async fn delete_application(&self, id: i64) -> Result<()> { + self.request(Method::DELETE, ["application".into(), id.to_string()]) + .await + } + /// Upload an image for an application. + pub async fn upload_application_image( + &self, + id: i64, + image_name: impl Into>, + image_content: impl Into>, + ) -> Result { + self.request_with_binary_body( + Method::POST, + ["application".into(), id.to_string(), "image".into()], + image_name, + image_content, + ) + .await + } + /// Delete an image of an application. + pub async fn delete_application_image(&self, id: i64) -> Result<()> { + self.request( + Method::DELETE, + ["application".into(), id.to_string(), "image".into()], + ) + .await + } +} + +request_builder! { + name = ApplicationBuilder, + client_type = ClientClient, + method = Method::POST, + uri = ["application"], + return_type = Application, + required_fields = { + name: impl Into => .into() => String, + }, + optional_fields = { + default_priority: u8 => u8, + description: impl Into => .into() => String, + } +} +request_builder! { + name = ApplicationUpdateBuilder, + client_type = ClientClient, + method = Method::PUT, + uri_with = |builder: &Self| ["application".into(), builder.id.to_string()], + return_type = Application, + required_fields = { + #[serde(skip)] + id: i64 => i64, + name: impl Into => .into() => String, + }, + optional_fields = { + default_priority: u8 => u8, + description: impl Into => .into() => String, + } +} + +#[cfg(test)] +pub(crate) mod tests { + use crate::testsuite::*; + + #[apply(run_test_server!)] + #[test] + async fn get_applications() -> eyre::Result<()> { + let applications = client_client().get_applications().await?; + + assert_eq!( + applications + .into_iter() + .take(3) + .map(|a| a.name) + .collect::>(), + vec!["gotify-rs", "App0", "App1"] + ); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn create_application() -> eyre::Result<()> { + let client = client_client(); + + let application = client.create_application("new-application1").await?; + assert_eq!(application.name, "new-application1"); + assert_eq!(application.description, ""); + + let application = client + .create_application("new-application2") + .with_description("application with a description") + .await?; + assert_eq!(application.name, "new-application2"); + assert_eq!(application.description, "application with a description"); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn update_application() -> eyre::Result<()> { + let client = client_client(); + + let application = client.create_application("new-application").await?; + assert_eq!(application.name, "new-application"); + assert_eq!(application.description, ""); + + let application = client + .update_application(application.id, "updated-application") + .with_description("updated application") + .await?; + assert_eq!(application.name, "updated-application"); + assert_eq!(application.description, "updated application"); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn delete_application() -> eyre::Result<()> { + let client = client_client(); + + assert!(client.get_applications().await?.iter().any(|a| a.id == 1)); + + client.delete_application(1).await?; + + assert!(!client.get_applications().await?.iter().any(|a| a.id == 1)); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn update_application_image() -> eyre::Result<()> { + let client = client_client(); + + assert!(client + .get_applications() + .await? + .iter() + .find(|a| a.id == 1) + .is_some_and(|a| a.image == "static/defaultapp.png")); + + let application = client + .upload_application_image(1, "img.png", include_bytes!("../tests/img.png").as_slice()) + .await?; + + assert!(application.image.starts_with("image/")); + + Ok(()) + } +} diff --git a/src/clients.rs b/src/clients.rs new file mode 100644 index 0000000..fded4f2 --- /dev/null +++ b/src/clients.rs @@ -0,0 +1,119 @@ +use reqwest::Method; + +use crate::{models::Client, utils::request_builder, ClientClient, Result}; + +/// List, create, update or delete clients. +impl ClientClient { + /// Return all clients. + pub async fn get_clients(&self) -> Result> { + self.request(Method::GET, ["client"]).await + } + /// Create a client. + pub fn create_client(&self, name: impl Into) -> ClientBuilder { + ClientBuilder::new(self, name) + } + /// Update a client. + pub fn update_client(&self, id: i64, name: impl Into) -> ClientUpdateBuilder { + ClientUpdateBuilder::new(self, id, name) + } + /// Delete a client. + pub async fn delete_client(&self, id: i64) -> Result<()> { + self.request(Method::DELETE, ["client".into(), id.to_string()]) + .await + } +} + +request_builder! { + name = ClientBuilder, + client_type = ClientClient, + method = Method::POST, + uri = ["client"], + return_type = Client, + required_fields = { + name: impl Into => .into() => String, + }, + optional_fields = {} +} +request_builder! { + name = ClientUpdateBuilder, + client_type = ClientClient, + method = Method::PUT, + uri_with = |builder: &Self| ["client".into(), builder.id.to_string()], + return_type = Client, + required_fields = { + #[serde(skip)] + id: i64 => i64, + name: impl Into => .into() => String, + }, + optional_fields = {} +} + +#[cfg(test)] +pub(crate) mod tests { + use crate::testsuite::*; + + #[apply(run_test_server!)] + #[test] + async fn get_clients() -> eyre::Result<()> { + let clients = client_client().get_clients().await?; + + assert_eq!( + clients.into_iter().map(|a| a.name).collect::>(), + vec!["gotify-rs"] + ); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn create_client() -> eyre::Result<()> { + let client = client_client().create_application("new-client").await?; + assert_eq!(client.name, "new-client"); + assert_eq!(client.description, ""); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn update_client() -> eyre::Result<()> { + let client = client_client(); + + assert!(!client + .get_clients() + .await? + .iter() + .any(|a| a.name == "new-client-name")); + + let client = client.update_client(1, "new-client-name").await?; + + assert_eq!(client.name, "new-client-name"); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn delete_client() -> eyre::Result<()> { + let client = client_client(); + + let new_client = client.create_client("new-client").await?; + + assert!(client + .get_clients() + .await? + .iter() + .any(|a| a.id == new_client.id)); + + client.delete_client(new_client.id).await?; + + assert!(!client + .get_clients() + .await? + .iter() + .any(|a| a.id == new_client.id)); + + Ok(()) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..5e0072a --- /dev/null +++ b/src/error.rs @@ -0,0 +1,26 @@ +use reqwest::header::InvalidHeaderValue; + +/// Errors that can occur when creating or authenticating a [`Client`](crate::Client). +#[allow(missing_docs)] +#[derive(Debug, thiserror::Error)] +pub enum InitError { + #[error("could not parse the server URL")] + InvalidUrl(#[from] url::ParseError), + #[error("invalid access token")] + InvalidAccessToken(#[from] InvalidHeaderValue), + #[error("failed to initialize the HTTP client")] + Reqwest(#[from] reqwest::Error), +} + +/// Errors that can occur when accessing an API endpoint. +#[allow(missing_docs)] +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("HTTP request failed")] + Reqwest(#[from] reqwest::Error), + #[error("Gotify's API returned an error")] + Response(#[from] crate::models::Error), +} + +/// Alias for the `Result` returned when accessing an API endpoint. +pub type Result = core::result::Result; diff --git a/src/health.rs b/src/health.rs new file mode 100644 index 0000000..ceac8d7 --- /dev/null +++ b/src/health.rs @@ -0,0 +1,25 @@ +use reqwest::Method; + +use crate::{models::Health, Client, Result}; + +impl Client { + /// Get health information. + pub async fn health(&self) -> Result { + self.request(Method::GET, ["health"]).await + } +} + +#[cfg(test)] +mod tests { + use crate::testsuite::*; + + #[apply(run_test_server!)] + #[test] + async fn health() -> eyre::Result<()> { + let health = unauthenticated_client().health().await?; + assert_eq!(health.database, "green"); + assert_eq!(health.health, "green"); + + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8e1f897 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,327 @@ +//! An idiomatic Rust client for Gotify. +//! +//! ## Overview +//! +//! By default, this crate only exposes the [`Client::health()`](crate::Client::health), [`Client::version()`](crate::Client::version) methods. +//! All other categories of endpoints must be enabled by the correspondig feature flags. +//! +//! | Feature flag | Enabled methods | Note | +//! | ------------ | --------------- | ---- | +//! | `app` | [`Client::create_message()`](crate::Client::create_message) | | +//! | `manage-clients` | [`Client::get_clients()`](crate::Client::get_clients), [`Client::create_client()`](crate::Client::create_client), [`Client::update_client()`](crate::Client::update_client), [`Client::delete_client()`](crate::Client::delete_client) | | +//! | `manage-messages` | [`Client::get_application_messages()`](crate::Client::get_application_messages), [`Client::delete_application_messages()`](crate::Client::delete_application_messages), [`Client::get_messages()`](crate::Client::get_messages), [`Client::delete_messages()`](crate::Client::delete_messages), [`Client::delete_message()`](crate::Client::delete_message) | doesn't include [`Client::create_message()`](crate::Client::create_message) and [`Client::message_stream()`](crate::Client::message_stream) | +//! | `manage-plugins` | [`Client::get_plugins()`](crate::Client::get_plugins), [`Client::get_plugin_config()`](crate::Client::get_plugin_config), [`Client::update_plugin_config()`](crate::Client::update_plugin_config), [`Client::disable_plugin()`](crate::Client::disable_plugin), [`Client::get_plugin_display()`](crate::Client::get_plugin_display), [`Client::enable_plugin()`](crate::Client::enable_plugin) | | +//! | `websocket` | [`Client::message_stream()`](crate::Client::message_stream) | enables additional dependencies (mainly [`tokio-tungstenite`](https://docs.rs/tokio-tungstenite)) | +//! +//! ## Examples +//! +//! ### Creating a message +//! +//! ```ignore +//! let client: gotify::AppClient = gotify::Client::new(GOTIFY_URL, GOTIFY_APP_TOKEN)?; +//! +//! client.create_message("Lorem ipsum dolor sit amet").with_title("Lorem Ipsum").await?; +//! ``` +//! +//! ### Listening for new messages +//! +//! ```ignore +//! use futures_util::StreamExt; +//! +//! let client: gotify::ClientClient = gotify::Client::new(GOTIFY_URL, GOTIFY_CLIENT_TOKEN)?; +//! +//! let mut messages = client.message_stream().await?; +//! +//! while let Some(result) = messages.next().await { +//! let message = result?; +//! +//! println!("{message:#?}") +//! } +//! ``` + +#![warn(missing_docs)] + +use std::marker::PhantomData; + +use reqwest::{ + header::{HeaderMap, HeaderValue, InvalidHeaderValue}, + Method, +}; +use url::Url; + +use crate::utils::UrlAppend; + +pub use crate::error::{Error, InitError, Result}; +#[cfg(feature = "websocket")] +pub use crate::websocket::{WebsocketConnectError, WebsocketError}; + +pub mod models; + +/// Builder structs used by some methods that send data to Gotify's API. +/// +/// While they provide a `send()` method, they also implement +/// [`IntoFuture`](std::future::IntoFuture) and can be `await`ed directly. +pub mod builder { + #[cfg(feature = "app")] + pub use crate::app::MessageBuilder; + #[cfg(feature = "manage-applications")] + pub use crate::applications::{ApplicationBuilder, ApplicationUpdateBuilder}; + #[cfg(feature = "manage-clients")] + pub use crate::clients::{ClientBuilder, ClientUpdateBuilder}; + #[cfg(feature = "manage-messages")] + pub use crate::messages::{GetApplicationMessagesBuilder, GetMessagesBuilder}; + #[cfg(feature = "manage-users")] + pub use crate::users::{CreateUserBuilder, UpdateCurrentUserBuilder, UpdateUserBuilder}; +} + +#[cfg(feature = "app")] +mod app; +#[cfg(feature = "manage-applications")] +mod applications; +#[cfg(feature = "manage-clients")] +mod clients; +mod error; +mod health; +#[cfg(feature = "manage-messages")] +mod messages; +#[cfg(feature = "manage-plugins")] +mod plugins; +#[cfg(feature = "manage-users")] +mod users; +mod version; +#[cfg(feature = "websocket")] +mod websocket; + +#[cfg(test)] +pub(crate) mod testsuite; +mod utils; + +/// A client for a specific Gotify server. The main entrypoint of this crate. +/// +/// It comes in three varieties to perform different tasks. +/// +/// | Type | Explanation | Feature flag | +/// | ---- | ------------ | ----------------- | +/// | [`UnauthenticatedClient = Client`](crate::UnauthenticatedClient) | get server status and version info | always enabled | +/// | [`AppClient = Client`](crate::AppClient) | create messages | `app` | +/// | [`ClientClient = Client`](crate::ClientClient) | manage the server (anything else) | any of `manage-*` or `websocket` | +#[derive(Clone, Debug)] +pub struct Client { + base_url: Url, + http: reqwest::Client, + token: PhantomData, +} + +/// A client that is authenticated to create messages. +#[cfg(feature = "app")] +pub type AppClient = Client; + +/// A client that is authenticated to manage the server. +#[cfg(feature = "client-core")] +pub type ClientClient = Client; + +/// A client that is unauthenticated. +pub type UnauthenticatedClient = Client; + +/// Marks a client as authenticated to create messages. +#[cfg(feature = "app")] +#[derive(Clone, Debug)] +pub struct AppToken; + +/// Marks a client as authenticated to manage the server. +#[cfg(feature = "client-core")] +#[derive(Clone, Debug)] +pub struct ClientToken; + +/// Marks a client as unauthenticated. +#[derive(Clone, Debug)] +pub struct Unauthenticated; + +/// Sealed trait to represent an [`AppToken`] or [`ClientToken`]. +pub trait TokenType: private::Sealed {} + +#[cfg(feature = "app")] +impl TokenType for AppToken {} + +#[cfg(feature = "client-core")] +impl TokenType for ClientToken {} + +mod private { + pub trait Sealed {} + + #[cfg(feature = "app")] + impl Sealed for super::AppToken {} + + #[cfg(feature = "client-core")] + impl Sealed for super::ClientToken {} +} + +#[cfg(any(feature = "app", feature = "client-core"))] +impl Client { + /// Create a new authenticated client. + /// + /// The type of the used access token (app token or client token) + /// must be provided as a generic parameter or be inferable. + pub fn new( + server_url: impl TryInto, + access_token: impl TryInto, + ) -> core::result::Result { + Ok(Client { + base_url: server_url.try_into()?, + http: reqwest::Client::builder() + .default_headers({ + let mut headers = HeaderMap::new(); + headers.insert("X-Gotify-Key", access_token.try_into()?); + headers + }) + .build() + .map_err(InitError::Reqwest)?, + token: PhantomData, + }) + } +} + +impl Client { + /// Create a new unauthenticated client. + /// + /// This type by itself has very limited capabilities but can be authenticated later on. + pub fn new_unauthenticated( + server_url: impl TryInto, + ) -> core::result::Result { + Ok(Client { + base_url: server_url.try_into()?, + http: reqwest::Client::new(), + token: PhantomData, + }) + } + + /// Create an authenticated client from this unauthenicated client. + /// + /// The type of the used access token (app token or client token) + /// must be provided as a generic parameter or be inferable. + pub fn authenticate( + self, + access_token: impl TryInto, + ) -> core::result::Result, InitError> { + Ok(Client { + base_url: self.base_url, + http: reqwest::Client::builder() + .default_headers({ + let mut headers = HeaderMap::new(); + headers.insert("X-Gotify-Key", access_token.try_into()?); + headers + }) + .build() + .map_err(InitError::Reqwest)?, + token: PhantomData, + }) + } +} + +impl Client { + async fn request serde::Deserialize<'a> + 'static>( + &self, + method: Method, + uri: impl IntoIterator>, + ) -> Result { + let r = self + .http + .request(method, self.base_url.append(uri)) + .send() + .await?; + + if r.status().is_success() { + if std::any::TypeId::of::() == std::any::TypeId::of::<()>() { + Ok(serde_json::de::from_str("null").unwrap()) + } else { + Ok(r.json().await?) + } + } else { + Err(Error::Response(r.json().await?)) + } + } + #[cfg(any(feature = "app", feature = "client-core"))] + async fn request_with_body serde::Deserialize<'a> + 'static>( + &self, + method: Method, + uri: impl IntoIterator>, + body: impl serde::Serialize, + ) -> Result { + let r = match method { + Method::GET => self + .http + .request(method, self.base_url.append(uri)) + .query(&body), + _ => self + .http + .request(method, self.base_url.append(uri)) + .json(&body), + } + .send() + .await?; + + if r.status().is_success() { + if std::any::TypeId::of::() == std::any::TypeId::of::<()>() { + Ok(serde_json::de::from_str("null").unwrap()) + } else { + Ok(r.json().await?) + } + } else { + Err(Error::Response(r.json().await?)) + } + } + #[cfg(feature = "manage-messages")] + async fn request_with_binary_body serde::Deserialize<'a> + 'static>( + &self, + method: Method, + uri: impl IntoIterator>, + file_name: impl Into>, + file_content: impl Into>, + ) -> Result { + use reqwest::multipart::{Form, Part}; + + let r = self + .http + .request(method, self.base_url.append(uri)) + .multipart(Form::new().part("file", Part::bytes(file_content).file_name(file_name))) + .send() + .await?; + + if r.status().is_success() { + if std::any::TypeId::of::() == std::any::TypeId::of::<()>() { + Ok(serde_json::de::from_str("null").unwrap()) + } else { + Ok(r.json().await?) + } + } else { + Err(Error::Response(r.json().await?)) + } + } +} + +#[cfg(test)] +mod tests { + use crate::testsuite::*; + + #[apply(run_test_server!)] + #[test] + async fn authenticate() -> eyre::Result<()> { + use crate::{AppToken, ClientToken}; + + let client = unauthenticated_client(); + + let app_client = client + .as_ref() + .clone() + .authenticate::(GOTIFY_APP_TOKEN)?; + let client_client = client + .as_ref() + .clone() + .authenticate::(GOTIFY_CLIENT_TOKEN)?; + + app_client.create_message("foobar").await?; + client_client.get_messages().await?; + + Ok(()) + } +} diff --git a/src/messages.rs b/src/messages.rs new file mode 100644 index 0000000..3ac6b1d --- /dev/null +++ b/src/messages.rs @@ -0,0 +1,169 @@ +use reqwest::Method; + +use crate::{models::PagedMessages, utils::request_builder, ClientClient, Result}; + +/// List or delete messages. +impl ClientClient { + /// Return all messages from a specific application. + pub fn get_application_messages(&self, id: i64) -> GetApplicationMessagesBuilder { + GetApplicationMessagesBuilder::new(self, id) + } + /// Delete all messages from a specific application. + pub async fn delete_application_messages(&self, id: i64) -> Result<()> { + self.request( + Method::DELETE, + ["application".into(), id.to_string(), "message".into()], + ) + .await + } + /// Return all messages. + pub fn get_messages(&self) -> GetMessagesBuilder { + GetMessagesBuilder::new(self) + } + /// Delete all messages. + pub async fn delete_messages(&self) -> Result<()> { + self.request(Method::DELETE, ["message"]).await + } + /// Delete a message with an id. + pub async fn delete_message(&self, id: i64) -> Result<()> { + self.request(Method::DELETE, ["message".into(), id.to_string()]) + .await + } +} + +request_builder! { + name = GetApplicationMessagesBuilder, + client_type = ClientClient, + method = Method::GET, + uri_with = |builder: &Self| ["application".into(), builder.id.to_string(), "message".into()], + return_type = PagedMessages, + required_fields = { + #[serde(skip)] + id: i64 => i64, + }, + optional_fields = { + limit: usize => usize, + since: i64 => i64, + } +} +request_builder! { + name = GetMessagesBuilder, + client_type = ClientClient, + method = Method::GET, + uri = ["message"], + return_type = PagedMessages, + required_fields = {}, + optional_fields = { + limit: usize => usize, + since: i64 => i64, + } +} + +#[cfg(test)] +pub(crate) mod tests { + use crate::testsuite::*; + + #[apply(run_test_server!)] + #[test] + async fn get_application_messages() -> eyre::Result<()> { + let client = client_client(); + + let messages = client.get_application_messages(3).await?; + + assert_eq!( + messages + .messages + .iter() + .map(|m| &m.message) + .collect::>(), + vec!["App1-Message1", "App1-Message0"] + ); + + let messages = client.get_application_messages(3).with_limit(1).await?; + + assert_eq!( + messages + .messages + .iter() + .map(|m| &m.message) + .collect::>(), + vec!["App1-Message1"] + ); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn delete_application_messages() -> eyre::Result<()> { + let client = client_client(); + + assert!(!client + .get_application_messages(3) + .await? + .messages + .is_empty()); + + client.delete_application_messages(3).await?; + + assert!(client + .get_application_messages(3) + .await? + .messages + .is_empty()); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn get_messages() -> eyre::Result<()> { + let client = client_client(); + + let messages = client.get_messages().with_limit(3).await?; + assert_eq!(messages.messages.len(), 3); + + let messages = client.get_messages().with_since(5).await?; + assert_eq!(messages.messages.len(), 4); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn delete_messages() -> eyre::Result<()> { + let client = client_client(); + + assert!(!client.get_messages().await?.messages.is_empty()); + + client.delete_messages().await?; + + assert!(client.get_messages().await?.messages.is_empty()); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn delete_message() -> eyre::Result<()> { + let client = client_client(); + + assert!(client + .get_messages() + .await? + .messages + .iter() + .any(|m| m.id == 1)); + + client.delete_message(1).await?; + + assert!(!client + .get_messages() + .await? + .messages + .iter() + .any(|m| m.id == 1)); + + Ok(()) + } +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..1da059d --- /dev/null +++ b/src/models.rs @@ -0,0 +1,110 @@ +//! JSON models returned by Gotify's API. +#![allow(missing_docs)] + +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "manage-applications")] +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Application { + pub default_priority: Option, + pub description: String, + pub id: i64, + pub image: String, + pub internal: bool, + pub name: String, + pub token: String, +} + +#[cfg(feature = "manage-clients")] +#[derive(Debug, Deserialize, Serialize)] +pub struct Client { + pub id: i64, + pub name: String, + pub token: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Error { + pub error: String, + pub error_code: u16, + pub error_description: String, +} +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} {}: {}", + self.error_code, self.error, self.error_description + ) + } +} +impl std::error::Error for Error {} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Health { + pub database: String, + pub health: String, +} + +#[cfg(any(feature = "app", feature = "manage-messages", feature = "websocket"))] +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Message { + pub appid: i64, + #[serde(with = "time::serde::iso8601")] + pub date: time::OffsetDateTime, + pub extras: Option>, + pub id: i64, + pub message: String, + pub priority: u8, + pub title: Option, +} + +#[cfg(feature = "manage-messages")] +#[derive(Debug, Deserialize, Serialize)] +pub struct PagedMessages { + pub messages: Vec, + pub paging: Paging, +} + +#[cfg(feature = "manage-messages")] +#[derive(Debug, Deserialize, Serialize)] +pub struct Paging { + pub limit: usize, + pub next: Option, + pub since: i64, + pub size: usize, +} + +#[cfg(feature = "manage-plugins")] +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginConf { + pub author: Option, + pub capabilities: Vec, + pub enabled: bool, + pub id: i64, + pub license: Option, + pub module_path: String, + pub name: String, + pub token: String, + pub website: Option, +} + +#[cfg(feature = "manage-users")] +#[derive(Debug, Deserialize, Serialize)] +pub struct User { + pub admin: bool, + pub id: i64, + pub name: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VersionInfo { + pub build_date: String, + pub commit: String, + pub version: String, +} diff --git a/src/plugins.rs b/src/plugins.rs new file mode 100644 index 0000000..6776e43 --- /dev/null +++ b/src/plugins.rs @@ -0,0 +1,63 @@ +use reqwest::Method; + +use crate::{models::PluginConf, ClientClient, Result}; + +/// List or configure plugins. +impl ClientClient { + /// Return all plugins. + pub async fn get_plugins(&self) -> Result> { + self.request(Method::GET, ["plugin"]).await + } + /// Get YAML configuration for Configurer plugin. + pub async fn get_plugin_config(&self, id: i64) -> Result { + self.request( + Method::GET, + ["plugin".into(), id.to_string(), "config".into()], + ) + .await + } + /// Update YAML configuration for Configurer plugin. + pub async fn update_plugin_config(&self, config: String) -> Result<()> { + // TODO: send body as text + self.request_with_body(Method::GET, ["user"], config).await + } + /// Disable a plugin. + pub async fn disable_plugin(&self, id: i64) -> Result<()> { + self.request( + Method::POST, + ["plugin".into(), id.to_string(), "disable".into()], + ) + .await + } + /// Get display info for a Displayer plugin. + pub async fn get_plugin_display(&self, id: i64) -> Result { + self.request( + Method::GET, + ["plugin".into(), id.to_string(), "display".into()], + ) + .await + } + /// Enable a plugin. + pub async fn enable_plugin(&self, id: i64) -> Result<()> { + self.request( + Method::POST, + ["plugin".into(), id.to_string(), "enable".into()], + ) + .await + } +} + +#[cfg(test)] +pub(crate) mod tests { + use crate::testsuite::*; + + #[apply(run_test_server!)] + #[test] + async fn get_plugins() -> eyre::Result<()> { + let clients = client_client().get_plugins().await?; + + assert!(clients.is_empty()); + + Ok(()) + } +} diff --git a/src/testsuite.rs b/src/testsuite.rs new file mode 100644 index 0000000..53aecd9 --- /dev/null +++ b/src/testsuite.rs @@ -0,0 +1,172 @@ +use std::sync::{Arc, OnceLock}; + +pub use macro_rules_attribute::apply; + +use super::*; + +pub const GOTIFY_URL: &str = "http://localhost:30080"; +pub const GOTIFY_APP_TOKEN: &str = "AGo8b9paHo5wPkI"; +pub const GOTIFY_CLIENT_TOKEN: &str = "C4er8DTiNk08mtt"; + +pub fn unauthenticated_client() -> Arc { + static CLIENT: OnceLock> = OnceLock::new(); + + CLIENT + .get_or_init(|| Arc::new(UnauthenticatedClient::new_unauthenticated(GOTIFY_URL).unwrap())) + .clone() +} + +pub fn app_client() -> Arc { + static CLIENT: OnceLock> = OnceLock::new(); + + CLIENT + .get_or_init(|| Arc::new(AppClient::new(GOTIFY_URL, GOTIFY_APP_TOKEN).unwrap())) + .clone() +} + +pub fn client_client() -> Arc { + static CLIENT: OnceLock> = OnceLock::new(); + + CLIENT + .get_or_init(|| Arc::new(ClientClient::new(GOTIFY_URL, GOTIFY_CLIENT_TOKEN).unwrap())) + .clone() +} + +macro_rules! run_test_server { + ( + #[test] + async fn $fn_name:ident() -> $return_type:ty $body:block + ) => { + #[test] + fn $fn_name() -> $return_type { + crate::testsuite::start_test_server_with(async { $body }) + } + }; +} + +pub(crate) use run_test_server; + +pub fn start_test_server_with( + fut: impl std::future::Future> + Send + 'static, +) -> eyre::Result<()> { + static RUNTIME: OnceLock = OnceLock::new(); + + RUNTIME + .get_or_init(|| tokio::runtime::Runtime::new().unwrap()) + .block_on(async { + use futures_util::FutureExt; + + let mut server = start_server().await?; + + let result = std::panic::AssertUnwindSafe(fut).catch_unwind().await; + + server.kill()?; + server.wait()?; + + match result { + Ok(res) => res, + Err(e) => std::panic::resume_unwind(e), + } + }) +} + +async fn start_server() -> eyre::Result { + let test_server_dir = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/test-server"); + + #[cfg(target_arch = "x86")] + let arch = "386"; + #[cfg(target_arch = "x86_64")] + let arch = "amd64"; + #[cfg(target_arch = "arm")] + let arch = "arm-7"; + #[cfg(target_arch = "aarch64")] + let arch = "arm64"; + #[cfg(not(any( + target_arch = "x86", + target_arch = "x86_64", + target_arch = "arm", + target_arch = "aarch64" + )))] + compile_error!("Your architecture seems to be unsupported by gotify-server. If this assumption is incorrect, please create an issue on github."); + + #[cfg(target_os = "linux")] + let gotify_binary = format!("gotify-linux-{arch}"); + #[cfg(target_os = "windows")] + let gotify_binary = format!("gotify-windows-{arch}.exe"); + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + compile_error!("Your operating system seems to be unsupported by gotify-server. If this assumption is incorrect, please create an issue on github."); + + let gotify_binary_path = test_server_dir.join(&gotify_binary); + + if !gotify_binary_path.try_exists()? { + let client = reqwest::Client::new(); + let download_url = client + .get("https://api.github.com/repos/gotify/server/releases/latest") + .header("User-Agent", "github.com/d-k-bo/gotify-rs testsuite") + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await? + .json::() + .await? + .get("assets") + .and_then(serde_json::Value::as_array) + .and_then(|assets| { + assets.iter().find(|asset| { + asset + .get("name") + .and_then(serde_json::Value::as_str) + .and_then(|name| name.strip_suffix(".zip")) + .is_some_and(|name| name == gotify_binary) + }) + }) + .and_then(|asset| { + asset + .get("browser_download_url") + .and_then(serde_json::Value::as_str) + }) + .ok_or_else(|| eyre::eyre!("failed to find latest gotify binary"))? + .to_owned(); + + zip::ZipArchive::new(std::io::Cursor::new( + client.get(download_url).send().await?.bytes().await?, + ))? + .extract(&test_server_dir)?; + } + + let data_dir = test_server_dir.join("data"); + std::fs::create_dir_all(&data_dir)?; + + std::fs::copy( + test_server_dir.join("gotify.db"), + data_dir.join("gotify.db"), + )?; + + let server = std::process::Command::new(gotify_binary_path.canonicalize()?) + .env("GOTIFY_SERVER_PORT", "30080") + .stdout(std::fs::File::create(test_server_dir.join("gotify.log"))?) + .current_dir(test_server_dir) + .spawn()?; + + loop { + match reqwest::get("http://localhost:30080").await { + Ok(_) => return Ok(server), + Err(e) if e.is_connect() => { + tokio::time::sleep(std::time::Duration::from_millis(10)).await + } + Err(e) => return Err(e.into()), + } + } +} + +#[test] +#[should_panic] +fn self_test() { + start_test_server_with(async { + assert_eq!(true, false); + + Ok(()) + }) + .unwrap() +} diff --git a/src/users.rs b/src/users.rs new file mode 100644 index 0000000..196fc73 --- /dev/null +++ b/src/users.rs @@ -0,0 +1,185 @@ +use reqwest::Method; + +use crate::{models::User, utils::request_builder, ClientClient, Result}; + +/// List, create, update or delete users. +impl ClientClient { + /// Return the current user. + pub async fn get_current_user(&self) -> Result { + self.request(Method::GET, ["current", "user"]).await + } + /// Update the password of the current user. + pub fn update_current_user(&self, pass: impl Into) -> UpdateCurrentUserBuilder { + UpdateCurrentUserBuilder::new(self, pass) + } + /// Return all users. + pub async fn get_users(&self) -> Result> { + self.request(Method::GET, ["user"]).await + } + /// Create a user. + pub fn create_user( + &self, + admin: bool, + name: impl Into, + pass: impl Into, + ) -> CreateUserBuilder { + CreateUserBuilder::new(self, admin, name, pass) + } + /// Get a user. + pub async fn get_user(&self, id: i64) -> Result { + self.request(Method::GET, ["user".into(), id.to_string()]) + .await + } + /// Update a client. + pub fn update_user(&self, id: i64, admin: bool, name: impl Into) -> UpdateUserBuilder { + UpdateUserBuilder::new(self, id, admin, name) + } + /// Delete a user. + pub async fn delete_user(&self, id: i64) -> Result<()> { + self.request(Method::DELETE, ["user".into(), id.to_string()]) + .await + } +} + +request_builder! { + name = UpdateCurrentUserBuilder, + client_type = ClientClient, + method = Method::POST, + uri = ["current", "user", "password"], + return_type = (), + required_fields = { + pass: impl Into => .into() => String, + }, + optional_fields = {} +} +request_builder! { + name = CreateUserBuilder, + client_type = ClientClient, + method = Method::POST, + uri = ["user"], + return_type = User, + required_fields = { + admin: bool => bool, + name: impl Into => .into() => String, + pass: impl Into => .into() => String, + }, + optional_fields = {} +} +request_builder! { + name = UpdateUserBuilder, + client_type = ClientClient, + method = Method::POST, + uri_with = |builder: &Self| ["user".into(), builder.id.to_string()], + return_type = User, + required_fields = { + #[serde(skip)] + id: i64 => i64, + admin: bool => bool, + name: impl Into => .into() => String, + }, + optional_fields = { + pass: impl Into => .into() => String, + } +} + +#[cfg(test)] +pub(crate) mod tests { + use crate::testsuite::*; + + #[apply(run_test_server!)] + #[test] + async fn get_current_user() -> eyre::Result<()> { + let user = client_client().get_current_user().await?; + + assert_eq!(user.name, "admin"); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn update_current_user() -> eyre::Result<()> { + client_client().update_current_user("new-password").await?; + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn get_users() -> eyre::Result<()> { + let users = client_client().get_users().await?; + + assert_eq!( + users.into_iter().map(|a| a.name).collect::>(), + vec!["admin"] + ); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn create_user() -> eyre::Result<()> { + let client = client_client(); + + let user = client.create_user(false, "new-user", "password").await?; + assert!(!user.admin); + assert_eq!(user.name, "new-user"); + + assert!(client + .get_users() + .await? + .into_iter() + .find(|u| u.id == user.id) + .is_some_and(|u| u.name == user.name)); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn get_user() -> eyre::Result<()> { + let client = client_client(); + + let user = client.get_user(1).await?; + assert_eq!(user.name, "admin"); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn update_user() -> eyre::Result<()> { + let client = client_client(); + + let user = client.create_user(false, "new-user", "password").await?; + assert_eq!(user.name, "new-user"); + + let updated_user = client.update_user(user.id, false, "updated-user").await?; + assert_eq!(user.id, updated_user.id); + assert_eq!(updated_user.name, "updated-user"); + + Ok(()) + } + + #[apply(run_test_server!)] + #[test] + async fn delete_user() -> eyre::Result<()> { + let client = client_client(); + + let user = client.create_user(false, "new-user", "password").await?; + assert_eq!(user.name, "new-user"); + + client.delete_user(user.id).await?; + + assert!(matches!( + client.get_user(user.id).await, + Err(crate::Error::Response(crate::models::Error { + error_code: 404, + .. + })) + )); + + Ok(()) + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..13d89b2 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,100 @@ +use url::Url; + +pub(crate) trait UrlAppend { + fn append(&self, segments: impl IntoIterator>) -> Url; +} + +impl UrlAppend for Url { + fn append(&self, segments: impl IntoIterator>) -> Url { + let mut url = self.clone(); + url.path_segments_mut() + .unwrap() + .pop_if_empty() + .extend(segments); + url + } +} + +#[cfg(any(feature = "app", feature = "client-core"))] +macro_rules! request_builder { + ( + name = $name:ident, + client_type = $client_type:ty, + method = $method:expr, + $( uri = $uri:expr, )? + $( uri_with = $uri_with:expr, )? + return_type = $return_type:ty, + required_fields = { + $( $( #[ $required_field_attrs:meta ] )* $required_field_name:ident : $required_field_setter_type:ty $( => . $required_field_setter_method:ident() )? => $required_field_type:ty ),* $(,)? + }, + optional_fields = { + $( $( #[ $optional_field_attrs:meta ] )* $optional_field_name:ident : $optional_field_setter_type:ty $( => . $optional_field_setter_method:ident() )? => $optional_field_type:ty ),* $(,)? + } $(,)? + ) => { + #[allow(missing_docs)] + #[derive(Debug, serde::Serialize)] + #[serde(rename_all = "camelCase")] + pub struct $name<'client> { + #[serde(skip)] + client: &'client $client_type, + $( + $( + #[$required_field_attrs] + )* + $required_field_name: $required_field_type, + )* + $( + $( + #[$optional_field_attrs] + )* + $optional_field_name: Option<$optional_field_type>, + )* + } + #[allow(missing_docs)] + impl<'client> $name<'client> { + #[allow(clippy::redundant_field_names)] + pub fn new(client: &'client $client_type, $( $required_field_name: $required_field_setter_type ),*) -> Self { + Self { + client, + $( + $required_field_name: $required_field_name $( .$required_field_setter_method() )?, + )* + $( + $optional_field_name: None, + )* + } + } + } + paste::paste! { + #[allow(missing_docs)] + impl<'client> $name<'client> { + $( + pub fn [](mut self, $optional_field_name: $optional_field_setter_type) -> Self { + self.$optional_field_name = Some($optional_field_name $( .$optional_field_setter_method() )? ); + self + } + )* + } + } + #[allow(missing_docs)] + impl<'client> $name<'client> { + #[allow(clippy::redundant_closure_call)] + pub async fn send(self) -> crate::Result<$return_type> { + self.client + .request_with_body($method, $( $uri )? $( $uri_with(&self) )?, Some(self)) + .await + } + } + impl<'client> std::future::IntoFuture for $name<'client> { + type Output = crate::Result<$return_type>; + type IntoFuture = std::pin::Pin + Send + 'client>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(self.send()) + } + } + }; +} + +#[cfg(any(feature = "app", feature = "client-core"))] +pub(crate) use request_builder; diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..73a7655 --- /dev/null +++ b/src/version.rs @@ -0,0 +1,23 @@ +use reqwest::Method; + +use crate::{models::VersionInfo, Client, Result}; + +impl Client { + /// Get version information. + pub async fn version(&self) -> Result { + self.request(Method::GET, ["version"]).await + } +} + +#[cfg(test)] +mod tests { + use crate::testsuite::*; + + #[apply(run_test_server!)] + #[test] + async fn version() -> eyre::Result<()> { + unauthenticated_client().version().await?; + + Ok(()) + } +} diff --git a/src/websocket.rs b/src/websocket.rs new file mode 100644 index 0000000..57b38f3 --- /dev/null +++ b/src/websocket.rs @@ -0,0 +1,121 @@ +use futures_util::{Stream, StreamExt}; +use reqwest::{header, StatusCode}; +use tokio_tungstenite::{ + tungstenite::{self, handshake::derive_accept_key}, + WebSocketStream, +}; + +use crate::{models::Message, utils::UrlAppend, ClientClient}; + +/// Subscribe to newly created messages. +impl ClientClient { + /// Return newly created messages via a websocket. + pub async fn message_stream( + &self, + ) -> Result> + '_, WebsocketConnectError> + { + // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism + let request_key = tungstenite::handshake::client::generate_key(); + + let response = self + .http + .get(self.base_url.append(["stream"])) + .version(reqwest::Version::HTTP_11) + .header(header::CONNECTION, "Upgrade") + .header(header::UPGRADE, "websocket") + .header(header::SEC_WEBSOCKET_VERSION, 13) + .header(header::SEC_WEBSOCKET_KEY, &request_key) + .header( + header::SEC_WEBSOCKET_EXTENSIONS, + "permessage-deflate; client_max_window_bits", + ) + .send() + .await? + .error_for_status()?; + + if response.status() != StatusCode::SWITCHING_PROTOCOLS + || !response + .headers() + .get(header::SEC_WEBSOCKET_ACCEPT) + .and_then(|v| v.to_str().ok()) + .is_some_and(|key| key == derive_accept_key(request_key.as_ref())) + { + return Err(WebsocketConnectError::Response(response)); + } + + let mut ws = WebSocketStream::from_raw_socket( + response + .upgrade() + .await + .map_err(WebsocketConnectError::Upgrade)?, + tungstenite::protocol::Role::Client, + None, + ) + .await; + + let stream = async_stream::stream! { + while let Some(res) = ws.next().await { + match res { + Ok(tungstenite::Message::Text(msg)) => { + yield serde_json::from_str(&msg).map_err(WebsocketError::Serde) + } + Ok(_) => continue, + Err(e) => yield Err(e.into()), + } + } + + }; + + Ok(Box::pin(stream)) + } +} + +/// Errors that can occur when initializing the websocket connection. +#[allow(missing_docs)] +#[derive(Debug, thiserror::Error)] +pub enum WebsocketConnectError { + #[error("initial HTTP request failed")] + Http(#[from] reqwest::Error), + #[error("server did not return a valid upgradable response: {0:?}")] + Response(reqwest::Response), + #[error("connection upgrade failed")] + Upgrade(#[source] reqwest::Error), + #[error("a websocket error occured")] + Websocket(#[from] tungstenite::Error), +} + +/// Errors that can occur when the websocket is established. +#[allow(missing_docs)] +#[derive(Debug, thiserror::Error)] +pub enum WebsocketError { + #[error("a websocket error occured")] + Websocket(#[from] tungstenite::Error), + #[error("failed to deserialize message")] + Serde(#[from] serde_json::Error), +} + +#[cfg(test)] +pub(crate) mod tests { + use crate::testsuite::*; + + #[apply(run_test_server!)] + #[test] + async fn message_stream() -> eyre::Result<()> { + use futures_util::StreamExt; + + let app_client = app_client(); + let client_client = client_client(); + + let mut stream = client_client.message_stream().await?; + + for i in 1..=10 { + let msg = format!("message-{i}"); + + app_client.create_message(&msg).await?; + + assert_eq!(stream.next().await.unwrap().unwrap().message, msg); + } + + Ok(()) + } +} diff --git a/tests/img.png b/tests/img.png new file mode 100644 index 0000000..9d9a3ef Binary files /dev/null and b/tests/img.png differ diff --git a/tests/test-server/.gitignore b/tests/test-server/.gitignore new file mode 100644 index 0000000..476e787 --- /dev/null +++ b/tests/test-server/.gitignore @@ -0,0 +1,5 @@ +data/* +gotify-* +licenses +gotify.log +LICENSE \ No newline at end of file diff --git a/tests/test-server/gotify.db b/tests/test-server/gotify.db new file mode 100644 index 0000000..2cf32b7 Binary files /dev/null and b/tests/test-server/gotify.db differ