diff --git a/CHANGELOG.md b/CHANGELOG.md index 690ec5b36..f9b963675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactor module structure, propagate errors in worker to service manager [#97](https://github.com/p2panda/aquadoggo/pull/97) - Restructure storage modules and remove JSON RPC [#101](https://github.com/p2panda/aquadoggo/pull/101) - Implement new methods required for replication defined by `EntryStore` trait [#102](https://github.com/p2panda/aquadoggo/pull/102) +- GraphQL client API [#119](https://github.com/p2panda/aquadoggo/pull/119) ### Changed diff --git a/aquadoggo/src/context.rs b/aquadoggo/src/context.rs index 7170a42ef..8f2b4a2c0 100644 --- a/aquadoggo/src/context.rs +++ b/aquadoggo/src/context.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use crate::config::Configuration; use crate::db::Pool; -use crate::graphql::{build_static_schema, StaticSchema}; +use crate::graphql::{build_root_schema, RootSchema}; /// Inner data shared across all services. pub struct Data { @@ -16,13 +16,13 @@ pub struct Data { pub pool: Pool, /// Static GraphQL schema. - pub schema: StaticSchema, + pub schema: RootSchema, } impl Data { /// Initialize new data instance with shared database connection pool. pub fn new(pool: Pool, config: Configuration) -> Self { - let schema = build_static_schema(pool.clone()); + let schema = build_root_schema(pool.clone()); Self { config, diff --git a/aquadoggo/src/db/provider.rs b/aquadoggo/src/db/provider.rs index c797bc977..9bf80ec58 100644 --- a/aquadoggo/src/db/provider.rs +++ b/aquadoggo/src/db/provider.rs @@ -10,7 +10,9 @@ use crate::db::stores::StorageEntry; use crate::db::stores::StorageLog; use crate::db::Pool; use crate::errors::StorageProviderResult; -use crate::rpc::{EntryArgsRequest, EntryArgsResponse, PublishEntryRequest, PublishEntryResponse}; +use crate::graphql::client::{ + EntryArgsRequest, EntryArgsResponse, PublishEntryRequest, PublishEntryResponse, +}; pub struct SqlStorage { pub(crate) pool: Pool, diff --git a/aquadoggo/src/db/stores/entry.rs b/aquadoggo/src/db/stores/entry.rs index 85c22364e..c1962d20d 100644 --- a/aquadoggo/src/db/stores/entry.rs +++ b/aquadoggo/src/db/stores/entry.rs @@ -421,7 +421,7 @@ mod tests { use crate::db::stores::entry::StorageEntry; use crate::db::stores::test_utils::test_db; - use crate::rpc::EntryArgsRequest; + use crate::graphql::client::EntryArgsRequest; #[tokio::test] async fn insert_entry() { @@ -456,7 +456,7 @@ mod tests { let update_operation = Operation::new_update( schema.clone(), - vec![next_entry_args.entry_hash_backlink.clone().unwrap().into()], + vec![next_entry_args.backlink.clone().unwrap().into()], fields.clone(), ) .unwrap(); @@ -464,8 +464,8 @@ mod tests { let update_entry = Entry::new( &next_entry_args.log_id, Some(&update_operation), - next_entry_args.entry_hash_skiplink.as_ref(), - next_entry_args.entry_hash_backlink.as_ref(), + next_entry_args.skiplink.as_ref(), + next_entry_args.backlink.as_ref(), &next_entry_args.seq_num, ) .unwrap(); diff --git a/aquadoggo/src/db/stores/test_utils.rs b/aquadoggo/src/db/stores/test_utils.rs index 10b408dbc..9dc519085 100644 --- a/aquadoggo/src/db/stores/test_utils.rs +++ b/aquadoggo/src/db/stores/test_utils.rs @@ -16,7 +16,7 @@ use p2panda_rs::storage_provider::traits::StorageProvider; use p2panda_rs::test_utils::constants::{DEFAULT_HASH, DEFAULT_PRIVATE_KEY, TEST_SCHEMA_ID}; use crate::db::provider::SqlStorage; -use crate::rpc::{EntryArgsRequest, PublishEntryRequest}; +use crate::graphql::client::{EntryArgsRequest, PublishEntryRequest}; use crate::test_helpers::initialize_db; pub fn test_operation() -> Operation { @@ -129,7 +129,7 @@ pub async fn test_db(no_of_entries: usize) -> SqlStorage { .await .unwrap(); - let backlink = next_entry_args.entry_hash_backlink.clone().unwrap(); + let backlink = next_entry_args.backlink.clone().unwrap(); // Construct the next UPDATE operation, we use the backlink hash in the prev_op vector let update_operation = @@ -138,8 +138,8 @@ pub async fn test_db(no_of_entries: usize) -> SqlStorage { let update_entry = Entry::new( &next_entry_args.log_id, Some(&update_operation), - next_entry_args.entry_hash_skiplink.as_ref(), - next_entry_args.entry_hash_backlink.as_ref(), + next_entry_args.skiplink.as_ref(), + next_entry_args.backlink.as_ref(), &next_entry_args.seq_num, ) .unwrap(); diff --git a/aquadoggo/src/errors.rs b/aquadoggo/src/errors.rs index 8bf64df50..d2fbc339f 100644 --- a/aquadoggo/src/errors.rs +++ b/aquadoggo/src/errors.rs @@ -1,4 +1,4 @@ // SPDX-License-Identifier: AGPL-3.0-or-later /// A specialized result type for the storage provider. -pub type StorageProviderResult = anyhow::Result>; +pub type StorageProviderResult = anyhow::Result>; diff --git a/aquadoggo/src/rpc/mod.rs b/aquadoggo/src/graphql/client/mod.rs similarity index 75% rename from aquadoggo/src/rpc/mod.rs rename to aquadoggo/src/graphql/client/mod.rs index 3d6ca11fb..b1c54545d 100644 --- a/aquadoggo/src/rpc/mod.rs +++ b/aquadoggo/src/graphql/client/mod.rs @@ -2,6 +2,9 @@ mod request; mod response; +mod root; +pub(crate) mod u64_string; pub use request::{EntryArgsRequest, PublishEntryRequest}; pub use response::{EntryArgsResponse, PublishEntryResponse}; +pub use root::ClientRoot; diff --git a/aquadoggo/src/rpc/request.rs b/aquadoggo/src/graphql/client/request.rs similarity index 100% rename from aquadoggo/src/rpc/request.rs rename to aquadoggo/src/graphql/client/request.rs diff --git a/aquadoggo/src/rpc/response.rs b/aquadoggo/src/graphql/client/response.rs similarity index 60% rename from aquadoggo/src/rpc/response.rs rename to aquadoggo/src/graphql/client/response.rs index 66855c794..7f2a8a216 100644 --- a/aquadoggo/src/rpc/response.rs +++ b/aquadoggo/src/graphql/client/response.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use serde::Serialize; +use async_graphql::Object; +use serde::{Deserialize, Serialize}; use p2panda_rs::entry::{LogId, SeqNum}; use p2panda_rs::hash::Hash; @@ -11,27 +12,48 @@ use crate::db::models::EntryRow; /// Response body of `panda_getEntryArguments`. /// /// `seq_num` and `log_id` are returned as strings to be able to represent large integers in JSON. -#[derive(Serialize, Debug)] +#[derive(Deserialize, Debug, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct EntryArgsResponse { - pub entry_hash_backlink: Option, - pub entry_hash_skiplink: Option, - pub seq_num: SeqNum, + #[serde(with = "super::u64_string::log_id_string_serialisation")] pub log_id: LogId, + + #[serde(with = "super::u64_string::seq_num_string_serialisation")] + pub seq_num: SeqNum, + + pub backlink: Option, + + pub skiplink: Option, +} + +#[Object] +impl EntryArgsResponse { + #[graphql(name = "logId")] + async fn log_id(&self) -> String { + self.log_id.clone().as_u64().to_string() + } + + #[graphql(name = "seqNum")] + async fn seq_num(&self) -> String { + self.seq_num.clone().as_u64().to_string() + } + + async fn backlink(&self) -> Option { + self.backlink.clone().map(|hash| hash.as_str().to_string()) + } + + async fn skiplink(&self) -> Option { + self.skiplink.clone().map(|hash| hash.as_str().to_string()) + } } impl AsEntryArgsResponse for EntryArgsResponse { - fn new( - entry_hash_backlink: Option, - entry_hash_skiplink: Option, - seq_num: SeqNum, - log_id: LogId, - ) -> Self { + fn new(backlink: Option, skiplink: Option, seq_num: SeqNum, log_id: LogId) -> Self { EntryArgsResponse { - entry_hash_backlink, - entry_hash_skiplink, - seq_num, log_id, + seq_num, + backlink, + skiplink, } } } diff --git a/aquadoggo/src/graphql/client/root.rs b/aquadoggo/src/graphql/client/root.rs new file mode 100644 index 000000000..eff8d83d5 --- /dev/null +++ b/aquadoggo/src/graphql/client/root.rs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_graphql::{Context, Error, Object, Result}; +use p2panda_rs::document::DocumentId; +use p2panda_rs::identity::Author; +use p2panda_rs::storage_provider::traits::StorageProvider; + +use crate::db::provider::SqlStorage; +use crate::db::Pool; + +use super::{EntryArgsRequest, EntryArgsResponse}; + +#[derive(Default, Debug, Copy, Clone)] +/// The GraphQL root for the client api that p2panda clients can use to connect to a node. +pub struct ClientRoot; + +#[Object] +impl ClientRoot { + /// Return required arguments for publishing the next entry. + async fn next_entry_args( + &self, + ctx: &Context<'_>, + #[graphql( + name = "publicKey", + desc = "Public key that will publish using the returned entry arguments" + )] + public_key_param: String, + #[graphql( + name = "documentId", + desc = "Document id to which the entry's operation will apply" + )] + document_id_param: Option, + ) -> Result { + // Parse and validate parameters + let document_id = match document_id_param { + Some(val) => Some(val.parse::()?), + None => None, + }; + let args = EntryArgsRequest { + author: Author::new(&public_key_param)?, + document: document_id, + }; + + // Prepare database connection + let pool = ctx.data::()?; + let provider = SqlStorage { + pool: pool.to_owned(), + }; + + provider + .get_entry_args(&args) + .await + .map_err(|err| Error::from(err)) + } +} + +#[cfg(test)] +mod tests { + use async_graphql::{value, Response}; + use p2panda_rs::entry::{LogId, SeqNum}; + use serde_json::json; + + use crate::config::Configuration; + use crate::context::Context; + use crate::graphql::client::EntryArgsResponse; + use crate::server::build_server; + use crate::test_helpers::{initialize_db, TestClient}; + + #[tokio::test] + async fn next_entry_args_valid_query() { + let pool = initialize_db().await; + let context = Context::new(pool.clone(), Configuration::default()); + let client = TestClient::new(build_server(context)); + + // Selected fields need to be alphabetically sorted because that's what the `json` macro + // that is used in the assert below produces. + let response = client + .post("/graphql") + .json(&json!({ + "query": r#"{ + nextEntryArgs( + publicKey: "8b52ae153142288402382fd6d9619e018978e015e6bc372b1b0c7bd40c6a240a" + ) { + logId, + seqNum, + backlink, + skiplink + } + }"#, + })) + .send() + .await + // .json::>() + .json::() + .await; + + let expected_entry_args = EntryArgsResponse { + log_id: LogId::new(1), + seq_num: SeqNum::new(1).unwrap(), + backlink: None, + skiplink: None, + }; + let received_entry_args: EntryArgsResponse = match response.data { + async_graphql::Value::Object(result_outer) => { + async_graphql::from_value(result_outer.get("nextEntryArgs").unwrap().to_owned()) + .unwrap() + } + _ => panic!("Expected return value to be an object"), + }; + + assert_eq!(received_entry_args, expected_entry_args); + } + + #[tokio::test] + async fn next_entry_args_error_response() { + let pool = initialize_db().await; + let context = Context::new(pool.clone(), Configuration::default()); + let client = TestClient::new(build_server(context)); + + // Selected fields need to be alphabetically sorted because that's what the `json` macro + // that is used in the assert below produces. + + let response = client + .post("/graphql") + .json(&json!({ + "query": r#"{ + nextEntryArgs(publicKey: "nope") { + logId + } + }"#, + })) + .send() + .await; + + let response: Response = response.json().await; + assert_eq!( + response.errors[0].message, + "invalid hex encoding in author string" + ) + } +} diff --git a/aquadoggo/src/graphql/client/u64_string.rs b/aquadoggo/src/graphql/client/u64_string.rs new file mode 100644 index 000000000..90b3211c8 --- /dev/null +++ b/aquadoggo/src/graphql/client/u64_string.rs @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use core::fmt; + +use serde::de::{self, Unexpected, Visitor}; + +/// Serialise log id as strings. +/// +/// To be used as a parameter for Serde's `with` field attribute. +#[allow(dead_code)] +pub mod log_id_string_serialisation { + use p2panda_rs::entry::LogId; + use serde::{Deserializer, Serializer}; + + use super::U64StringVisitor; + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let u64_val = deserializer.deserialize_string(U64StringVisitor)?; + Ok(LogId::new(u64_val)) + } + + pub fn serialize(value: &LogId, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&value.as_u64().to_string()) + } +} + +/// Serialise sequence numbers as strings. +/// +/// To be used as a parameter for Serde's `with` field attribute. +#[allow(dead_code)] +pub mod seq_num_string_serialisation { + use p2panda_rs::entry::SeqNum; + use serde::de::Error; + use serde::{Deserializer, Serializer}; + + use super::U64StringVisitor; + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let u64_val = deserializer.deserialize_string(U64StringVisitor)?; + SeqNum::new(u64_val).map_err(D::Error::custom) + } + + pub fn serialize(value: &SeqNum, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&value.as_u64().to_string()) + } +} + +/// Serde visitor for deserialising string representations of u64 values. +struct U64StringVisitor; + +impl<'de> Visitor<'de> for U64StringVisitor { + type Value = u64; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("string representation of a u64") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + value.parse::().map_err(|_err| { + E::invalid_value(Unexpected::Str(value), &"string representation of a u64") + }) + } +} + +#[cfg(test)] +mod tests { + use p2panda_rs::entry::{LogId, SeqNum}; + use serde::{Deserialize, Serialize}; + + #[test] + fn log_id() { + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct Value { + #[serde(with = "super::log_id_string_serialisation")] + log_id: LogId, + } + + let val = Value { + log_id: LogId::new(1), + }; + let serialised = serde_json::to_string(&val).unwrap(); + assert_eq!(serialised, "{\"log_id\":\"1\"}".to_string()); + assert_eq!(val, serde_json::from_str(&serialised).unwrap()); + } + + #[test] + fn seq_num() { + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct Value { + #[serde(with = "super::seq_num_string_serialisation")] + seq_num: SeqNum, + } + + let val = Value { + seq_num: SeqNum::new(1).unwrap(), + }; + let serialised = serde_json::to_string(&val).unwrap(); + assert_eq!(serialised, "{\"seq_num\":\"1\"}".to_string()); + assert_eq!(val, serde_json::from_str(&serialised).unwrap()); + } +} diff --git a/aquadoggo/src/graphql/mod.rs b/aquadoggo/src/graphql/mod.rs index c0e051e48..8c56d6ebf 100644 --- a/aquadoggo/src/graphql/mod.rs +++ b/aquadoggo/src/graphql/mod.rs @@ -1,7 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later mod api; +pub mod client; mod schema; pub use api::{handle_graphql_playground, handle_graphql_query}; -pub use schema::{build_static_schema, StaticSchema}; +pub use schema::{build_root_schema, RootSchema}; diff --git a/aquadoggo/src/graphql/schema.rs b/aquadoggo/src/graphql/schema.rs index 4b3f0aff2..a6d663001 100644 --- a/aquadoggo/src/graphql/schema.rs +++ b/aquadoggo/src/graphql/schema.rs @@ -1,26 +1,20 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use std::str::FromStr; - -use async_graphql::{EmptyMutation, EmptySubscription, Object, Schema}; +use async_graphql::{EmptyMutation, EmptySubscription, MergedObject, Schema}; use crate::db::Pool; +use crate::graphql::client::ClientRoot; -pub struct QueryRoot; - -#[Object] -impl QueryRoot { - // @TODO: Remove this example. - async fn ping(&self) -> String { - String::from_str("pong").unwrap() - } -} +#[derive(MergedObject, Debug)] +pub struct QueryRoot(pub ClientRoot); /// GraphQL schema for p2panda node. -pub type StaticSchema = Schema; +pub type RootSchema = Schema; -pub fn build_static_schema(pool: Pool) -> StaticSchema { - Schema::build(QueryRoot, EmptyMutation, EmptySubscription) +pub fn build_root_schema(pool: Pool) -> RootSchema { + let client_root: ClientRoot = Default::default(); + let query_root = QueryRoot(client_root); + Schema::build(query_root, EmptyMutation, EmptySubscription) .data(pool) .finish() } diff --git a/aquadoggo/src/lib.rs b/aquadoggo/src/lib.rs index c063bd7df..718709da8 100644 --- a/aquadoggo/src/lib.rs +++ b/aquadoggo/src/lib.rs @@ -22,7 +22,6 @@ mod graphql; mod manager; mod materializer; mod node; -mod rpc; mod server; #[cfg(test)] diff --git a/aquadoggo/src/server.rs b/aquadoggo/src/server.rs index 073fda0bd..e96a7feb1 100644 --- a/aquadoggo/src/server.rs +++ b/aquadoggo/src/server.rs @@ -68,7 +68,7 @@ mod tests { let response = client .post("/graphql") .json(&json!({ - "query": "{ ping }", + "query": "{ __schema { __typename } }", })) .send() .await; @@ -77,7 +77,9 @@ mod tests { response.text().await, json!({ "data": { - "ping": "pong" + "__schema": { + "__typename": "__Schema" + } } }) .to_string()