diff --git a/CHANGELOG.md b/CHANGELOG.md index f844bd2f7..052af6922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Implement API changes to p2panda-rs storage traits, new and breaking db migration [#268](https://github.com/p2panda/aquadoggo/pull/268) - Move all test utils into one module [#275](https://github.com/p2panda/aquadoggo/pull/275) - Use new version of `async-graphql` for dynamic schema generation [#287](https://github.com/p2panda/aquadoggo/pull/287) +- Restructure `graphql` module [#307](https://github.com/p2panda/aquadoggo/pull/307) - Removed replication service for now, preparing for new replication protocol [#296](https://github.com/p2panda/aquadoggo/pull/296) ### Fixed diff --git a/aquadoggo/src/graphql/mod.rs b/aquadoggo/src/graphql/mod.rs index b7ca3aa7d..6c24b48e1 100644 --- a/aquadoggo/src/graphql/mod.rs +++ b/aquadoggo/src/graphql/mod.rs @@ -2,9 +2,11 @@ pub mod constants; pub mod mutations; +pub mod queries; pub mod scalars; mod schema; -mod schema_builders; +#[cfg(test)] +mod tests; pub mod types; pub mod utils; diff --git a/aquadoggo/src/graphql/queries/all_documents.rs b/aquadoggo/src/graphql/queries/all_documents.rs new file mode 100644 index 000000000..13184ea14 --- /dev/null +++ b/aquadoggo/src/graphql/queries/all_documents.rs @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_graphql::dynamic::{Field, FieldFuture, Object, TypeRef}; +use dynamic_graphql::FieldValue; +use log::debug; +use p2panda_rs::document::traits::AsDocument; +use p2panda_rs::schema::Schema; +use p2panda_rs::storage_provider::traits::DocumentStore; + +use crate::db::SqlStore; +use crate::graphql::constants; +use crate::graphql::scalars::{DocumentIdScalar, DocumentViewIdScalar}; + +/// Adds GraphQL query for getting all documents of a certain p2panda schema to the root query +/// object. +/// +/// The query follows the format `all_`. +pub fn build_all_documents_query(query: Object, schema: &Schema) -> Object { + let schema_id = schema.id().clone(); + query.field( + Field::new( + format!("{}{}", constants::QUERY_ALL_PREFIX, schema_id), + TypeRef::named_list(schema_id.to_string()), + move |ctx| { + // Take ownership of the schema id in the resolver. + let schema_id = schema_id.clone(); + + debug!( + "Query to {}{} received", + constants::QUERY_ALL_PREFIX, + schema_id + ); + + FieldFuture::new(async move { + // Fetch all queried documents and compose the field value, a list of document + // id / view id tuples, which will bubble up the query tree. + + let store = ctx.data_unchecked::(); + let documents: Vec = store + .get_documents_by_schema(&schema_id) + .await? + .iter() + .map(|document| { + FieldValue::owned_any(( + Some(DocumentIdScalar::from(document.id())), + None::, + )) + }) + .collect(); + + // Pass the list up to the children query fields. + Ok(Some(FieldValue::list(documents))) + }) + }, + ) + .description(format!("Get all {} documents.", schema.name())), + ) +} + +#[cfg(test)] +mod test { + use async_graphql::{value, Response}; + use p2panda_rs::identity::KeyPair; + use p2panda_rs::schema::FieldType; + use p2panda_rs::test_utils::fixtures::random_key_pair; + use rstest::rstest; + use serde_json::json; + + use crate::test_utils::{add_document, add_schema, graphql_test_client, test_runner, TestNode}; + + #[rstest] + fn collection_query(#[from(random_key_pair)] key_pair: KeyPair) { + // Test collection query parameter variations. + test_runner(move |mut node: TestNode| async move { + // Add schema to node. + let schema = add_schema( + &mut node, + "schema_name", + vec![("bool", FieldType::Boolean)], + &key_pair, + ) + .await; + + // Publish document on node. + add_document( + &mut node, + schema.id(), + vec![("bool", true.into())], + &key_pair, + ) + .await; + + // Configure and send test query. + let client = graphql_test_client(&node).await; + let query = format!( + r#"{{ + collection: all_{type_name} {{ + fields {{ bool }} + }}, + }}"#, + type_name = schema.id(), + ); + + let response = client + .post("/graphql") + .json(&json!({ + "query": query, + })) + .send() + .await; + + let response: Response = response.json().await; + + let expected_data = value!({ + "collection": value!([{ "fields": { "bool": true, } }]), + }); + assert_eq!(response.data, expected_data, "{:#?}", response.errors); + }); + } +} diff --git a/aquadoggo/src/graphql/schema_builders/document.rs b/aquadoggo/src/graphql/queries/document.rs similarity index 51% rename from aquadoggo/src/graphql/schema_builders/document.rs rename to aquadoggo/src/graphql/queries/document.rs index f036e230d..edf827463 100644 --- a/aquadoggo/src/graphql/schema_builders/document.rs +++ b/aquadoggo/src/graphql/queries/document.rs @@ -1,71 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use async_graphql::dynamic::{Field, FieldFuture, InputValue, Object, TypeRef}; -use dynamic_graphql::{Error, FieldValue, ScalarValue}; +use async_graphql::dynamic::{Field, FieldFuture, InputValue, Object, ResolverContext, TypeRef}; +use async_graphql::Error; +use dynamic_graphql::{FieldValue, ScalarValue}; use log::debug; -use p2panda_rs::storage_provider::traits::DocumentStore; -use p2panda_rs::{document::traits::AsDocument, schema::Schema}; +use p2panda_rs::schema::Schema; -use crate::db::SqlStore; use crate::graphql::constants; use crate::graphql::scalars::{DocumentIdScalar, DocumentViewIdScalar}; -use crate::graphql::types::DocumentMeta; -use crate::graphql::utils::{downcast_id_params, fields_name, get_document_from_params}; - -/// Build a GraphQL object type for a p2panda schema. -/// -/// Contains resolvers for both `fields` and `meta`. The former simply passes up the query -/// arguments to it's children query fields. The latter retrieves the document being queried and -/// already constructs and returns the `DocumentMeta` object. -pub fn build_document_schema(schema: &Schema) -> Object { - let document_fields_name = fields_name(schema.id()); - Object::new(schema.id().to_string()) - // The `fields` field of a document, passes up the query arguments to it's children. - .field(Field::new( - constants::FIELDS_FIELD, - TypeRef::named(document_fields_name), - move |ctx| { - FieldFuture::new(async move { - // Here we just pass up the root query parameters to be used in the fields resolver - let params = downcast_id_params(&ctx); - Ok(Some(FieldValue::owned_any(params))) - }) - }, - )) - // The `meta` field of a document, resolves the `DocumentMeta` object. - .field(Field::new( - constants::META_FIELD, - TypeRef::named(constants::DOCUMENT_META), - move |ctx| { - FieldFuture::new(async move { - let store = ctx.data_unchecked::(); - - // Downcast the parameters passed up from the parent query field - let (document_id, document_view_id) = downcast_id_params(&ctx); - // Get the whole document - let document = - get_document_from_params(store, &document_id, &document_view_id).await?; - - // Construct `DocumentMeta` and return it. We defined the document meta - // type and already registered it in the schema. It's derived resolvers - // will handle field selection. - let field_value = match document { - Some(document) => { - let document_meta = DocumentMeta { - document_id: document.id().into(), - view_id: document.view_id().into(), - }; - Some(FieldValue::owned_any(document_meta)) - } - None => Some(FieldValue::NULL), - }; - - Ok(field_value) - }) - }, - )) - .description(schema.description().to_string()) -} /// Adds GraphQL query for getting a single p2panda document, selected by its document id or /// document view id to the root query object. @@ -78,43 +20,12 @@ pub fn build_document_query(query: Object, schema: &Schema) -> Object { schema_id.to_string(), TypeRef::named(schema_id.to_string()), move |ctx| { - let schema_id = schema_id.clone(); FieldFuture::new(async move { - // Parse arguments - let mut document_id = None; - let mut document_view_id = None; - for (name, id) in ctx.field().arguments()?.into_iter() { - match name.as_str() { - constants::DOCUMENT_ID_ARG => { - document_id = Some(DocumentIdScalar::from_value(id)?); - } - constants::DOCUMENT_VIEW_ID_ARG => { - document_view_id = Some(DocumentViewIdScalar::from_value(id)?) - } - _ => (), - } - } + // Validate the received arguments. + let args = validate_args(&ctx)?; - // Check a valid combination of arguments was passed - match (&document_id, &document_view_id) { - (None, None) => { - return Err(Error::new("Must provide either `id` or `viewId` argument")) - } - (Some(_), Some(_)) => { - return Err(Error::new("Must only provide `id` or `viewId` argument")) - } - (Some(id), None) => { - debug!("Query to {} received for document {}", schema_id, id); - } - (None, Some(id)) => { - debug!( - "Query to {} received for document at view id {}", - schema_id, id - ); - } - }; - // Pass them up to the children query fields - Ok(Some(FieldValue::owned_any((document_id, document_view_id)))) + // Pass them up to the children query fields. + Ok(Some(FieldValue::owned_any(args))) }) }, ) @@ -133,49 +44,43 @@ pub fn build_document_query(query: Object, schema: &Schema) -> Object { ) } -/// Adds GraphQL query for getting all documents of a certain p2panda schema to the root query -/// object. -/// -/// The query follows the format `all_`. -pub fn build_all_document_query(query: Object, schema: &Schema) -> Object { - let schema_id = schema.id().clone(); - query.field( - Field::new( - format!("{}{}", constants::QUERY_ALL_PREFIX, schema_id), - TypeRef::named_list(schema_id.to_string()), - move |ctx| { - let schema_id = schema_id.clone(); - FieldFuture::new(async move { - debug!( - "Query to {}{} received", - constants::QUERY_ALL_PREFIX, - schema_id - ); - - // Access the store. - let store = ctx.data_unchecked::(); +fn validate_args( + ctx: &ResolverContext, +) -> Result<(Option, Option), Error> { + // Parse arguments + let schema_id = ctx.field().name(); + let mut document_id = None; + let mut document_view_id = None; + for (name, id) in ctx.field().arguments()?.into_iter() { + match name.as_str() { + constants::DOCUMENT_ID_ARG => { + document_id = Some(DocumentIdScalar::from_value(id)?); + } + constants::DOCUMENT_VIEW_ID_ARG => { + document_view_id = Some(DocumentViewIdScalar::from_value(id)?) + } + _ => (), + } + } - // Fetch all documents of the schema this endpoint serves and compose the - // field value (a list) which will bubble up the query tree. - let documents: Vec = store - .get_documents_by_schema(&schema_id) - .await? - .iter() - .map(|document| { - FieldValue::owned_any(( - Some(DocumentIdScalar::from(document.id())), - None::, - )) - }) - .collect(); + // Check a valid combination of arguments was passed + match (&document_id, &document_view_id) { + (None, None) => return Err(Error::new("Must provide either `id` or `viewId` argument")), + (Some(_), Some(_)) => { + return Err(Error::new("Must only provide `id` or `viewId` argument")) + } + (Some(id), None) => { + debug!("Query to {} received for document {}", schema_id, id); + } + (None, Some(id)) => { + debug!( + "Query to {} received for document at view id {}", + schema_id, id + ); + } + }; - // Pass the list up to the children query fields. - Ok(Some(FieldValue::list(documents))) - }) - }, - ) - .description(format!("Get all {} documents.", schema.name())), - ) + Ok((document_id, document_view_id)) } #[cfg(test)] @@ -343,56 +248,6 @@ mod test { }); } - #[rstest] - fn collection_query(#[from(random_key_pair)] key_pair: KeyPair) { - // Test collection query parameter variations. - test_runner(move |mut node: TestNode| async move { - // Add schema to node. - let schema = add_schema( - &mut node, - "schema_name", - vec![("bool", FieldType::Boolean)], - &key_pair, - ) - .await; - - // Publish document on node. - add_document( - &mut node, - schema.id(), - vec![("bool", true.into())], - &key_pair, - ) - .await; - - // Configure and send test query. - let client = graphql_test_client(&node).await; - let query = format!( - r#"{{ - collection: all_{type_name} {{ - fields {{ bool }} - }}, - }}"#, - type_name = schema.id(), - ); - - let response = client - .post("/graphql") - .json(&json!({ - "query": query, - })) - .send() - .await; - - let response: Response = response.json().await; - - let expected_data = value!({ - "collection": value!([{ "fields": { "bool": true, } }]), - }); - assert_eq!(response.data, expected_data, "{:#?}", response.errors); - }); - } - #[rstest] fn type_name(#[from(random_key_pair)] key_pair: KeyPair) { // Test availability of `__typename` on all objects. diff --git a/aquadoggo/src/graphql/queries/mod.rs b/aquadoggo/src/graphql/queries/mod.rs new file mode 100644 index 000000000..ddf59833a --- /dev/null +++ b/aquadoggo/src/graphql/queries/mod.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod all_documents; +mod document; +mod next_args; + +pub use all_documents::build_all_documents_query; +pub use document::build_document_query; +pub use next_args::build_next_args_query; diff --git a/aquadoggo/src/graphql/schema_builders/next_args.rs b/aquadoggo/src/graphql/queries/next_args.rs similarity index 80% rename from aquadoggo/src/graphql/schema_builders/next_args.rs rename to aquadoggo/src/graphql/queries/next_args.rs index b8db5a0d7..ba0eca976 100644 --- a/aquadoggo/src/graphql/schema_builders/next_args.rs +++ b/aquadoggo/src/graphql/queries/next_args.rs @@ -1,11 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use async_graphql::dynamic::{Field, FieldFuture, InputValue, Object, TypeRef}; +use async_graphql::dynamic::{Field, FieldFuture, InputValue, Object, ResolverContext, TypeRef}; +use async_graphql::Error; use dynamic_graphql::{FieldValue, ScalarValue}; use log::debug; use p2panda_rs::api; -use p2panda_rs::document::DocumentViewId; -use p2panda_rs::identity::PublicKey; use crate::db::SqlStore; use crate::graphql::constants; @@ -20,30 +19,17 @@ pub fn build_next_args_query(query: Object) -> Object { TypeRef::named(constants::NEXT_ARGS), |ctx| { FieldFuture::new(async move { - let mut args = ctx.field().arguments()?.into_iter().map(|(_, value)| value); - let store = ctx.data::()?; - - // Convert and validate passed parameters. - let public_key: PublicKey = - PublicKeyScalar::from_value(args.next().unwrap())?.into(); - let document_view_id: Option = match args.next() { - Some(value) => { - let document_view_id = DocumentViewIdScalar::from_value(value)?.into(); - debug!( - "Query to nextArgs received for public key {} and document at view {}", - public_key, document_view_id - ); - Some(document_view_id) - } - None => { - debug!("Query to nextArgs received for public key {}", public_key); - None - } - }; + // Get and validate arguments. + let (public_key, document_view_id) = validate_args(&ctx)?; + let store = ctx.data_unchecked::(); // Calculate next entry's arguments. - let (backlink, skiplink, seq_num, log_id) = - api::next_args(store, &public_key, document_view_id.as_ref()).await?; + let (backlink, skiplink, seq_num, log_id) = api::next_args( + store, + &public_key.into(), + document_view_id.map(|id| id.into()).as_ref(), + ) + .await?; let next_args = NextArguments { log_id: log_id.into(), @@ -68,6 +54,32 @@ pub fn build_next_args_query(query: Object) -> Object { ) } +/// Validate and return the arguments passed to next_args. +fn validate_args( + ctx: &ResolverContext, +) -> Result<(PublicKeyScalar, Option), Error> { + let mut args = ctx.field().arguments()?.into_iter().map(|(_, value)| value); + + // Convert and validate passed parameters. + let public_key = PublicKeyScalar::from_value(args.next().unwrap())?; + let document_view_id = match args.next() { + Some(value) => { + let document_view_id = DocumentViewIdScalar::from_value(value)?; + debug!( + "Query to nextArgs received for public key {} and document at view {}", + public_key, document_view_id + ); + Some(document_view_id) + } + None => { + debug!("Query to nextArgs received for public key {}", public_key); + None + } + }; + + Ok((public_key, document_view_id)) +} + #[cfg(test)] mod tests { use async_graphql::{value, Response}; diff --git a/aquadoggo/src/graphql/schema.rs b/aquadoggo/src/graphql/schema.rs index 2e3756556..39928603e 100644 --- a/aquadoggo/src/graphql/schema.rs +++ b/aquadoggo/src/graphql/schema.rs @@ -13,16 +13,14 @@ use tokio::sync::Mutex; use crate::bus::ServiceSender; use crate::db::SqlStore; use crate::graphql::mutations::{MutationRoot, Publish}; +use crate::graphql::queries::{ + build_all_documents_query, build_document_query, build_next_args_query, +}; use crate::graphql::scalars::{ DocumentIdScalar, DocumentViewIdScalar, EncodedEntryScalar, EncodedOperationScalar, EntryHashScalar, LogIdScalar, PublicKeyScalar, SeqNumScalar, }; -use crate::graphql::schema_builders::{ - build_all_document_query, build_document_field_schema, build_document_query, - build_document_schema, build_next_args_query, -}; -use crate::graphql::types::{DocumentMeta, NextArguments}; -use crate::graphql::utils::fields_name; +use crate::graphql::types::{Document, DocumentFields, DocumentMeta, NextArguments}; use crate::schema::SchemaProvider; /// Returns GraphQL API schema for p2panda node. @@ -66,17 +64,10 @@ pub async fn build_root_schema( // documents they describe. for schema in all_schema { // Construct the document fields object which will be named `Field`. - let schema_field_name = fields_name(schema.id()); - let mut document_schema_fields = Object::new(&schema_field_name); - - // For every field in the schema we create a type with a resolver. - for (name, field_type) in schema.fields().iter() { - document_schema_fields = - build_document_field_schema(document_schema_fields, name.to_string(), field_type); - } + let document_schema_fields = DocumentFields::build(&schema); // Construct the document schema which has "fields" and "meta" fields. - let document_schema = build_document_schema(&schema); + let document_schema = Document::build(&schema); // Register a schema and schema fields type for every schema. schema_builder = schema_builder @@ -89,7 +80,7 @@ pub async fn build_root_schema( root_query = build_document_query(root_query, &schema); // Add a query for retrieving all documents of a certain schema. - root_query = build_all_document_query(root_query, &schema); + root_query = build_all_documents_query(root_query, &schema); } // Add next args to the query object. diff --git a/aquadoggo/src/graphql/schema_builders/document_field.rs b/aquadoggo/src/graphql/schema_builders/document_field.rs deleted file mode 100644 index 63613ce17..000000000 --- a/aquadoggo/src/graphql/schema_builders/document_field.rs +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use async_graphql::dynamic::{Field, FieldFuture, Object, TypeRef}; -use dynamic_graphql::FieldValue; -use p2panda_rs::document::traits::AsDocument; -use p2panda_rs::operation::OperationValue; -use p2panda_rs::schema::FieldType; - -use crate::db::SqlStore; -use crate::graphql::scalars::{DocumentIdScalar, DocumentViewIdScalar}; -use crate::graphql::utils::{downcast_id_params, get_document_from_params, gql_scalar}; - -/// Get the GraphQL type name for a p2panda field type. -/// -/// GraphQL types for relations use the p2panda schema id as their name. -fn graphql_type(field_type: &FieldType) -> TypeRef { - match field_type { - p2panda_rs::schema::FieldType::Boolean => TypeRef::named(TypeRef::BOOLEAN), - p2panda_rs::schema::FieldType::Integer => TypeRef::named(TypeRef::INT), - p2panda_rs::schema::FieldType::Float => TypeRef::named(TypeRef::FLOAT), - p2panda_rs::schema::FieldType::String => TypeRef::named(TypeRef::STRING), - p2panda_rs::schema::FieldType::Relation(schema_id) => TypeRef::named(schema_id.to_string()), - p2panda_rs::schema::FieldType::RelationList(schema_id) => { - TypeRef::named_list(schema_id.to_string()) - } - p2panda_rs::schema::FieldType::PinnedRelation(schema_id) => { - TypeRef::named(schema_id.to_string()) - } - p2panda_rs::schema::FieldType::PinnedRelationList(schema_id) => { - TypeRef::named_list(schema_id.to_string()) - } - } -} - -/// Build a graphql schema object for each field of a document. -/// -/// Contains a resolver which accesses the actual value of the field from the store when queries -/// are resolved. -pub fn build_document_field_schema( - document_fields: Object, - name: String, - field_type: &FieldType, -) -> Object { - // The type of this field. - let field_type = field_type.clone(); - let graphql_type = graphql_type(&field_type); - - // Define the field and create a resolver. - document_fields.field(Field::new(name.clone(), graphql_type, move |ctx| { - let store = ctx.data_unchecked::(); - let name = name.clone(); - - FieldFuture::new(async move { - // Parse the bubble up message. - let (document_id, document_view_id) = downcast_id_params(&ctx); - - // Get the whole document from the store. - let document = - match get_document_from_params(store, &document_id, &document_view_id).await? { - Some(document) => document, - None => return Ok(FieldValue::NONE), - }; - - // Get the field this query is concerned with. - match document.get(&name).unwrap() { - // Relation fields are expected to resolve to the related document so we pass - // along the document id which will be processed through it's own resolver. - OperationValue::Relation(rel) => Ok(Some(FieldValue::owned_any(( - Some(DocumentIdScalar::from(rel.document_id())), - None::, - )))), - // Relation lists are handled by collecting and returning a list of all document - // id's in the relation list. Each of these in turn are processed and queries - // forwarded up the tree via their own respective resolvers. - OperationValue::RelationList(rel) => { - let mut fields = vec![]; - for document_id in rel.iter() { - fields.push(FieldValue::owned_any(( - Some(DocumentIdScalar::from(document_id)), - None::, - ))); - } - Ok(Some(FieldValue::list(fields))) - } - // Pinned relation behaves the same as relation but passes along a document view id. - OperationValue::PinnedRelation(rel) => Ok(Some(FieldValue::owned_any(( - None::, - Some(DocumentViewIdScalar::from(rel.view_id())), - )))), - // Pinned relation lists behave the same as relation lists but pass along view ids. - OperationValue::PinnedRelationList(rel) => { - let mut fields = vec![]; - for document_view_id in rel.iter() { - fields.push(FieldValue::owned_any(( - None::, - Some(DocumentViewIdScalar::from(document_view_id)), - ))); - } - Ok(Some(FieldValue::list(fields))) - } - // All other fields are simply resolved to their scalar value. - value => Ok(Some(FieldValue::value(gql_scalar(value)))), - } - }) - })) -} diff --git a/aquadoggo/src/graphql/schema_builders/mod.rs b/aquadoggo/src/graphql/schema_builders/mod.rs deleted file mode 100644 index f8c2f73ac..000000000 --- a/aquadoggo/src/graphql/schema_builders/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -mod document; -mod document_field; -mod next_args; -#[cfg(test)] -mod tests; - -pub use document::{build_all_document_query, build_document_query, build_document_schema}; -pub use document_field::build_document_field_schema; -pub use next_args::build_next_args_query; diff --git a/aquadoggo/src/graphql/schema_builders/tests.rs b/aquadoggo/src/graphql/tests.rs similarity index 100% rename from aquadoggo/src/graphql/schema_builders/tests.rs rename to aquadoggo/src/graphql/tests.rs diff --git a/aquadoggo/src/graphql/types/document.rs b/aquadoggo/src/graphql/types/document.rs new file mode 100644 index 000000000..4d4a3e0ba --- /dev/null +++ b/aquadoggo/src/graphql/types/document.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_graphql::dynamic::{Field, FieldFuture, Object, TypeRef}; +use dynamic_graphql::FieldValue; +use p2panda_rs::schema::Schema; + +use crate::graphql::constants; +use crate::graphql::types::DocumentMeta; +use crate::graphql::utils::{downcast_document_id_arguments, fields_name}; + +/// GraphQL object which represents a document type which contains `fields` and `meta` fields. A +/// type is added to the root GraphQL schema for every document, as these types are not known at +/// compile time we make use of the `async-graphql ` `dynamic` module. +/// +/// See `DocumentFields` and `DocumentMeta` to see the shape of the children field types. +pub struct Document; + +impl Document { + /// Build a GraphQL object type from a p2panda schema. + /// + /// Contains resolvers for both `fields` and `meta`. The former simply passes up the query + /// arguments to it's children query fields. The latter calls the `resolve` method defined on + /// `DocumentMeta` type. + pub fn build(schema: &Schema) -> Object { + let document_fields_name = fields_name(schema.id()); + Object::new(schema.id().to_string()) + // The `fields` field of a document, passes up the query arguments to it's children. + .field(Field::new( + constants::FIELDS_FIELD, + TypeRef::named(document_fields_name), + move |ctx| { + FieldFuture::new(async move { + // Here we just pass up the root query parameters to be used in the fields resolver + let params = downcast_document_id_arguments(&ctx); + Ok(Some(FieldValue::owned_any(params))) + }) + }, + )) + // The `meta` field of a document, resolves the `DocumentMeta` object. + .field(Field::new( + constants::META_FIELD, + TypeRef::named(constants::DOCUMENT_META), + move |ctx| FieldFuture::new(async move { DocumentMeta::resolve(ctx).await }), + )) + .description(schema.description().to_string()) + } +} diff --git a/aquadoggo/src/graphql/types/document_fields.rs b/aquadoggo/src/graphql/types/document_fields.rs new file mode 100644 index 000000000..171c24ce6 --- /dev/null +++ b/aquadoggo/src/graphql/types/document_fields.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_graphql::dynamic::{Field, FieldFuture, Object, ResolverContext}; +use async_graphql::Error; +use dynamic_graphql::FieldValue; +use p2panda_rs::document::traits::AsDocument; +use p2panda_rs::operation::OperationValue; +use p2panda_rs::schema::Schema; + +use crate::db::SqlStore; +use crate::graphql::scalars::{DocumentIdScalar, DocumentViewIdScalar}; +use crate::graphql::utils::{ + downcast_document_id_arguments, fields_name, get_document_from_params, gql_scalar, graphql_type, +}; + +/// GraphQL object which represents the fields of a document document type as described by it's +/// p2panda schema. A type is added to the root GraphQL schema for every document, as these types +/// are not known at compile time we make use of the `async-graphql ` `dynamic` module. +pub struct DocumentFields; + +impl DocumentFields { + /// Build the fields of a document from the related p2panda schema. Constructs an object which + /// can then be added to the root GraphQL schema. + pub fn build(schema: &Schema) -> Object { + // Construct the document fields object which will be named `Field`. + let schema_field_name = fields_name(schema.id()); + let mut document_schema_fields = Object::new(&schema_field_name); + + // For every field in the schema we create a type with a resolver. + for (name, field_type) in schema.fields().iter() { + document_schema_fields = document_schema_fields.field(Field::new( + name, + graphql_type(field_type), + move |ctx| FieldFuture::new(async move { Self::resolve(ctx).await }), + )); + } + + document_schema_fields + } + + /// Resolve a document field value as a graphql `FieldValue`. If the value is a relation, then + /// the relevant document id or document view id is determined and passed along the query chain. + /// If the value is a simple type (meaning it is also a query leaf) then it is directly resolved. + /// + /// Requires a `ResolverContext` to be passed into the method. + async fn resolve(ctx: ResolverContext<'_>) -> Result>, Error> { + let store = ctx.data_unchecked::(); + let name = ctx.field().name(); + + // Parse the bubble up message. + let (document_id, document_view_id) = downcast_document_id_arguments(&ctx); + + // Get the whole document from the store. + let document = + match get_document_from_params(store, &document_id, &document_view_id).await? { + Some(document) => document, + None => return Ok(FieldValue::NONE), + }; + + // Get the field this query is concerned with. + match document.get(name).unwrap() { + // Relation fields are expected to resolve to the related document so we pass + // along the document id which will be processed through it's own resolver. + OperationValue::Relation(rel) => Ok(Some(FieldValue::owned_any(( + Some(DocumentIdScalar::from(rel.document_id())), + None::, + )))), + // Relation lists are handled by collecting and returning a list of all document + // id's in the relation list. Each of these in turn are processed and queries + // forwarded up the tree via their own respective resolvers. + OperationValue::RelationList(rel) => { + let mut fields = vec![]; + for document_id in rel.iter() { + fields.push(FieldValue::owned_any(( + Some(DocumentIdScalar::from(document_id)), + None::, + ))); + } + Ok(Some(FieldValue::list(fields))) + } + // Pinned relation behaves the same as relation but passes along a document view id. + OperationValue::PinnedRelation(rel) => Ok(Some(FieldValue::owned_any(( + None::, + Some(DocumentViewIdScalar::from(rel.view_id())), + )))), + // Pinned relation lists behave the same as relation lists but pass along view ids. + OperationValue::PinnedRelationList(rel) => { + let mut fields = vec![]; + for document_view_id in rel.iter() { + fields.push(FieldValue::owned_any(( + None::, + Some(DocumentViewIdScalar::from(document_view_id)), + ))); + } + Ok(Some(FieldValue::list(fields))) + } + // All other fields are simply resolved to their scalar value. + value => Ok(Some(FieldValue::value(gql_scalar(value)))), + } + } +} diff --git a/aquadoggo/src/graphql/types/document_meta.rs b/aquadoggo/src/graphql/types/document_meta.rs index eb230dd5d..f522af507 100644 --- a/aquadoggo/src/graphql/types/document_meta.rs +++ b/aquadoggo/src/graphql/types/document_meta.rs @@ -1,8 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use dynamic_graphql::SimpleObject; +use async_graphql::dynamic::ResolverContext; +use async_graphql::Error; +use dynamic_graphql::{FieldValue, SimpleObject}; +use p2panda_rs::document::traits::AsDocument; +use crate::db::SqlStore; use crate::graphql::scalars::{DocumentIdScalar, DocumentViewIdScalar}; +use crate::graphql::utils::{downcast_document_id_arguments, get_document_from_params}; /// The meta fields of a document. #[derive(SimpleObject)] @@ -13,3 +18,34 @@ pub struct DocumentMeta { #[graphql(name = "viewId")] pub view_id: DocumentViewIdScalar, } + +impl DocumentMeta { + /// Resolve `DocumentMeta` as a graphql `FieldValue`. + /// + /// Requires a `ResolverContext` to be passed into the method. + pub async fn resolve(ctx: ResolverContext<'_>) -> Result>, Error> { + let store = ctx.data_unchecked::(); + + // Downcast the parameters passed up from the parent query field + let (document_id, document_view_id) = downcast_document_id_arguments(&ctx); + + // Get the whole document + let document = get_document_from_params(store, &document_id, &document_view_id).await?; + + // Construct `DocumentMeta` and return it. We defined the document meta + // type and already registered it in the schema. It's derived resolvers + // will handle field selection. + let field_value = match document { + Some(document) => { + let document_meta = Self { + document_id: document.id().into(), + view_id: document.view_id().into(), + }; + Some(FieldValue::owned_any(document_meta)) + } + None => Some(FieldValue::NULL), + }; + + Ok(field_value) + } +} diff --git a/aquadoggo/src/graphql/types/mod.rs b/aquadoggo/src/graphql/types/mod.rs index 23a50c49d..6f3422f04 100644 --- a/aquadoggo/src/graphql/types/mod.rs +++ b/aquadoggo/src/graphql/types/mod.rs @@ -1,7 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +mod document; +mod document_fields; mod document_meta; mod next_arguments; +pub use document::Document; +pub use document_fields::DocumentFields; pub use document_meta::DocumentMeta; pub use next_arguments::NextArguments; diff --git a/aquadoggo/src/graphql/utils.rs b/aquadoggo/src/graphql/utils.rs index 5d9b93fd8..f5bf87362 100644 --- a/aquadoggo/src/graphql/utils.rs +++ b/aquadoggo/src/graphql/utils.rs @@ -1,10 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use async_graphql::dynamic::ResolverContext; +use async_graphql::dynamic::{ResolverContext, TypeRef}; use async_graphql::Value; use p2panda_rs::document::{DocumentId, DocumentViewId}; use p2panda_rs::operation::OperationValue; -use p2panda_rs::schema::SchemaId; +use p2panda_rs::schema::{FieldType, SchemaId}; use p2panda_rs::storage_provider::error::DocumentStorageError; use p2panda_rs::storage_provider::traits::DocumentStore; @@ -31,9 +31,33 @@ pub fn gql_scalar(operation_value: &OperationValue) -> Value { } } +/// Get the GraphQL type name for a p2panda field type. +/// +/// GraphQL types for relations use the p2panda schema id as their name. +pub fn graphql_type(field_type: &FieldType) -> TypeRef { + match field_type { + p2panda_rs::schema::FieldType::Boolean => TypeRef::named(TypeRef::BOOLEAN), + p2panda_rs::schema::FieldType::Integer => TypeRef::named(TypeRef::INT), + p2panda_rs::schema::FieldType::Float => TypeRef::named(TypeRef::FLOAT), + p2panda_rs::schema::FieldType::String => TypeRef::named(TypeRef::STRING), + p2panda_rs::schema::FieldType::Relation(schema_id) => TypeRef::named(schema_id.to_string()), + p2panda_rs::schema::FieldType::RelationList(schema_id) => { + TypeRef::named_list(schema_id.to_string()) + } + p2panda_rs::schema::FieldType::PinnedRelation(schema_id) => { + TypeRef::named(schema_id.to_string()) + } + p2panda_rs::schema::FieldType::PinnedRelationList(schema_id) => { + TypeRef::named_list(schema_id.to_string()) + } + } +} + /// Downcast document id and document view id from parameters passed up the query fields and /// retrieved via the `ResolverContext`. -pub fn downcast_id_params( +/// +/// We unwrap internally here as we expect validation to have occured in the query resolver. +pub fn downcast_document_id_arguments( ctx: &ResolverContext, ) -> (Option, Option) { ctx.parent_value @@ -42,7 +66,7 @@ pub fn downcast_id_params( .to_owned() } -/// Helper for getting a document from score by either the document id or document view id. +/// Helper for getting a document from the store by either the document id or document view id. pub async fn get_document_from_params( store: &SqlStore, document_id: &Option,