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 }} diff --git a/client/Cargo.toml b/client/Cargo.toml index 84eabbe..121b726 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,10 +22,27 @@ serde = { version = "1.0.130", features = ["derive"]} url = "2.2.2" async-stream = "0.3.2" futures = "0.3" -directories = "4.0.1" +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" +]} +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 +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 diff --git a/client/src/actor.rs b/client/src/actor.rs new file mode 100644 index 0000000..802db3b --- /dev/null +++ b/client/src/actor.rs @@ -0,0 +1,24 @@ +//! 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, 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 + Debug { + /// 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; +} +// actor types support downcasting +impl_downcast!(Actor); diff --git a/client/src/actor/user.rs b/client/src/actor/user.rs new file mode 100644 index 0000000..319d28e --- /dev/null +++ b/client/src/actor/user.rs @@ -0,0 +1,39 @@ +use crate::{seal::Sealed, Node}; + +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/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/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/database.rs b/client/src/database.rs index 240143a..6510742 100644 --- a/client/src/database.rs +++ b/client/src/database.rs @@ -1,21 +1,61 @@ //! 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 { + /// 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, p: &Self::Parent) -> 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..b190788 --- /dev/null +++ b/client/src/database/sqlite/server.rs @@ -0,0 +1,69 @@ +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 { + type Parent = Application; + async fn insert(&self, app: &Self::Parent) -> Result<()> { + let endpoint = self.api_endpoint.as_str(); + sqlx::query!( + r#" + INSERT INTO servers(api_endpoint, nickname) + VALUES ($1, $2) + "#, + endpoint, + self.nickname, + ) + .execute(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, None) + .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, None) + .expect("invalid server in database") + })) + } +} diff --git a/client/src/error.rs b/client/src/error.rs index aaca5ee..8615908 100644 --- a/client/src/error.rs +++ b/client/src/error.rs @@ -1,16 +1,153 @@ +//! Different error types + +use crate::{actor::Actor, database::DatabaseError}; +use sequoia_openpgp::Cert; use serde::Deserialize; use thiserror::Error; -#[derive(Debug, Deserialize, Error)] -#[serde(tag = "__typename")] +/// 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 + ),*)? + } + ),*, + // "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 + } + }; +} + +/// A type alias for Results which return [`enum@Error`] +pub type Result = core::result::Result; + +#[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 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), + #[error(transparent)] + /// Errors that are associated with OpenPGP + OpenPGPError(#[from] sequoia_openpgp::Error), + #[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; +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")] + /// 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), +} diff --git a/client/src/lib.rs b/client/src/lib.rs index 0e4bf96..e298ff2 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -2,15 +2,25 @@ //! //! 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; +pub mod actor; mod application; -mod error; +pub mod error; +pub mod challenge; +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/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; +} diff --git a/client/src/server.rs b/client/src/server.rs index 730caf5..4a3c696 100644 --- a/client/src/server.rs +++ b/client/src/server.rs @@ -1,6 +1,10 @@ //! The backend the client talks to + +use reqwest::Client; use url::Url; +use crate::{error::ParseError, seal::Sealed, Error, Result}; + #[non_exhaustive] #[derive(Debug)] /// A server the client can talk to @@ -12,6 +16,102 @@ 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 [`Client`] used to communicate with the [`Server`] via the graphql + /// endpoint + pub(crate) client: Client, +} + +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 api_endpoint = Url::parse("https://example.com/graphql").unwrap(); + /// let server = Server::new(api_endpoint, None).await.unwrap(); + /// # }) + /// ``` + 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 { + url: api_endpoint, + })); + } + let client = Client::new(); + Ok(Self { + api_endpoint, + nickname, + client, + }) + } + + /// 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 api_endpoint = Url::parse("https://example.com/graphql").unwrap(); + /// let server = Server::new(api_endpoint, None).await.unwrap(); + /// assert_eq!(server.endpoint().path(), "/graphql"); + /// # }) + /// ``` + pub fn endpoint(&self) -> &Url { + &self.api_endpoint + } + + /// Returns the user-defined nickname of a [`Server`]. + /// Nicknames can help an 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 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).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, + 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, + }) + } } diff --git a/client/src/util.rs b/client/src/util.rs new file mode 100644 index 0000000..921e7cd --- /dev/null +++ b/client/src/util.rs @@ -0,0 +1,92 @@ +//! 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) -> StdResult +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) -> StdResult +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) -> 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()))?; + + /* + 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))) + }) +} 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 +}