diff --git a/v3/Cargo.lock b/v3/Cargo.lock index ddb5e52384b..8d7bae9eea5 100644 --- a/v3/Cargo.lock +++ b/v3/Cargo.lock @@ -1899,6 +1899,7 @@ dependencies = [ "open-dds", "ref-cast", "reqwest", + "schemars", "serde", "serde_json", "similar-asserts", @@ -2522,8 +2523,10 @@ dependencies = [ name = "query-usage-analytics" version = "0.1.0" dependencies = [ + "goldenfile", "metadata-resolve", "open-dds", + "schemars", "serde", "serde_json", ] diff --git a/v3/crates/metadata-resolve/Cargo.toml b/v3/crates/metadata-resolve/Cargo.toml index 2ba4a3f8f50..a1dfb2037ca 100644 --- a/v3/crates/metadata-resolve/Cargo.toml +++ b/v3/crates/metadata-resolve/Cargo.toml @@ -19,6 +19,7 @@ ndc-models = { workspace = true } nonempty = { workspace = true } ref-cast = { workspace = true } reqwest = { workspace = true, features = ["json", "multipart"] } +schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/v3/crates/metadata-resolve/src/stages/models/types.rs b/v3/crates/metadata-resolve/src/stages/models/types.rs index e7d15566933..7024b5e693f 100644 --- a/v3/crates/metadata-resolve/src/stages/models/types.rs +++ b/v3/crates/metadata-resolve/src/stages/models/types.rs @@ -12,6 +12,7 @@ use open_dds::{ models::{ModelGraphQlDefinition, ModelName, OrderableField}, types::{CustomTypeName, FieldName}, }; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -54,6 +55,7 @@ pub struct Model { #[derive( Serialize, Deserialize, + JsonSchema, Clone, Debug, PartialEq, diff --git a/v3/crates/metadata-resolve/src/types/subgraph.rs b/v3/crates/metadata-resolve/src/types/subgraph.rs index e544bb619d7..6c8529b4df1 100644 --- a/v3/crates/metadata-resolve/src/types/subgraph.rs +++ b/v3/crates/metadata-resolve/src/types/subgraph.rs @@ -2,10 +2,13 @@ use std::fmt::Display; use std::{collections::BTreeMap, fmt::Write}; use open_dds::types::{BaseType, CustomTypeName, InbuiltType, TypeName, TypeReference}; +use schemars::JsonSchema; use serde::{de::DeserializeOwned, ser::SerializeMap, Deserialize, Serialize}; use serde_json; -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Hash, Eq, PartialOrd, Ord)] +#[derive( + Serialize, Deserialize, JsonSchema, Clone, Debug, PartialEq, Hash, Eq, PartialOrd, Ord, +)] pub struct Qualified { pub subgraph: String, pub name: T, diff --git a/v3/crates/query-usage-analytics/Cargo.toml b/v3/crates/query-usage-analytics/Cargo.toml index 1fb3848b931..170ae72eeb6 100644 --- a/v3/crates/query-usage-analytics/Cargo.toml +++ b/v3/crates/query-usage-analytics/Cargo.toml @@ -10,9 +10,15 @@ bench = false [dependencies] open-dds = { path = "../open-dds" } metadata-resolve = { path = "../metadata-resolve" } +schemars = { version = "0.8.20", features = ["preserve_order"] } serde = { workspace = true } serde_json = { workspace = true } + +[dev-dependencies] +goldenfile = "^1.7.1" +schemars = { version = "0.8.20", features = ["preserve_order"] } + [lints] workspace = true diff --git a/v3/crates/query-usage-analytics/query_usage_analytics.jsonschema b/v3/crates/query-usage-analytics/query_usage_analytics.jsonschema new file mode 100644 index 00000000000..99fbb8b2b6e --- /dev/null +++ b/v3/crates/query-usage-analytics/query_usage_analytics.jsonschema @@ -0,0 +1,517 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GqlOperation", + "description": "This is the data to emit (serlialized) for analytics, when a GraphQL operation is executed.", + "oneOf": [ + { + "type": "object", + "required": [ + "query" + ], + "properties": { + "query": { + "type": "object", + "required": [ + "fields", + "operation_name" + ], + "properties": { + "operation_name": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/GqlField" + } + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "mutation" + ], + "properties": { + "mutation": { + "type": "object", + "required": [ + "fields", + "operation_name" + ], + "properties": { + "operation_name": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/GqlField" + } + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "subscription" + ], + "properties": { + "subscription": { + "type": "object", + "required": [ + "fields", + "operation_name" + ], + "properties": { + "operation_name": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/GqlField" + } + } + } + } + }, + "additionalProperties": false + } + ], + "definitions": { + "GqlField": { + "description": "A GraphQL field appearing in the query", + "type": "object", + "required": [ + "alias", + "arguments", + "fields", + "name", + "used" + ], + "properties": { + "name": { + "description": "Name of the GraphQL field", + "type": "string" + }, + "alias": { + "description": "Alias of this field used in the query", + "type": "string" + }, + "arguments": { + "description": "Arguments of this field", + "type": "array", + "items": { + "$ref": "#/definitions/GqlInputField" + } + }, + "fields": { + "description": "Fields in its selection set", + "type": "array", + "items": { + "$ref": "#/definitions/GqlField" + } + }, + "used": { + "description": "Which OpenDD objects it is using", + "type": "array", + "items": { + "$ref": "#/definitions/OpenddObject" + } + } + } + }, + "GqlInputField": { + "description": "A GraphQL input field", + "type": "object", + "required": [ + "fields", + "name", + "used" + ], + "properties": { + "name": { + "description": "Name of the input field", + "type": "string" + }, + "fields": { + "description": "Fields of this input field", + "type": "array", + "items": { + "$ref": "#/definitions/GqlInputField" + } + }, + "used": { + "description": "Which OpenDD objects it is using", + "type": "array", + "items": { + "$ref": "#/definitions/OpenddObject" + } + } + } + }, + "OpenddObject": { + "description": "All kinds of OpenDD objects that could be used in a GraphQL operation", + "oneOf": [ + { + "type": "object", + "required": [ + "model" + ], + "properties": { + "model": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "$ref": "#/definitions/Qualified_for_ModelName" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "$ref": "#/definitions/Qualified_for_CommandName" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "field" + ], + "properties": { + "field": { + "$ref": "#/definitions/FieldUsage" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "$ref": "#/definitions/PermissionUsage" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "relationship" + ], + "properties": { + "relationship": { + "$ref": "#/definitions/RelationshipUsage" + } + }, + "additionalProperties": false + } + ] + }, + "Qualified_for_ModelName": { + "type": "object", + "required": [ + "name", + "subgraph" + ], + "properties": { + "subgraph": { + "type": "string" + }, + "name": { + "$ref": "#/definitions/ModelName" + } + } + }, + "ModelName": { + "$id": "https://hasura.io/jsonschemas/metadata/ModelName", + "title": "ModelName", + "description": "The name of data model.", + "type": "string", + "pattern": "^[_a-zA-Z][_a-zA-Z0-9]*$" + }, + "Qualified_for_CommandName": { + "type": "object", + "required": [ + "name", + "subgraph" + ], + "properties": { + "subgraph": { + "type": "string" + }, + "name": { + "$ref": "#/definitions/CommandName" + } + } + }, + "CommandName": { + "$id": "https://hasura.io/jsonschemas/metadata/CommandName", + "title": "CommandName", + "description": "The name of a command.", + "type": "string", + "pattern": "^[_a-zA-Z][_a-zA-Z0-9]*$" + }, + "FieldUsage": { + "type": "object", + "required": [ + "name", + "opendd_type" + ], + "properties": { + "name": { + "$ref": "#/definitions/FieldName" + }, + "opendd_type": { + "$ref": "#/definitions/Qualified_for_CustomTypeName" + } + } + }, + "FieldName": { + "$id": "https://hasura.io/jsonschemas/metadata/FieldName", + "title": "FieldName", + "description": "The name of a field in a user-defined object type.", + "type": "string", + "pattern": "^[_a-zA-Z][_a-zA-Z0-9]*$" + }, + "Qualified_for_CustomTypeName": { + "type": "object", + "required": [ + "name", + "subgraph" + ], + "properties": { + "subgraph": { + "type": "string" + }, + "name": { + "$ref": "#/definitions/CustomTypeName" + } + } + }, + "CustomTypeName": { + "$id": "https://hasura.io/jsonschemas/metadata/CustomTypeName", + "title": "CustomTypeName", + "description": "The name of a user-defined type.", + "type": "string", + "pattern": "^[_a-zA-Z][_a-zA-Z0-9]*$" + }, + "PermissionUsage": { + "oneOf": [ + { + "type": "object", + "required": [ + "field_presets" + ], + "properties": { + "field_presets": { + "$ref": "#/definitions/FieldPresetsUsage" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "filter_predicate" + ], + "properties": { + "filter_predicate": { + "$ref": "#/definitions/FilterPredicateUsage" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "argument_presets" + ], + "properties": { + "argument_presets": { + "$ref": "#/definitions/ArgumentPresetsUsage" + } + }, + "additionalProperties": false + } + ] + }, + "FieldPresetsUsage": { + "type": "object", + "required": [ + "fields" + ], + "properties": { + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/FieldUsage" + } + } + } + }, + "FilterPredicateUsage": { + "type": "object", + "required": [ + "fields", + "relationships" + ], + "properties": { + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/FieldUsage" + } + }, + "relationships": { + "type": "array", + "items": { + "$ref": "#/definitions/RelationshipUsage" + } + } + } + }, + "RelationshipUsage": { + "type": "object", + "required": [ + "name", + "source", + "target" + ], + "properties": { + "name": { + "$ref": "#/definitions/RelationshipName" + }, + "source": { + "$ref": "#/definitions/Qualified_for_CustomTypeName" + }, + "target": { + "$ref": "#/definitions/RelationshipTarget" + } + } + }, + "RelationshipName": { + "$id": "https://hasura.io/jsonschemas/metadata/RelationshipName", + "title": "RelationshipName", + "description": "The name of the GraphQL relationship field.", + "type": "string", + "pattern": "^[_a-zA-Z][_a-zA-Z0-9]*$" + }, + "RelationshipTarget": { + "oneOf": [ + { + "type": "object", + "required": [ + "model" + ], + "properties": { + "model": { + "type": "object", + "required": [ + "model_name", + "relationship_type" + ], + "properties": { + "model_name": { + "$ref": "#/definitions/Qualified_for_ModelName" + }, + "relationship_type": { + "$ref": "#/definitions/RelationshipType" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "object", + "required": [ + "command_name" + ], + "properties": { + "command_name": { + "$ref": "#/definitions/Qualified_for_CommandName" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "RelationshipType": { + "title": "RelationshipType", + "description": "Type of the relationship.", + "oneOf": [ + { + "description": "Select one related object from the target.", + "type": "string", + "enum": [ + "Object" + ] + }, + { + "description": "Select multiple related objects from the target.", + "type": "string", + "enum": [ + "Array" + ] + } + ] + }, + "ArgumentPresetsUsage": { + "type": "object", + "required": [ + "arguments" + ], + "properties": { + "arguments": { + "type": "array", + "items": { + "$ref": "#/definitions/ConnectorArgumentName" + } + } + } + }, + "ConnectorArgumentName": { + "description": "The name of an argument as defined by a data connector", + "type": "string" + } + } +} \ No newline at end of file diff --git a/v3/crates/query-usage-analytics/src/lib.rs b/v3/crates/query-usage-analytics/src/lib.rs index b187d9c0d18..3383eb930ac 100644 --- a/v3/crates/query-usage-analytics/src/lib.rs +++ b/v3/crates/query-usage-analytics/src/lib.rs @@ -1,17 +1,18 @@ //! Usage analytics, like model, command, field usage analytics, from a GraphQL query -use metadata_resolve::Qualified; +use metadata_resolve::{ConnectorArgumentName, Qualified}; use open_dds::{ commands::CommandName, models::ModelName, relationships::{RelationshipName, RelationshipType}, types::{CustomTypeName, FieldName}, }; +use schemars::JsonSchema; use serde::Serialize; /// This is the data to emit (serlialized) for analytics, when a GraphQL /// operation is executed. -#[derive(Serialize)] +#[derive(Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum GqlOperation { Query { @@ -22,71 +23,87 @@ pub enum GqlOperation { operation_name: String, fields: Vec, }, + Subscription { + operation_name: String, + fields: Vec, + }, } /// A GraphQL field appearing in the query -#[derive(Serialize)] +#[derive(Serialize, JsonSchema)] pub struct GqlField { /// Name of the GraphQL field pub name: String, /// Alias of this field used in the query pub alias: String, /// Arguments of this field - pub arguments: Vec, + pub arguments: Vec, /// Fields in its selection set pub fields: Vec, - /// Which OpenDD object it is using - pub used: OpenddObject, + /// Which OpenDD objects it is using + pub used: Vec, } -#[derive(Serialize)] +#[derive(Serialize, JsonSchema)] /// A GraphQL input field pub struct GqlInputField { /// Name of the input field pub name: String, /// Fields of this input field pub fields: Vec, - /// Which OpenDD object it is using - pub used: Option, -} - -/// Arguments of a GraphQL field -#[derive(Serialize)] -pub struct GqlFieldArgument { - pub name: String, - pub fields: Vec, + /// Which OpenDD objects it is using + pub used: Vec, } /// All kinds of OpenDD objects that could be used in a GraphQL operation -#[derive(Serialize, Clone)] +#[derive(Serialize, JsonSchema, Clone)] #[serde(rename_all = "snake_case")] pub enum OpenddObject { Model { name: Qualified }, Command { name: Qualified }, Field(FieldUsage), - Permission(PermissionsUsage), + Permission(PermissionUsage), Relationship(RelationshipUsage), } -#[derive(Serialize, Clone)] +#[derive(Serialize, JsonSchema, Clone)] pub struct FieldUsage { pub name: FieldName, pub opendd_type: Qualified, } -#[derive(Serialize, Clone)] -pub struct PermissionsUsage { +#[derive(Serialize, JsonSchema, Clone)] +#[serde(rename_all = "snake_case")] +pub enum PermissionUsage { + FieldPresets(FieldPresetsUsage), + FilterPredicate(FilterPredicateUsage), + ArgumentPresets(ArgumentPresetsUsage), +} + +#[derive(Serialize, JsonSchema, Clone)] +pub struct FieldPresetsUsage { pub fields: Vec, } -#[derive(Serialize, Clone)] +#[derive(Serialize, JsonSchema, Clone)] +pub struct FilterPredicateUsage { + pub fields: Vec, + pub relationships: Vec, +} + +#[derive(Serialize, JsonSchema, Clone)] +pub struct ArgumentPresetsUsage { + pub arguments: Vec, +} + +#[derive(Serialize, JsonSchema, Clone)] pub struct RelationshipUsage { - pub name: Qualified, + pub name: RelationshipName, pub source: Qualified, pub target: RelationshipTarget, } -#[derive(Serialize, Clone)] +#[derive(Serialize, JsonSchema, Clone)] #[serde(rename_all = "snake_case")] pub enum RelationshipTarget { Model { @@ -95,15 +112,32 @@ pub enum RelationshipTarget { }, Command { command_name: Qualified, - relationship_type: RelationshipType, }, } #[cfg(test)] mod tests { use super::*; + use goldenfile::Mint; use open_dds::identifier; use open_dds::relationships::RelationshipType; + use schemars::schema_for; + use std::{io::Write, path::PathBuf}; + + #[test] + fn test_json_schema() { + let mut mint = Mint::new(PathBuf::from(env!("CARGO_MANIFEST_DIR"))); + let mut expected = mint + .new_goldenfile("query_usage_analytics.jsonschema") + .unwrap(); + let schema = schema_for!(super::GqlOperation); + write!( + expected, + "{}", + serde_json::to_string_pretty(&schema).unwrap() + ) + .unwrap(); + } #[test] // just a dummy serialize test for now to visualize the output @@ -128,7 +162,7 @@ mod tests { } */ let product_relationship = OpenddObject::Relationship(RelationshipUsage { - name: Qualified::new("app".to_string(), RelationshipName(identifier!("product"))), + name: RelationshipName(identifier!("product")), source: Qualified::new("app".to_string(), CustomTypeName(identifier!("Order"))), target: RelationshipTarget::Model { model_name: Qualified::new("app".to_string(), ModelName(identifier!("Products"))), @@ -139,16 +173,16 @@ mod tests { // id: {_eq: 5} let product_id_filter = GqlInputField { name: "id".to_string(), - used: Some(OpenddObject::Field(FieldUsage { + used: vec![OpenddObject::Field(FieldUsage { name: FieldName(identifier!("id")), opendd_type: Qualified::new( "app".to_string(), CustomTypeName(identifier!("Order")), ), - })), + })], fields: vec![GqlInputField { name: "_eq".to_string(), - used: None, + used: vec![], fields: vec![], }], }; @@ -157,82 +191,86 @@ mod tests { name: "products".to_string(), fields: vec![GqlInputField { name: "price".to_string(), - used: Some(OpenddObject::Field(FieldUsage { + used: vec![OpenddObject::Field(FieldUsage { name: FieldName(identifier!("price")), opendd_type: Qualified::new( "app".to_string(), CustomTypeName(identifier!("Product")), ), - })), + })], fields: vec![GqlInputField { name: "_gt".to_string(), - used: None, + used: vec![], fields: vec![], }], }], - used: Some(product_relationship.clone()), + used: vec![product_relationship.clone()], }; // where: {id: {_eq: 5}, products: {price: {_gt: 100}}} - let where_argument = GqlFieldArgument { + let where_argument = GqlInputField { name: "where".to_string(), fields: vec![product_id_filter, products_price_filter], + used: vec![], }; // order_by: {product: {price: asc}} - let order_by_argument = GqlFieldArgument { + let order_by_argument = GqlInputField { name: "order_by".to_string(), fields: vec![GqlInputField { name: "product".to_string(), fields: vec![GqlInputField { name: "price".to_string(), fields: vec![], - used: Some(OpenddObject::Field(FieldUsage { + used: vec![OpenddObject::Field(FieldUsage { name: FieldName(identifier!("price")), opendd_type: Qualified::new( "app".to_string(), CustomTypeName(identifier!("Product")), ), - })), + })], }], - used: Some(product_relationship.clone()), + used: vec![product_relationship.clone()], }], + used: vec![], }; // quantity: {_gt: 2} let products_quantity_filter = GqlInputField { name: "quantity".to_string(), - used: Some(OpenddObject::Field(FieldUsage { + used: vec![OpenddObject::Field(FieldUsage { name: FieldName(identifier!("quantity")), opendd_type: Qualified::new( "app".to_string(), CustomTypeName(identifier!("Product")), ), - })), + })], fields: vec![GqlInputField { name: "_gt".to_string(), - used: None, + used: vec![], fields: vec![], }], }; // where: {quantity: {_gt: 2}} - let products_where_argument = GqlFieldArgument { + let products_where_argument = GqlInputField { name: "where".to_string(), fields: vec![products_quantity_filter], + used: vec![], }; // order_by: {quantity: desc} - let products_order_by_argument = GqlFieldArgument { + let products_order_by_argument = GqlInputField { name: "order_by".to_string(), fields: vec![GqlInputField { name: "quantity".to_string(), fields: vec![], - used: Some(OpenddObject::Field(FieldUsage { + used: vec![OpenddObject::Field(FieldUsage { name: FieldName(identifier!("quantity")), opendd_type: Qualified::new( "app".to_string(), CustomTypeName(identifier!("Product")), ), - })), + })], }], + used: vec![], }; let operation = GqlOperation::Query { @@ -241,20 +279,34 @@ mod tests { name: "app_orders".to_string(), alias: "orders".to_string(), arguments: vec![where_argument, order_by_argument], - used: OpenddObject::Model { - name: Qualified::new("app".to_string(), ModelName(identifier!("Orders"))), - }, + used: vec![ + OpenddObject::Model { + name: Qualified::new("app".to_string(), ModelName(identifier!("Orders"))), + }, + OpenddObject::Permission(PermissionUsage::FilterPredicate( + FilterPredicateUsage { + fields: vec![FieldUsage { + name: FieldName(identifier!("id")), + opendd_type: Qualified::new( + "app".to_string(), + CustomTypeName(identifier!("Order")), + ), + }], + relationships: vec![], + }, + )), + ], fields: vec![ GqlField { name: "date".to_string(), alias: "date".to_string(), - used: OpenddObject::Field(FieldUsage { + used: vec![OpenddObject::Field(FieldUsage { name: FieldName(identifier!("date")), opendd_type: Qualified::new( "app".to_string(), CustomTypeName(identifier!("Order")), ), - }), + })], fields: vec![], arguments: vec![], }, @@ -268,51 +320,51 @@ mod tests { alias: "address_line_1".to_string(), arguments: vec![], fields: vec![], - used: OpenddObject::Field(FieldUsage { + used: vec![OpenddObject::Field(FieldUsage { name: FieldName(identifier!("address_line_1")), opendd_type: Qualified::new( "app".to_string(), CustomTypeName(identifier!("Address")), ), - }), + })], }, GqlField { name: "address_line_2".to_string(), alias: "address_line_2".to_string(), arguments: vec![], fields: vec![], - used: OpenddObject::Field(FieldUsage { + used: vec![OpenddObject::Field(FieldUsage { name: FieldName(identifier!("address_line_2")), opendd_type: Qualified::new( "app".to_string(), CustomTypeName(identifier!("Address")), ), - }), + })], }, ], - used: OpenddObject::Field(FieldUsage { + used: vec![OpenddObject::Field(FieldUsage { name: FieldName(identifier!("address")), opendd_type: Qualified::new( "app".to_string(), CustomTypeName(identifier!("Order")), ), - }), + })], }, GqlField { name: "product".to_string(), alias: "product".to_string(), arguments: vec![products_where_argument, products_order_by_argument], - used: product_relationship, + used: vec![product_relationship], fields: vec![GqlField { name: "name".to_string(), alias: "name".to_string(), - used: OpenddObject::Field(FieldUsage { + used: vec![OpenddObject::Field(FieldUsage { name: FieldName(identifier!("name")), opendd_type: Qualified::new( "app".to_string(), CustomTypeName(identifier!("Product")), ), - }), + })], arguments: vec![], fields: vec![], }],