From c570100adc4b40fa695e7dd2557aca27e9f63209 Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Fri, 22 Oct 2021 17:55:00 +0200 Subject: [PATCH 01/18] chore: don't run ci on stable and beta rust channel --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e19690..141d37e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - rust-version: [stable, beta, nightly] + rust-version: [nightly] checks: - advisories - bans licenses sources @@ -46,7 +46,7 @@ jobs: strategy: matrix: os: [windows-latest, ubuntu-latest, macos-latest] - rust-version: [stable, beta, nightly] + rust-version: [nightly] name: Build with ${{ matrix.rust-version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} @@ -88,7 +88,7 @@ jobs: test: strategy: matrix: - rust-version: [stable, beta, nightly] + rust-version: [nightly] os: [windows-latest, ubuntu-latest, macos-latest] name: Test with rust ${{ matrix.rust-version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} From b33d5de2bf47f63f53add0425895af67f5d274ce Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Fri, 22 Oct 2021 17:56:52 +0200 Subject: [PATCH 02/18] chore: use rust 2021 annd add tokio-test for async tests --- client/Cargo.toml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/Cargo.toml b/client/Cargo.toml index 84eabbe..29381cf 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "minkan-client" version = "0.1.0" -edition = "2018" +edition = "2021" authors = [ "Erik Tesar " ] @@ -22,7 +22,10 @@ serde = { version = "1.0.130", features = ["derive"]} url = "2.2.2" async-stream = "0.3.2" futures = "0.3" -directories = "4.0.1" + +[dev-dependencies] +# used to test async code +tokio-test = "0.4.2" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] sqlx = { version = "0.5.9", features = ["sqlite", "runtime-tokio-native-tls"]} From 81fb2c130928cead1388c2f8cd142272e5d6551a Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Fri, 22 Oct 2021 17:58:28 +0200 Subject: [PATCH 03/18] feat: add traits for database backends and implement them for Server --- client/src/application.rs | 55 +++++++++++--- client/src/database.rs | 72 +++++++++++++----- client/src/database/indexed_db.rs | 3 - client/src/database/sqlite.rs | 4 +- client/src/database/sqlite/server.rs | 68 +++++++++++++++++ client/src/error.rs | 33 +++++++-- client/src/lib.rs | 3 +- client/src/server.rs | 107 ++++++++++++++++++++++++++- 8 files changed, 302 insertions(+), 43 deletions(-) create mode 100644 client/src/database/sqlite/server.rs diff --git a/client/src/application.rs b/client/src/application.rs index 5382929..ea52d9f 100644 --- a/client/src/application.rs +++ b/client/src/application.rs @@ -1,24 +1,55 @@ -use directories::ProjectDirs; - -use crate::{database::Database, Result}; +use crate::{database::DatabaseError, Result}; #[non_exhaustive] -#[derive(Debug)] +#[derive(Debug, Clone)] /// The base application /// -/// It keeps track where to store the data for different server instances +/// It keeps track where to store the application data pub struct Application { - // the database, the application will use - database: Database, + #[cfg(not(target_arch = "wasm32"))] + /// On native builds, we'll use a sqlite database for better performance + pool: sqlx::SqlitePool, } impl Application { - /// Creates a new [`Application`] instance. If ``path`` is set, - /// it will try to use that as the project dir. + #[cfg(not(target_arch = "wasm32"))] + /// Returns the underlying database pool + pub(crate) fn pool(&self) -> &sqlx::SqlitePool { + &self.pool + } +} +impl Application { + /// Creates a new [`Application`] instance. If `uri` is set, + /// it will try to use that uri for the SQLite database driver. + /// Returns [`crate::Error::Other`] if it can't initalize a database. /// /// # Note - /// On wasm targets, ``path`` will be ignored - pub async fn new(_path: impl Into) -> Result { - todo!() + /// On wasm targets, `uri` will be ignored. + /// + /// # Example + /// + /// ``` + /// # use minkan_client::Application; + /// # tokio_test::block_on(async { + /// let app = Application::new("sqlite::memory:").await.unwrap(); + /// # }) + pub async fn new(uri: impl AsRef) -> Result { + #[cfg(not(target_arch = "wasm32"))] + let pool = { + let pool = sqlx::SqlitePool::connect(uri.as_ref()) + .await + .map_err(|e| DatabaseError::OpenError(e.to_string()))?; + + sqlx::migrate!("./migrations/") + .run(&pool) + .await + .map_err(|e| DatabaseError::MigrationError(e.to_string()))?; + pool + }; + #[cfg(target_arch = "wasm32")] + { + todo!("database backend for wasm is not ready yet") + } + Ok(Self { pool }) } } diff --git a/client/src/database.rs b/client/src/database.rs index 240143a..aaf6da9 100644 --- a/client/src/database.rs +++ b/client/src/database.rs @@ -1,21 +1,59 @@ //! Data storage and different database backends +use async_trait::async_trait; +use futures::Stream; +use thiserror::Error; -#[cfg(target_arch = "wasm")] -pub mod indexed_db; -#[cfg(not(target_arch = "wasm"))] -pub mod sqlite; -/// A database that will be used to store application data +use crate::{seal::Sealed, Result}; + +#[cfg(target_arch = "wasm32")] +pub(crate) mod indexed_db; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod sqlite; + +#[async_trait] +/// A trait for all objects that can be inserted into the database /// -/// This struct is used to abstract different database backends -// Usually, you probably want to use a trait for this but since async traits -// and especially streams in async traits are really not a real thing and we -// don't expose this struct, it's okay to just use different implementations -// on different backends (see [`self::indexed_db`] and [`self::sqlite`]) -#[derive(Debug)] -pub struct Database { - #[cfg(not(target_arch = "wasm32"))] - db: sqlx::SqlitePool, - #[cfg(target_arch = "wasm32")] - // guess thats the right thing? - db: web_sys::IdbOpenDbRequest, +/// # Note +/// This trait is sealed to allow future extension +pub trait Insert: Sealed { + /// Inserts the instance of [`Self`] into the database. + async fn insert(&self) -> Result<()>; +} + +#[async_trait] +/// A trait for objects that are stored locally and can be retrieved +/// +/// # Note +/// This trait is sealed to allow future extension +pub trait Get: Sealed + Sized { + /// [`crate::Application`] is the root parent. For example, a + /// [`crate::server::Server`] depends on an [`crate::Application`] + type Parent; + /// The type that is used to identify an object. This could be an [`u32`] + /// or something else. + type Identifier; + /// Workaround for streams in traits + type Stream<'a>: Stream> + 'a; + /// Returns a [`Stream`] of all items + fn get_all(app: &Self::Parent) -> Self::Stream<'_>; + /// Returns a single item + async fn get(i: &Self::Identifier, p: &Self::Parent) -> Result>; +} + +#[derive(Debug, Error)] +/// Errors that happen during database operations +pub enum DatabaseError { + #[error("failed to open the database: {0}")] + /// Used if we can't open the database. This could be the case because the + /// user denied access to indexedDB in a webapp or because sqlx failed to + /// open a connection to the sqlite url + OpenError(String), + #[error("error during database migration: {0}")] + /// Used if a database migration fails. + /// At an application level, there's nothing you can do except using another + /// database for application startup + MigrationError(String), + #[error("database error")] + /// Errors that are not handled in any special way + Other, } diff --git a/client/src/database/indexed_db.rs b/client/src/database/indexed_db.rs index 134f185..30ed41a 100644 --- a/client/src/database/indexed_db.rs +++ b/client/src/database/indexed_db.rs @@ -1,4 +1 @@ //! Implements the indexedDB database backend -use super::Database; - -impl Database {} diff --git a/client/src/database/sqlite.rs b/client/src/database/sqlite.rs index d1a6bfa..07f2239 100644 --- a/client/src/database/sqlite.rs +++ b/client/src/database/sqlite.rs @@ -1,5 +1,3 @@ //! Implements the SQLite database backend -use super::Database; -/// Implements the methods which will call sqlx's sqlite stuff -impl Database {} +pub mod server; diff --git a/client/src/database/sqlite/server.rs b/client/src/database/sqlite/server.rs new file mode 100644 index 0000000..e4d6383 --- /dev/null +++ b/client/src/database/sqlite/server.rs @@ -0,0 +1,68 @@ +use crate::{ + database::{DatabaseError, Get, Insert}, + server::Server, + Application, Error, Result, +}; +use async_trait::async_trait; +use futures::{Stream, TryStreamExt}; + +#[async_trait] +impl Insert for Server { + async fn insert(&self) -> Result<()> { + let endpoint = self.api_endpoint.as_str(); + sqlx::query!( + r#" + INSERT INTO servers(api_endpoint, nickname) + VALUES ($1, $2) + "#, + endpoint, + self.nickname, + ) + .execute(self.app.pool()) + .await + // TODO: map to correct error + .map_err(|_| Error::DatabaseError(DatabaseError::Other))?; + Ok(()) + } +} + +#[async_trait] +impl Get for Server { + type Identifier = url::Url; + type Parent = Application; + type Stream<'a> = impl Stream> + 'a; + + fn get_all(app: &Self::Parent) -> Self::Stream<'_> { + sqlx::query!( + r#" + SELECT api_endpoint AS endpoint, nickname FROM servers + "# + ) + .fetch(app.pool()) + .map_ok(move |record| { + Server::from_values(record.endpoint, record.nickname, app.clone()) + .expect("invalid server in database") + }) + // TODO: map to correct error + .map_err(|_| Error::DatabaseError(DatabaseError::Other)) + } + + async fn get(identifier: &Self::Identifier, parent: &Self::Parent) -> Result> { + let endpoint = identifier.as_str(); + Ok(sqlx::query!( + r#" + SELECT api_endpoint AS endpoint, nickname FROM servers + WHERE api_endpoint = $1 + "#, + endpoint + ) + .fetch_optional(parent.pool()) + .await + // TODO: map to correct error + .map_err(|_| Error::DatabaseError(DatabaseError::Other))? + .map(|record| { + Self::from_values(record.endpoint, record.nickname, parent.clone()) + .expect("invalid server in database") + })) + } +} diff --git a/client/src/error.rs b/client/src/error.rs index aaca5ee..4b13d0d 100644 --- a/client/src/error.rs +++ b/client/src/error.rs @@ -1,16 +1,39 @@ +use crate::database::DatabaseError; use serde::Deserialize; use thiserror::Error; +/// A type alias for Results which return [`enum@Error`] +pub type Result = core::result::Result; -#[derive(Debug, Deserialize, Error)] -#[serde(tag = "__typename")] +#[derive(Debug, Error)] #[non_exhaustive] /// The errors that may occur when using this library pub enum Error { #[error(transparent)] - #[serde(skip)] + /// Special case to represent errors during parsing of some data + ParseError(#[from] ParseError), + #[error(transparent)] + /// Maps to the graphql api errors + ApiError(#[from] ApiError), + #[error(transparent)] + /// Database operation errors + DatabaseError(#[from] DatabaseError), + #[error(transparent)] /// a catch-all error. If no other error matches, this will Other(#[from] anyhow::Error), } -/// A type alias for Results which return [`enum@Error`] -pub type Result = core::result::Result; +#[derive(Debug, Error, Deserialize)] +#[non_exhaustive] +#[serde(tag = "__typename")] +/// Represents the different errors the server may return +pub enum ApiError {} + +#[derive(Debug, Error)] +#[non_exhaustive] +/// Errors that may occur while parsing data +pub enum ParseError { + #[error("url has no host")] + UrlWithoutHost { url: url::Url }, + #[error(transparent)] + UrlError(#[from] url::ParseError), +} diff --git a/client/src/lib.rs b/client/src/lib.rs index 0e4bf96..ec81576 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -2,7 +2,8 @@ //! //! For native builds, SQLite is used. For wasm builds, indexedDB is used. #![warn(missing_docs, missing_debug_implementations)] -pub(crate) mod database; +#![feature(generic_associated_types, type_alias_impl_trait)] +pub mod database; pub(crate) mod seal; mod application; diff --git a/client/src/server.rs b/client/src/server.rs index 730caf5..63e0562 100644 --- a/client/src/server.rs +++ b/client/src/server.rs @@ -1,6 +1,9 @@ //! The backend the client talks to + use url::Url; +use crate::{error::ParseError, seal::Sealed, Application, Error, Result}; + #[non_exhaustive] #[derive(Debug)] /// A server the client can talk to @@ -12,6 +15,106 @@ use url::Url; pub struct Server { /// The GraphQL API endpoint /// - /// Usually, this is something like ``example.com/graphql`` - api_endpoint: Url, + /// Usually, this is something like `https://example.com/graphql` + pub(crate) api_endpoint: Url, + /// The name an end-user can give to a server so they can easier identify it + pub(crate) nickname: Option, + /// The application the server can use to do interactions with the database + pub(crate) app: Application, +} + +impl Sealed for Server {} + +impl Server { + /// Creates a new server with the given url as the graphql api endpoint + /// + /// # Note + /// Call [`crate::database::Insert::insert`] on [`Self`] to actually insert this + /// server instance into the database. + /// + /// # Example + /// + /// ``` + /// # use url::Url; + /// # use minkan_client::server::Server; + /// # use minkan_client::Application; + /// # tokio_test::block_on( async { + /// let app = Application::new("sqlite::memory:").await.unwrap(); + /// let api_endpoint = Url::parse("https://example.com/graphql").unwrap(); + /// let server = Server::new(api_endpoint, None, app).await.unwrap(); + /// # }) + /// ``` + pub async fn new( + api_endpoint: Url, + nickname: Option, + app: Application, + ) -> Result { + // if the url has no host, it is invalid + if !api_endpoint.has_host() { + return Err(Error::ParseError(ParseError::UrlWithoutHost { + url: api_endpoint, + })); + } + Ok(Self { + api_endpoint, + nickname, + app, + }) + } + + /// Returns the [`Url`] of the API used for this server. + /// It can be useful to access the [`Url`] directly in cases where you want + /// to use addtional things like the domain or the port + /// + /// # Example + /// + /// ``` + /// # use url::Url; + /// # use minkan_client::server::Server; + /// # use minkan_client::Application; + /// # tokio_test::block_on(async { + /// let app = Application::new("sqlite::memory:").await.unwrap(); + /// let api_endpoint = Url::parse("https://example.com/graphql").unwrap(); + /// let server = Server::new(api_endpoint, None, app).await.unwrap(); + /// assert_eq!(server.endpoint().path(), "/graphql"); + /// # }) + /// ``` + pub fn endpoint(&self) -> &Url { + &self.api_endpoint + } + + /// Returns the nickname user-defined nickname of a [`Server`]. + /// Nicknames can help a user to identify a [`Server`] easier. + /// + /// # Example + /// + /// ```rust + /// # use url::Url; + /// # use minkan_client::server::Server; + /// # use minkan_client::Application; + /// # tokio_test::block_on(async { + /// let app = Application::new("sqlite::memory:").await.unwrap(); + /// let api_endpoint = Url::parse("https://example.com/graphql").unwrap(); + /// let nickname = Some("my friend's minkan instance".to_string()); + /// let server = Server::new(api_endpoint, nickname, app).await.unwrap(); + /// assert!(server.nickname().is_some()); + /// # }) + pub fn nickname(&self) -> &Option { + &self.nickname + } +} + +impl Server { + /// Shortcut for database operations so they dont have to use `new` + pub(crate) fn from_values( + endpoint: impl AsRef, + nickname: Option, + app: Application, + ) -> Result { + Ok(Self { + api_endpoint: Url::parse(endpoint.as_ref()).map_err(|e| Error::ParseError(e.into()))?, + nickname, + app, + }) + } } From 09f983da92144ab2fd208dcb9fa207ace7596b37 Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Fri, 22 Oct 2021 18:23:16 +0200 Subject: [PATCH 04/18] fix: enable sqlx offline feature --- client/Cargo.toml | 2 +- client/sqlx-data.json | 61 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 client/sqlx-data.json diff --git a/client/Cargo.toml b/client/Cargo.toml index 29381cf..34c93da 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -28,7 +28,7 @@ futures = "0.3" tokio-test = "0.4.2" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -sqlx = { version = "0.5.9", features = ["sqlite", "runtime-tokio-native-tls"]} +sqlx = { version = "0.5.9", features = ["sqlite", "runtime-tokio-native-tls", "offline"]} [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3.55" diff --git a/client/sqlx-data.json b/client/sqlx-data.json new file mode 100644 index 0000000..2dc186c --- /dev/null +++ b/client/sqlx-data.json @@ -0,0 +1,61 @@ +{ + "db": "SQLite", + "09909c6a0d31e8548b6709b2900416735b5651c4930659bb334fdd28a5b57d55": { + "query": "\n SELECT api_endpoint AS endpoint, nickname FROM servers\n WHERE api_endpoint = $1\n ", + "describe": { + "columns": [ + { + "name": "endpoint", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "nickname", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + } + }, + "7c41905b00b9562519a884cca46a02f9ddb7327586cc72009e5c766c88d67616": { + "query": "\n SELECT api_endpoint AS endpoint, nickname FROM servers\n ", + "describe": { + "columns": [ + { + "name": "endpoint", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "nickname", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + true + ] + } + }, + "f460c9e58df7e0ba86a6fc16042f59c63597d389cd293394f4e13087c37cea78": { + "query": "\n INSERT INTO servers(api_endpoint, nickname)\n VALUES ($1, $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + } + } +} \ No newline at end of file From c7fe911e62f7d360116a742e3f53c7be9d3b3b00 Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Fri, 22 Oct 2021 18:23:35 +0200 Subject: [PATCH 05/18] chore: fix typo --- client/src/server.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/server.rs b/client/src/server.rs index 63e0562..e136940 100644 --- a/client/src/server.rs +++ b/client/src/server.rs @@ -83,8 +83,8 @@ impl Server { &self.api_endpoint } - /// Returns the nickname user-defined nickname of a [`Server`]. - /// Nicknames can help a user to identify a [`Server`] easier. + /// Returns the user-defined nickname of a [`Server`]. + /// Nicknames can help an user to identify a [`Server`] easier. /// /// # Example /// From 8b5679d61ba7cebe03f465163a58ef7d4a4c5389 Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Fri, 22 Oct 2021 21:24:20 +0200 Subject: [PATCH 06/18] feat: add Actor trait --- client/Cargo.toml | 8 ++++++++ client/src/actor.rs | 20 ++++++++++++++++++++ client/src/lib.rs | 1 + 3 files changed, 29 insertions(+) create mode 100644 client/src/actor.rs diff --git a/client/Cargo.toml b/client/Cargo.toml index 34c93da..7728ffb 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -22,6 +22,14 @@ serde = { version = "1.0.130", features = ["derive"]} url = "2.2.2" async-stream = "0.3.2" futures = "0.3" +downcast-rs = "1.2.0" +typetag = "0.1.7" +sequoia-openpgp = { version = "1.5.0", default-features = false, features = [ + "crypto-rust", + "allow-experimental-crypto", + "allow-variable-time-crypto", + "compression" +]} [dev-dependencies] # used to test async code diff --git a/client/src/actor.rs b/client/src/actor.rs new file mode 100644 index 0000000..3389505 --- /dev/null +++ b/client/src/actor.rs @@ -0,0 +1,20 @@ +//! Keeps the different types of actors that can appear + +use downcast_rs::{impl_downcast, Downcast}; +use sequoia_openpgp::Cert; + +use crate::{seal::Sealed, server::Server}; + +// GraphQL tags interfaces (traits in rust) with `__typename` +#[typetag::serde(tag = "__typename")] +/// Everything that can take actions implements the [`Actor`] trait. +pub trait Actor: Sealed + Downcast { + /// The openpgp certificate of an [`Actor`] from `sequoia-openpgp` + fn certificate(&self) -> &Cert; + /// The name of an [`Actor`] used to identify them + fn name(&self) -> &str; + /// The origin server of the given [`Actor`] + fn server(&self) -> &Server; +} +// actor types support downcasting +impl_downcast!(Actor); diff --git a/client/src/lib.rs b/client/src/lib.rs index ec81576..76599d3 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -6,6 +6,7 @@ pub mod database; pub(crate) mod seal; +pub mod actor; mod application; mod error; From e6a488902cde7d45e8d52a8d8f707ee9aa03326e Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Sat, 23 Oct 2021 18:23:00 +0200 Subject: [PATCH 07/18] refactor: change database traits to accept a Parent so struct can be deserialized with serde --- client/src/actor.rs | 4 ++++ client/src/actor/user.rs | 4 ++++ client/src/database.rs | 4 +++- client/src/database/sqlite/server.rs | 10 +++++----- client/src/server.rs | 27 ++++++--------------------- 5 files changed, 22 insertions(+), 27 deletions(-) create mode 100644 client/src/actor/user.rs diff --git a/client/src/actor.rs b/client/src/actor.rs index 3389505..af8d3a6 100644 --- a/client/src/actor.rs +++ b/client/src/actor.rs @@ -3,7 +3,11 @@ use downcast_rs::{impl_downcast, Downcast}; use sequoia_openpgp::Cert; +mod user; + use crate::{seal::Sealed, server::Server}; +#[doc(inline)] +pub use user::User; // GraphQL tags interfaces (traits in rust) with `__typename` #[typetag::serde(tag = "__typename")] diff --git a/client/src/actor/user.rs b/client/src/actor/user.rs new file mode 100644 index 0000000..d25f345 --- /dev/null +++ b/client/src/actor/user.rs @@ -0,0 +1,4 @@ +/// The [`User`] [`Actor`] + +#[derive(Debug)] +pub struct User {} diff --git a/client/src/database.rs b/client/src/database.rs index aaf6da9..6510742 100644 --- a/client/src/database.rs +++ b/client/src/database.rs @@ -16,8 +16,10 @@ pub(crate) mod sqlite; /// # Note /// This trait is sealed to allow future extension pub trait Insert: Sealed { + /// The parent of this struct. Same as the `Parent` in the [`Get`] trait + type Parent; /// Inserts the instance of [`Self`] into the database. - async fn insert(&self) -> Result<()>; + async fn insert(&self, p: &Self::Parent) -> Result<()>; } #[async_trait] diff --git a/client/src/database/sqlite/server.rs b/client/src/database/sqlite/server.rs index e4d6383..cbf94a4 100644 --- a/client/src/database/sqlite/server.rs +++ b/client/src/database/sqlite/server.rs @@ -8,7 +8,8 @@ use futures::{Stream, TryStreamExt}; #[async_trait] impl Insert for Server { - async fn insert(&self) -> Result<()> { + type Parent = Application; + async fn insert(&self, app: &Self::Parent) -> Result<()> { let endpoint = self.api_endpoint.as_str(); sqlx::query!( r#" @@ -18,7 +19,7 @@ impl Insert for Server { endpoint, self.nickname, ) - .execute(self.app.pool()) + .execute(app.pool()) .await // TODO: map to correct error .map_err(|_| Error::DatabaseError(DatabaseError::Other))?; @@ -40,7 +41,7 @@ impl Get for Server { ) .fetch(app.pool()) .map_ok(move |record| { - Server::from_values(record.endpoint, record.nickname, app.clone()) + Server::from_values(record.endpoint, record.nickname) .expect("invalid server in database") }) // TODO: map to correct error @@ -61,8 +62,7 @@ impl Get for Server { // TODO: map to correct error .map_err(|_| Error::DatabaseError(DatabaseError::Other))? .map(|record| { - Self::from_values(record.endpoint, record.nickname, parent.clone()) - .expect("invalid server in database") + Self::from_values(record.endpoint, record.nickname).expect("invalid server in database") })) } } diff --git a/client/src/server.rs b/client/src/server.rs index e136940..1d89f04 100644 --- a/client/src/server.rs +++ b/client/src/server.rs @@ -2,7 +2,7 @@ use url::Url; -use crate::{error::ParseError, seal::Sealed, Application, Error, Result}; +use crate::{error::ParseError, seal::Sealed, Error, Result}; #[non_exhaustive] #[derive(Debug)] @@ -19,8 +19,6 @@ pub struct Server { pub(crate) api_endpoint: Url, /// The name an end-user can give to a server so they can easier identify it pub(crate) nickname: Option, - /// The application the server can use to do interactions with the database - pub(crate) app: Application, } impl Sealed for Server {} @@ -39,16 +37,11 @@ impl Server { /// # use minkan_client::server::Server; /// # use minkan_client::Application; /// # tokio_test::block_on( async { - /// let app = Application::new("sqlite::memory:").await.unwrap(); /// let api_endpoint = Url::parse("https://example.com/graphql").unwrap(); - /// let server = Server::new(api_endpoint, None, app).await.unwrap(); + /// let server = Server::new(api_endpoint, None).await.unwrap(); /// # }) /// ``` - pub async fn new( - api_endpoint: Url, - nickname: Option, - app: Application, - ) -> Result { + pub async fn new(api_endpoint: Url, nickname: Option) -> Result { // if the url has no host, it is invalid if !api_endpoint.has_host() { return Err(Error::ParseError(ParseError::UrlWithoutHost { @@ -58,7 +51,6 @@ impl Server { Ok(Self { api_endpoint, nickname, - app, }) } @@ -73,9 +65,8 @@ impl Server { /// # use minkan_client::server::Server; /// # use minkan_client::Application; /// # tokio_test::block_on(async { - /// let app = Application::new("sqlite::memory:").await.unwrap(); /// let api_endpoint = Url::parse("https://example.com/graphql").unwrap(); - /// let server = Server::new(api_endpoint, None, app).await.unwrap(); + /// let server = Server::new(api_endpoint, None).await.unwrap(); /// assert_eq!(server.endpoint().path(), "/graphql"); /// # }) /// ``` @@ -93,10 +84,9 @@ impl Server { /// # use minkan_client::server::Server; /// # use minkan_client::Application; /// # tokio_test::block_on(async { - /// let app = Application::new("sqlite::memory:").await.unwrap(); /// let api_endpoint = Url::parse("https://example.com/graphql").unwrap(); /// let nickname = Some("my friend's minkan instance".to_string()); - /// let server = Server::new(api_endpoint, nickname, app).await.unwrap(); + /// let server = Server::new(api_endpoint, nickname).await.unwrap(); /// assert!(server.nickname().is_some()); /// # }) pub fn nickname(&self) -> &Option { @@ -106,15 +96,10 @@ impl Server { impl Server { /// Shortcut for database operations so they dont have to use `new` - pub(crate) fn from_values( - endpoint: impl AsRef, - nickname: Option, - app: Application, - ) -> Result { + pub(crate) fn from_values(endpoint: impl AsRef, nickname: Option) -> Result { Ok(Self { api_endpoint: Url::parse(endpoint.as_ref()).map_err(|e| Error::ParseError(e.into()))?, nickname, - app, }) } } From e3dff3db142319bbd7b6ec45273dbc26a5e57cf8 Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Sat, 23 Oct 2021 19:11:34 +0200 Subject: [PATCH 08/18] refactor: remove server from Actor trait --- client/src/actor.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/client/src/actor.rs b/client/src/actor.rs index af8d3a6..ceb4a20 100644 --- a/client/src/actor.rs +++ b/client/src/actor.rs @@ -5,20 +5,18 @@ use sequoia_openpgp::Cert; mod user; -use crate::{seal::Sealed, server::Server}; +use crate::{seal::Sealed, server::Server, Node}; #[doc(inline)] pub use user::User; // GraphQL tags interfaces (traits in rust) with `__typename` #[typetag::serde(tag = "__typename")] /// Everything that can take actions implements the [`Actor`] trait. -pub trait Actor: Sealed + Downcast { +pub trait Actor: Sealed + Downcast + Node { /// The openpgp certificate of an [`Actor`] from `sequoia-openpgp` fn certificate(&self) -> &Cert; /// The name of an [`Actor`] used to identify them fn name(&self) -> &str; - /// The origin server of the given [`Actor`] - fn server(&self) -> &Server; } // actor types support downcasting impl_downcast!(Actor); From c8f07046b5cc935cbe76f3a0e726fa9350f80a4f Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Sat, 23 Oct 2021 22:44:59 +0200 Subject: [PATCH 09/18] feat: add Node trait --- client/src/node.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 client/src/node.rs diff --git a/client/src/node.rs b/client/src/node.rs new file mode 100644 index 0000000..cf6dfdd --- /dev/null +++ b/client/src/node.rs @@ -0,0 +1,12 @@ +use uuid::Uuid; + +use crate::seal::Sealed; + +/// This represents the `Node` interface as defined in the [graphql best practice][1] +/// Basically, this is everything that has an unique identifier +/// +/// [1]: https://graphql.org/learn/global-object-identification/#node-interface +pub trait Node: Sealed { + /// The unique identifer of this [`Node`] + fn id(&self) -> &Uuid; +} From f8de595f5041cc0e1cf3b44312964d0307835766 Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Sat, 23 Oct 2021 22:45:37 +0200 Subject: [PATCH 10/18] feat: add ApiErrors --- client/src/error.rs | 111 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 7 deletions(-) diff --git a/client/src/error.rs b/client/src/error.rs index 4b13d0d..15c7434 100644 --- a/client/src/error.rs +++ b/client/src/error.rs @@ -1,6 +1,44 @@ -use crate::database::DatabaseError; +//! Different error types + +use crate::{actor::Actor, database::DatabaseError}; +use sequoia_openpgp::Cert; use serde::Deserialize; use thiserror::Error; + +/// Macro to avoid code duplication of the `description` and `hint` field +macro_rules! api_errors { + ($( + $(#[$attr:meta])* + $name:ident $({ + $( + $(#[$inner_attr:meta])* + $field:ident: $field_type:ty$(,)? + ),* + }$(,)?)? + ),*) => { + #[derive(Debug, Error, Deserialize)] + #[non_exhaustive] + #[serde(tag = "__typename")] + /// This error type maps to the graphql error types + pub enum ApiError { + $( + #[error("{description}")] + $(#[$attr])* + $name { + /// The error description sent by the server + description: String, + /// A hint how to prevent this error + hint: Option, + $($( + $(#[$inner_attr])* + $field: $field_type + ),*)? + } + ),* + } + }; +} + /// A type alias for Results which return [`enum@Error`] pub type Result = core::result::Result; @@ -22,18 +60,77 @@ pub enum Error { Other(#[from] anyhow::Error), } -#[derive(Debug, Error, Deserialize)] -#[non_exhaustive] -#[serde(tag = "__typename")] -/// Represents the different errors the server may return -pub enum ApiError {} +api_errors! { + /// This error means that another [`Actor`]'s PGP certificate has the same + /// fingerprint. The chance that this will happen by accident is incredible + /// low. It is way more likely that the client has a bug and sent the same + /// [`Cert`] twice. + CertificateTaken { + #[serde( + rename = "certificate", + serialize_with = "crate::serialize_cert", + deserialize_with = "crate::deserialize_cert_box" + )] + /// The [`Cert`] that was sent in the input + cert: Box, + }, + /// The server failed to parse the [`Cert`] + InvalidCertificate, + /// The fingerprint was invalid. That means, it is not a valid hex string. + InvalidCertificateFingerprint, + /// The challenge provided is not valid. + InvalidChallenge { + /// The supplied challenge + challenge: String, + }, + /// The server either failed to parse the signature or parsed the signature + /// successful but failed to verify the signature. In the latter case, the + /// server would probably respond with ``UnexpectedSigner`` instead. + InvalidSignature, + /// The supplied username is invalid. This error occurs mostly if the + /// username contains invalid characters or otherwise does not meet the + /// criteria. + InvalidUsername, + /// The server didn't find a user with that name + NoSuchUser { + /// The name given in the input + name: String, + }, + /// An unexpected error. Probably something internal like a database error + Unexpected, + /// The server could verify signature but detected that the signature is not + /// made by the expected signer. + UnexpectedSigner { + /// The expected [`Actor`] + expected: Option>, + /// The actual [`Actor`] + got: Option>, + }, + /// The server does not know a certificate with the given fingerprint + UnknownCertificate { + /// The given fingerprint + fingerprint: String, + }, + /// The supplied username is unavailable. This error usually occurs when + /// another user has the same name. However, it could also occur because the + /// name violates the name policy. + UsernameUnavailable { + /// The supplied name + name: String, + }, +} #[derive(Debug, Error)] #[non_exhaustive] /// Errors that may occur while parsing data pub enum ParseError { #[error("url has no host")] - UrlWithoutHost { url: url::Url }, + /// The url has no host + UrlWithoutHost { + /// The url that has no host + url: url::Url, + }, #[error(transparent)] + /// Errors from the [`url`] crate UrlError(#[from] url::ParseError), } From 63ad7d32d39b420ef97a19815c6d8085a6208aac Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Sat, 23 Oct 2021 22:46:34 +0200 Subject: [PATCH 11/18] feat: implement Actor and Node trait for User --- client/Cargo.toml | 2 ++ client/src/actor.rs | 6 ++++-- client/src/actor/user.rs | 41 +++++++++++++++++++++++++++++++++++--- client/src/lib.rs | 9 ++++++++- client/src/util.rs | 43 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 client/src/util.rs diff --git a/client/Cargo.toml b/client/Cargo.toml index 7728ffb..ea778a9 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -30,6 +30,8 @@ sequoia-openpgp = { version = "1.5.0", default-features = false, features = [ "allow-variable-time-crypto", "compression" ]} +uuid = { version = "0.8.2", features = ["serde"] } +bytes = { version = "1.1.0", features = ["serde"] } [dev-dependencies] # used to test async code diff --git a/client/src/actor.rs b/client/src/actor.rs index ceb4a20..802db3b 100644 --- a/client/src/actor.rs +++ b/client/src/actor.rs @@ -1,18 +1,20 @@ //! Keeps the different types of actors that can appear +use std::fmt::Debug; + use downcast_rs::{impl_downcast, Downcast}; use sequoia_openpgp::Cert; mod user; -use crate::{seal::Sealed, server::Server, Node}; +use crate::{seal::Sealed, Node}; #[doc(inline)] pub use user::User; // GraphQL tags interfaces (traits in rust) with `__typename` #[typetag::serde(tag = "__typename")] /// Everything that can take actions implements the [`Actor`] trait. -pub trait Actor: Sealed + Downcast + Node { +pub trait Actor: Sealed + Downcast + Node + Debug { /// The openpgp certificate of an [`Actor`] from `sequoia-openpgp` fn certificate(&self) -> &Cert; /// The name of an [`Actor`] used to identify them diff --git a/client/src/actor/user.rs b/client/src/actor/user.rs index d25f345..319d28e 100644 --- a/client/src/actor/user.rs +++ b/client/src/actor/user.rs @@ -1,4 +1,39 @@ -/// The [`User`] [`Actor`] +use crate::{seal::Sealed, Node}; -#[derive(Debug)] -pub struct User {} +use super::Actor; +use sequoia_openpgp::Cert; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Deserialize, Serialize)] +#[non_exhaustive] +/// Represents a real person using the application +pub struct User { + id: Uuid, + name: String, + #[serde( + rename = "certificate", + serialize_with = "crate::serialize_cert", + deserialize_with = "crate::deserialize_cert" + )] + cert: Cert, +} + +impl Sealed for User {} + +impl Node for User { + fn id(&self) -> &Uuid { + &self.id + } +} + +#[typetag::serde] +impl Actor for User { + fn certificate(&self) -> &Cert { + &self.cert + } + + fn name(&self) -> &str { + &self.name + } +} diff --git a/client/src/lib.rs b/client/src/lib.rs index 76599d3..973b113 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -8,11 +8,18 @@ pub(crate) mod seal; pub mod actor; mod application; -mod error; +pub mod error; +mod node; pub mod server; +mod util; #[doc(inline)] pub use application::Application; #[doc(inline)] pub use error::{Error, Result}; +#[doc(inline)] +pub use node::Node; + +#[doc(inline)] +pub(crate) use util::*; diff --git a/client/src/util.rs b/client/src/util.rs new file mode 100644 index 0000000..f7c9939 --- /dev/null +++ b/client/src/util.rs @@ -0,0 +1,43 @@ +//! Helper methods + +use sequoia_openpgp::{parse::Parse, serialize::SerializeInto, Cert}; +use serde::{Deserialize, Deserializer, Serializer}; + +/// A helper method to serialize a [`Cert`] with serde. +/// +/// **This method will always include secret key material if there is some** +pub fn serialize_cert(cert: &Cert, ser: S) -> Result +where + S: Serializer, +{ + ser.serialize_bytes( + &cert + .as_tsk() + .export_to_vec() + .map_err(::custom)?, + ) +} + +/// A helper method to deserialize a [`Cert`] with serde +pub fn deserialize_cert<'de, D>(de: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + // this represents the `certificate` field in an [`crate::actor::Actor`] + #[serde(rename = "certificate")] + struct Repr { + body: bytes::Bytes, + } + let repr = Repr::deserialize(de)?; + + Cert::from_bytes(&repr.body).map_err(::custom) +} + +/// A helper method to deserialize a [`Cert`] with serde which returns a [`Box`] +pub fn deserialize_cert_box<'de, D>(de: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + deserialize_cert::<'de>(de).map(Box::new) +} From ae65fb1501534199938c3375828365292e2d72e7 Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Sun, 24 Oct 2021 14:38:42 +0200 Subject: [PATCH 12/18] feat: let server store the reqwest client --- client/src/server.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/src/server.rs b/client/src/server.rs index 1d89f04..4a3c696 100644 --- a/client/src/server.rs +++ b/client/src/server.rs @@ -1,5 +1,6 @@ //! The backend the client talks to +use reqwest::Client; use url::Url; use crate::{error::ParseError, seal::Sealed, Error, Result}; @@ -19,6 +20,9 @@ pub struct Server { pub(crate) api_endpoint: Url, /// The name an end-user can give to a server so they can easier identify it pub(crate) nickname: Option, + /// The [`Client`] used to communicate with the [`Server`] via the graphql + /// endpoint + pub(crate) client: Client, } impl Sealed for Server {} @@ -48,9 +52,11 @@ impl Server { url: api_endpoint, })); } + let client = Client::new(); Ok(Self { api_endpoint, nickname, + client, }) } @@ -96,10 +102,16 @@ impl Server { impl Server { /// Shortcut for database operations so they dont have to use `new` - pub(crate) fn from_values(endpoint: impl AsRef, nickname: Option) -> Result { + pub(crate) fn from_values( + endpoint: impl AsRef, + nickname: Option, + client: Option, + ) -> Result { + let client = client.unwrap_or_else(Client::new); Ok(Self { api_endpoint: Url::parse(endpoint.as_ref()).map_err(|e| Error::ParseError(e.into()))?, nickname, + client, }) } } From 06575737cd4ec4fd6f7f3915f12ca8883644c66d Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Sun, 24 Oct 2021 18:11:14 +0200 Subject: [PATCH 13/18] feat: add reqwest and openpgp error types --- client/src/error.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/src/error.rs b/client/src/error.rs index 15c7434..8b4d8b1 100644 --- a/client/src/error.rs +++ b/client/src/error.rs @@ -53,6 +53,12 @@ pub enum Error { /// Maps to the graphql api errors ApiError(#[from] ApiError), #[error(transparent)] + /// Errors that may occur while sending http requests to the api + ReqwestError(#[from] reqwest::Error), + #[error(transparent)] + /// Errors that are associated with OpenPGP + OpenPGPError(#[from] sequoia_openpgp::Error), + #[error(transparent)] /// Database operation errors DatabaseError(#[from] DatabaseError), #[error(transparent)] From aa61b17921ca34089fddcd1b086c4afd070825b0 Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Sun, 24 Oct 2021 18:12:46 +0200 Subject: [PATCH 14/18] chore: update sqlite server implementation to new server struct --- client/src/database/sqlite/server.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/database/sqlite/server.rs b/client/src/database/sqlite/server.rs index cbf94a4..b190788 100644 --- a/client/src/database/sqlite/server.rs +++ b/client/src/database/sqlite/server.rs @@ -41,7 +41,7 @@ impl Get for Server { ) .fetch(app.pool()) .map_ok(move |record| { - Server::from_values(record.endpoint, record.nickname) + Server::from_values(record.endpoint, record.nickname, None) .expect("invalid server in database") }) // TODO: map to correct error @@ -62,7 +62,8 @@ impl Get for Server { // TODO: map to correct error .map_err(|_| Error::DatabaseError(DatabaseError::Other))? .map(|record| { - Self::from_values(record.endpoint, record.nickname).expect("invalid server in database") + Self::from_values(record.endpoint, record.nickname, None) + .expect("invalid server in database") })) } } From 7bb1795a1b8fd4fce04c993831df4a5c400e2f3b Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Sun, 24 Oct 2021 18:13:30 +0200 Subject: [PATCH 15/18] feat: implement challenge request --- client/Cargo.toml | 4 ++ client/src/challenge.rs | 56 +++++++++++++++++++++ client/src/lib.rs | 1 + client/src/util.rs | 37 ++++++++++++-- other/graphql/queries/get_challenge.graphql | 3 ++ 5 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 client/src/challenge.rs create mode 100644 other/graphql/queries/get_challenge.graphql diff --git a/client/Cargo.toml b/client/Cargo.toml index ea778a9..121b726 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -32,6 +32,10 @@ sequoia-openpgp = { version = "1.5.0", default-features = false, features = [ ]} uuid = { version = "0.8.2", features = ["serde"] } bytes = { version = "1.1.0", features = ["serde"] } +graphql_client = { version = "0.10.0", features = ["reqwest"]} +# I'd really like to use ciborium since serde_cbor is no longer maintained, +# but it seems like they aren't maintaing ciborium either. +serde_cbor = "0.11.2" [dev-dependencies] # used to test async code diff --git a/client/src/challenge.rs b/client/src/challenge.rs new file mode 100644 index 0000000..a6b3c05 --- /dev/null +++ b/client/src/challenge.rs @@ -0,0 +1,56 @@ +//! Proof the ownership of a key to a [`Server`] +use bytes::Bytes; +use graphql_client::{GraphQLQuery, QueryBody}; + +use crate::{server::Server, util::perform_query, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone)] +#[non_exhaustive] +/// A random challenge used by the [`crate::server::Server`] to ensure that the +/// [`crate::actor::Actor`] has control over the primary key of a [`sequoia_openpgp::Cert`] +pub struct Challenge { + /// The actual challenge hex string + challenge: Bytes, +} + +impl Challenge { + /// Request a [`Challenge`] from a [`Server`] + /// + /// # Example + /// + /// Note: This example is not tested because it needs a running backend server + /// ```ignore + /// # use url::Url; + /// # use minkan_client::server::Server; + /// # use minkan_client::Application; + /// # use minkan_client::challenge::Challenge; + /// # tokio_test::block_on( async { + /// let api_endpoint = Url::parse("http://localhost:8000/graphql").unwrap(); + /// + /// // the server we request the challange from + /// let server = Server::new(api_endpoint, None).await.unwrap(); + /// + /// let challenge = Challenge::request(&server).await.unwrap(); + /// + /// // and you can compare them (but they should never be the same anyway) + /// assert!(challenge == challenge); + /// + /// # }) + /// ``` + pub async fn request(server: &Server) -> Result { + perform_query::(Self::build_query(()), server).await + } +} + +impl GraphQLQuery for Challenge { + type Variables = (); + type ResponseData = Self; + fn build_query(variables: Self::Variables) -> QueryBody { + QueryBody { + variables, + query: include_str!("../../other/graphql/queries/get_challenge.graphql"), + operation_name: "getChallenge", + } + } +} diff --git a/client/src/lib.rs b/client/src/lib.rs index 973b113..e298ff2 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -10,6 +10,7 @@ pub mod actor; mod application; pub mod error; +pub mod challenge; mod node; pub mod server; mod util; diff --git a/client/src/util.rs b/client/src/util.rs index f7c9939..b53b677 100644 --- a/client/src/util.rs +++ b/client/src/util.rs @@ -1,12 +1,16 @@ //! Helper methods +use graphql_client::{GraphQLQuery, QueryBody, Response}; use sequoia_openpgp::{parse::Parse, serialize::SerializeInto, Cert}; use serde::{Deserialize, Deserializer, Serializer}; +use std::result::Result as StdResult; + +use crate::{server::Server, Error, Result}; /// A helper method to serialize a [`Cert`] with serde. /// /// **This method will always include secret key material if there is some** -pub fn serialize_cert(cert: &Cert, ser: S) -> Result +pub fn serialize_cert(cert: &Cert, ser: S) -> StdResult where S: Serializer, { @@ -19,7 +23,7 @@ where } /// A helper method to deserialize a [`Cert`] with serde -pub fn deserialize_cert<'de, D>(de: D) -> Result +pub fn deserialize_cert<'de, D>(de: D) -> StdResult where D: Deserializer<'de>, { @@ -35,9 +39,36 @@ where } /// A helper method to deserialize a [`Cert`] with serde which returns a [`Box`] -pub fn deserialize_cert_box<'de, D>(de: D) -> Result, D::Error> +pub fn deserialize_cert_box<'de, D>(de: D) -> StdResult, D::Error> where D: Deserializer<'de>, { deserialize_cert::<'de>(de).map(Box::new) } + +/// Performs a graphql query/mutation +pub async fn perform_query( + query_body: QueryBody, + server: &Server, +) -> Result +where + T: GraphQLQuery, +{ + // there shouldn't be an error with serde + let body = serde_cbor::to_vec(&query_body).map_err(|e| Error::Other(e.into()))?; + + // perform the request + let response = server + .client + .post(server.api_endpoint.clone()) + // cbor is in application/octet-stream + .header("Content-Type", "application/octet-stream") + .body(body) + .send() + .await?; + + let response: Response = + serde_cbor::from_slice(&response.bytes().await?).map_err(|e| Error::Other(e.into()))?; + + Ok(response.data.unwrap()) +} diff --git a/other/graphql/queries/get_challenge.graphql b/other/graphql/queries/get_challenge.graphql new file mode 100644 index 0000000..d58fa5f --- /dev/null +++ b/other/graphql/queries/get_challenge.graphql @@ -0,0 +1,3 @@ +query getChallenge { + challenge +} From 2e4e60c7202b48aba68c84ccb8611b871700ccca Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Tue, 26 Oct 2021 19:25:48 +0200 Subject: [PATCH 16/18] feat: add new error type for `erros` section in graphql response --- client/src/error.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/src/error.rs b/client/src/error.rs index 8b4d8b1..06160be 100644 --- a/client/src/error.rs +++ b/client/src/error.rs @@ -50,8 +50,12 @@ pub enum Error { /// Special case to represent errors during parsing of some data ParseError(#[from] ParseError), #[error(transparent)] - /// Maps to the graphql api errors + /// Maps to the expected graphql result type api errors ApiError(#[from] ApiError), + #[error("`errors`` section from api request: {0:#?}")] + /// Represents errors from the GraphQL API that are in the `errors` field + /// in the response mapping which aren't expected. + GraphQLError(Vec), #[error(transparent)] /// Errors that may occur while sending http requests to the api ReqwestError(#[from] reqwest::Error), From f9038ef484d8fc4cf1fe64bb5d85328c841c35aa Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Tue, 26 Oct 2021 19:26:28 +0200 Subject: [PATCH 17/18] fix: don't unwrap in perform_request method --- client/src/util.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/client/src/util.rs b/client/src/util.rs index b53b677..921e7cd 100644 --- a/client/src/util.rs +++ b/client/src/util.rs @@ -70,5 +70,23 @@ where let response: Response = serde_cbor::from_slice(&response.bytes().await?).map_err(|e| Error::Other(e.into()))?; - Ok(response.data.unwrap()) + /* + A GraphQL response looks like the following: + { + "data": expected data (T::ResponseData), + "errors": Vec + } + graphql_client represents that with the Response struct, + where both data and errors are Options + */ + // if data is None, there must be at least one error (at least as defined in the graphql spec) + response.data.ok_or(()).or_else(|_| { + // + response + .errors + // no data and no errors violates the spec + .ok_or_else(|| anyhow::anyhow!("api did not return any data nor any errors").into()) + // map in erros from Some into Err since we handle them in the Result type as GraphQL errors + .and_then(|errors| Err(Error::GraphQLError(errors))) + }) } From 574279e6372f482f2dd5bc7915a2ff5dd3d9c1b0 Mon Sep 17 00:00:00 2001 From: Erik Tesar Date: Mon, 15 Nov 2021 18:04:35 +0100 Subject: [PATCH 18/18] fix: use unit type for unimplemented api errors (see #12) --- client/src/error.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/error.rs b/client/src/error.rs index 06160be..8615908 100644 --- a/client/src/error.rs +++ b/client/src/error.rs @@ -34,7 +34,14 @@ macro_rules! api_errors { $field: $field_type ),*)? } - ),* + ),*, + // "workaround" for #12 + #[serde(other)] + #[error("not yet implemented ApiError")] + /// A fallback error to indicate that the error is not implemented (yet) + /// Because of a limitation from serde (see #12) you cannot access + /// `description` and `hint` + Other } }; }