Repository: p2panda/aquadoggo (language: rust)

Issue:
Replace JSON RPC with GraphQL API queries and mutations



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9b963675..3c8d4d4d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,7 +16,8 @@ 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)
+- GraphQL client API with endpoint for retrieving next entry arguments [#119](https://github.com/p2panda/aquadoggo/pull/119)
+- GraphQL endpoint for publishing entries [#123](https://github.com/p2panda/aquadoggo/pull/132)
 
 ### Changed
 
diff --git a/aquadoggo/src/graphql/client/mod.rs b/aquadoggo/src/graphql/client/mod.rs
index b1c54545d..32ac84bea 100644
--- a/aquadoggo/src/graphql/client/mod.rs
+++ b/aquadoggo/src/graphql/client/mod.rs
@@ -1,10 +1,12 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
+mod mutation;
+mod query;
 mod request;
 mod response;
-mod root;
 pub(crate) mod u64_string;
 
+pub use mutation::Mutation;
+pub use query::Query;
 pub use request::{EntryArgsRequest, PublishEntryRequest};
 pub use response::{EntryArgsResponse, PublishEntryResponse};
-pub use root::ClientRoot;
diff --git a/aquadoggo/src/graphql/client/mutation.rs b/aquadoggo/src/graphql/client/mutation.rs
new file mode 100644
index 000000000..b858c5ef3
--- /dev/null
+++ b/aquadoggo/src/graphql/client/mutation.rs
@@ -0,0 +1,142 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use async_graphql::{Context, Error, Object, Result};
+use p2panda_rs::entry::EntrySigned;
+use p2panda_rs::operation::OperationEncoded;
+use p2panda_rs::storage_provider::traits::StorageProvider;
+
+use crate::db::provider::SqlStorage;
+use crate::db::Pool;
+
+use super::{PublishEntryRequest, PublishEntryResponse};
+
+/// Mutations for use by p2panda clients.
+#[derive(Default, Debug, Copy, Clone)]
+pub struct Mutation;
+
+#[Object]
+impl Mutation {
+    /// Publish an entry using parameters obtained through `nextEntryArgs` query.
+    ///
+    /// Returns arguments for publishing the next entry in the same log.
+    async fn publish_entry(
+        &self,
+        ctx: &Context<'_>,
+        #[graphql(name = "entryEncoded", desc = "Encoded entry to publish")]
+        entry_encoded_param: String,
+        #[graphql(
+            name = "operationEncoded",
+            desc = "Encoded entry payload, which contains a p2panda operation matching the \
+            provided encoded entry."
+        )]
+        operation_encoded_param: String,
+    ) -> Result<PublishEntryResponse> {
+        // Parse and validate parameters
+        let args = PublishEntryRequest {
+            entry_encoded: EntrySigned::new(&entry_encoded_param)?,
+            operation_encoded: OperationEncoded::new(&operation_encoded_param)?,
+        };
+
+        // Prepare database connection
+        let pool = ctx.data::<Pool>()?;
+        let provider = SqlStorage {
+            pool: pool.to_owned(),
+        };
+
+        provider
+            .publish_entry(&args)
+            .await
+            .map_err(|err| Error::from(err))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use async_graphql::{from_value, value, Request, Value, Variables};
+    use p2panda_rs::entry::{LogId, SeqNum};
+
+    use crate::context::Context;
+    use crate::graphql::client::PublishEntryResponse;
+    use crate::test_helpers::initialize_db;
+    use crate::Configuration;
+
+    const ENTRY_ENCODED: &str =
+        "00bedabb435758855968b3e2de2aa1f653adfbb392fcf9cb2295a68b2eca3cfb0301\
+    01a200204b771d59d76e820cbae493682003e99b795e4e7c86a8d6b4c9ad836dc4c9bf1d3970fb39f21542099ba\
+    2fbfd6ec5076152f26c02445c621b43a7e2898d203048ec9f35d8c2a1547f2b83da8e06cadd8a60bb45d3b50045\
+    1e63f7cccbcbd64d09";
+
+    const OPERATION_ENCODED: &str = "a466616374696f6e6663726561746566736368656d61784a76656e7565\
+    5f30303230633635353637616533376566656132393365333461396337643133663866326266323364626463336\
+    235633762396162343632393331313163343866633738626776657273696f6e01666669656c6473a1676d657373\
+    616765a26474797065637374726576616c7565764f68682c206d79206669727374206d65737361676521";
+
+    #[tokio::test]
+    async fn publish_entry() {
+        let pool = initialize_db().await;
+        let context = Context::new(pool.clone(), Configuration::default());
+
+        let query = r#"
+            mutation TestPublishEntry($entryEncoded: String!, $operationEncoded: String!) {
+                publishEntry(entryEncoded: $entryEncoded, operationEncoded: $operationEncoded) {
+                    logId,
+                    seqNum,
+                    backlink,
+                    skiplink
+                }
+            }"#;
+        let parameters = Variables::from_value(value!({
+            "entryEncoded": ENTRY_ENCODED,
+            "operationEncoded": OPERATION_ENCODED
+        }));
+        let request = Request::new(query).variables(parameters);
+        let response = context.0.schema.execute(request).await;
+
+        let received: PublishEntryResponse = match response.data {
+            Value::Object(result_outer) => {
+                from_value(result_outer.get("publishEntry").unwrap().to_owned()).unwrap()
+            }
+            _ => panic!("Expected return value to be an object"),
+        };
+        // The response should contain args for the next entry in the same log.
+        let expected = PublishEntryResponse {
+            log_id: LogId::new(1),
+            seq_num: SeqNum::new(2).unwrap(),
+            backlink: Some(
+                "00201c221b573b1e0c67c5e2c624a93419774cdf46b3d62414c44a698df1237b1c16"
+                    .parse()
+                    .unwrap(),
+            ),
+            skiplink: None,
+        };
+        assert_eq!(expected, received);
+    }
+
+    #[tokio::test]
+    async fn publish_entry_error_handling() {
+        let pool = initialize_db().await;
+        let context = Context::new(pool.clone(), Configuration::default());
+
+        let query = r#"
+            mutation TestPublishEntry($entryEncoded: String!, $operationEncoded: String!) {
+                publishEntry(entryEncoded: $entryEncoded, operationEncoded: $operationEncoded) {
+                    logId,
+                    seqNum,
+                    backlink,
+                    skiplink
+                }
+            }"#;
+        let parameters = Variables::from_value(value!({
+            "entryEncoded": ENTRY_ENCODED,
+            "operationEncoded": "".to_string()
+        }));
+        let request = Request::new(query).variables(parameters);
+        let response = context.0.schema.execute(request).await;
+
+        assert!(response.is_err());
+        assert_eq!(
+            "operation needs to match payload hash of encoded entry".to_string(),
+            response.errors[0].to_string()
+        );
+    }
+}
diff --git a/aquadoggo/src/graphql/client/root.rs b/aquadoggo/src/graphql/client/query.rs
similarity index 98%
rename from aquadoggo/src/graphql/client/root.rs
rename to aquadoggo/src/graphql/client/query.rs
index eff8d83d5..7a4b999b3 100644
--- a/aquadoggo/src/graphql/client/root.rs
+++ b/aquadoggo/src/graphql/client/query.rs
@@ -12,10 +12,10 @@ 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;
+pub struct Query;
 
 #[Object]
-impl ClientRoot {
+impl Query {
     /// Return required arguments for publishing the next entry.
     async fn next_entry_args(
         &self,
@@ -56,7 +56,7 @@ impl ClientRoot {
 
 #[cfg(test)]
 mod tests {
-    use async_graphql::{value, Response};
+    use async_graphql::Response;
     use p2panda_rs::entry::{LogId, SeqNum};
     use serde_json::json;
 
diff --git a/aquadoggo/src/graphql/client/response.rs b/aquadoggo/src/graphql/client/response.rs
index 7f2a8a216..7b5538939 100644
--- a/aquadoggo/src/graphql/client/response.rs
+++ b/aquadoggo/src/graphql/client/response.rs
@@ -12,7 +12,7 @@ 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(Deserialize, Debug, Eq, PartialEq)]
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
 #[serde(rename_all = "camelCase")]
 pub struct EntryArgsResponse {
     #[serde(with = "super::u64_string::log_id_string_serialisation")]
@@ -61,13 +61,39 @@ impl AsEntryArgsResponse for EntryArgsResponse {
 /// Response body of `panda_publishEntry`.
 ///
 /// `seq_num` and `log_id` are returned as strings to be able to represent large integers in JSON.
-#[derive(Serialize, Debug)]
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
 #[serde(rename_all = "camelCase")]
 pub struct PublishEntryResponse {
-    pub entry_hash_backlink: Option<Hash>,
-    pub entry_hash_skiplink: Option<Hash>,
-    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<Hash>,
+
+    pub skiplink: Option<Hash>,
+}
+
+#[Object]
+impl PublishEntryResponse {
+    #[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<String> {
+        self.backlink.clone().map(|hash| hash.as_str().to_string())
+    }
+
+    async fn skiplink(&self) -> Option<String> {
+        self.skiplink.clone().map(|hash| hash.as_str().to_string())
+    }
 }
 
 impl AsPublishEntryResponse for PublishEntryResponse {
@@ -78,8 +104,8 @@ impl AsPublishEntryResponse for PublishEntryResponse {
         log_id: LogId,
     ) -> Self {
         PublishEntryResponse {
-            entry_hash_backlink,
-            entry_hash_skiplink,
+            backlink: entry_hash_backlink,
+            skiplink: entry_hash_skiplink,
             seq_num,
             log_id,
         }
diff --git a/aquadoggo/src/graphql/schema.rs b/aquadoggo/src/graphql/schema.rs
index a6d663001..ec2392baf 100644
--- a/aquadoggo/src/graphql/schema.rs
+++ b/aquadoggo/src/graphql/schema.rs
@@ -1,20 +1,18 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
-use async_graphql::{EmptyMutation, EmptySubscription, MergedObject, Schema};
+use async_graphql::{EmptySubscription, Schema};
 
 use crate::db::Pool;
-use crate::graphql::client::ClientRoot;
-
-#[derive(MergedObject, Debug)]
-pub struct QueryRoot(pub ClientRoot);
+use crate::graphql::client::{Mutation, Query};
 
 /// GraphQL schema for p2panda node.
-pub type RootSchema = Schema<QueryRoot, EmptyMutation, EmptySubscription>;
+pub type RootSchema = Schema<Query, Mutation, 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)
+    let query = Query::default();
+    let mutation = Mutation::default();
+
+    Schema::build(query, mutation, EmptySubscription)
         .data(pool)
         .finish()
 }


Test patch: