diff --git a/integration_tests/codegen_fail/fail/scalar/derive_invalid_url.rs b/integration_tests/codegen_fail/fail/scalar/derive_invalid_url.rs new file mode 100644 index 00000000..50549f11 --- /dev/null +++ b/integration_tests/codegen_fail/fail/scalar/derive_invalid_url.rs @@ -0,0 +1,7 @@ +use juniper::GraphQLScalarValue; + +#[derive(GraphQLScalarValue)] +#[graphql(specified_by_url = "not an url")] +struct ScalarSpecifiedByUrl(i64); + +fn main() {} diff --git a/integration_tests/codegen_fail/fail/scalar/derive_invalid_url.stderr b/integration_tests/codegen_fail/fail/scalar/derive_invalid_url.stderr new file mode 100644 index 00000000..9a0d5afd --- /dev/null +++ b/integration_tests/codegen_fail/fail/scalar/derive_invalid_url.stderr @@ -0,0 +1,5 @@ +error: Invalid URL: relative URL without a base + --> fail/scalar/derive_invalid_url.rs:4:30 + | +4 | #[graphql(specified_by_url = "not an url")] + | ^^^^^^^^^^^^ diff --git a/integration_tests/codegen_fail/fail/scalar/impl_invalid_url.rs b/integration_tests/codegen_fail/fail/scalar/impl_invalid_url.rs new file mode 100644 index 00000000..a71da71b --- /dev/null +++ b/integration_tests/codegen_fail/fail/scalar/impl_invalid_url.rs @@ -0,0 +1,22 @@ +use juniper::graphql_scalar; + +struct ScalarSpecifiedByUrl(i32); + +#[graphql_scalar(specified_by_url = "not an url")] +impl GraphQLScalar for ScalarSpecifiedByUrl { + fn resolve(&self) -> Value { + Value::scalar(self.0) + } + + fn from_input_value(v: &InputValue) -> Result { + v.as_int_value() + .map(ScalarSpecifiedByUrl) + .ok_or_else(|| format!("Expected `Int`, found: {}", v)) + } + + fn from_str<'a>(value: ScalarToken<'a>) -> ParseScalarResult<'a, DefaultScalarValue> { + ::from_str(value) + } +} + +fn main() {} diff --git a/integration_tests/codegen_fail/fail/scalar/impl_invalid_url.stderr b/integration_tests/codegen_fail/fail/scalar/impl_invalid_url.stderr new file mode 100644 index 00000000..bb2aeaee --- /dev/null +++ b/integration_tests/codegen_fail/fail/scalar/impl_invalid_url.stderr @@ -0,0 +1,5 @@ +error: Invalid URL: relative URL without a base + --> fail/scalar/impl_invalid_url.rs:5:22 + | +5 | #[graphql_scalar(specified_by_url = "not an url")] + | ^^^^^^^^^^^^^^^^ diff --git a/integration_tests/juniper_tests/src/codegen/derive_scalar.rs b/integration_tests/juniper_tests/src/codegen/derive_scalar.rs index 8010cbea..24e329bc 100644 --- a/integration_tests/juniper_tests/src/codegen/derive_scalar.rs +++ b/integration_tests/juniper_tests/src/codegen/derive_scalar.rs @@ -6,7 +6,11 @@ use juniper::{ use crate::custom_scalar::MyScalarValue; #[derive(Debug, PartialEq, Eq, Hash, juniper::GraphQLScalarValue)] -#[graphql(transparent, scalar = MyScalarValue)] +#[graphql( + transparent, + scalar = MyScalarValue, + specified_by_url = "https://tools.ietf.org/html/rfc4122", +)] pub struct LargeId(i64); #[derive(juniper::GraphQLObject)] @@ -49,6 +53,29 @@ fn test_scalar_value_large_id() { assert_eq!(output, InputValue::scalar(num)); } +#[tokio::test] +async fn test_scalar_value_large_specified_url() { + let schema = RootNode::<'_, _, _, _, MyScalarValue>::new_with_scalar_value( + Query, + EmptyMutation::<()>::new(), + EmptySubscription::<()>::new(), + ); + + let doc = r#"{ + __type(name: "LargeId") { + specifiedByUrl + } + }"#; + + assert_eq!( + execute(doc, None, &schema, &Variables::::new(), &()).await, + Ok(( + graphql_value!({"__type": {"specifiedByUrl": "https://tools.ietf.org/html/rfc4122"}}), + vec![], + )), + ); +} + #[tokio::test] async fn test_scalar_value_large_query() { let schema = RootNode::<'_, _, _, _, MyScalarValue>::new_with_scalar_value( diff --git a/integration_tests/juniper_tests/src/codegen/impl_scalar.rs b/integration_tests/juniper_tests/src/codegen/impl_scalar.rs index 1a53b889..b852416e 100644 --- a/integration_tests/juniper_tests/src/codegen/impl_scalar.rs +++ b/integration_tests/juniper_tests/src/codegen/impl_scalar.rs @@ -9,6 +9,7 @@ struct DefaultName(i32); struct OtherOrder(i32); struct Named(i32); struct ScalarDescription(i32); +struct ScalarSpecifiedByUrl(i32); struct Generated(String); struct Root; @@ -93,6 +94,23 @@ impl GraphQLScalar for ScalarDescription { } } +#[graphql_scalar(specified_by_url = "https://tools.ietf.org/html/rfc4122")] +impl GraphQLScalar for ScalarSpecifiedByUrl { + fn resolve(&self) -> Value { + Value::scalar(self.0) + } + + fn from_input_value(v: &InputValue) -> Result { + v.as_int_value() + .map(ScalarSpecifiedByUrl) + .ok_or_else(|| format!("Expected `Int`, found: {}", v)) + } + + fn from_str<'a>(value: ScalarToken<'a>) -> ParseScalarResult<'a, DefaultScalarValue> { + ::from_str(value) + } +} + macro_rules! impl_scalar { ($name: ident) => { #[graphql_scalar] @@ -134,6 +152,9 @@ impl Root { fn scalar_description() -> ScalarDescription { ScalarDescription(0) } + fn scalar_specified_by_url() -> ScalarSpecifiedByUrl { + ScalarSpecifiedByUrl(0) + } fn generated() -> Generated { Generated("foo".to_owned()) } @@ -297,6 +318,7 @@ async fn scalar_description_introspection() { __type(name: "ScalarDescription") { name description + specifiedByUrl } } "#; @@ -312,6 +334,32 @@ async fn scalar_description_introspection() { "A sample scalar, represented as an integer", )), ); + assert_eq!( + type_info.get_field_value("specifiedByUrl"), + Some(&graphql_value!(null)), + ); + }) + .await; +} + +#[tokio::test] +async fn scalar_specified_by_url_introspection() { + let doc = r#"{ + __type(name: "ScalarSpecifiedByUrl") { + name + specifiedByUrl + } + }"#; + + run_type_info_query(doc, |type_info| { + assert_eq!( + type_info.get_field_value("name"), + Some(&graphql_value!("ScalarSpecifiedByUrl")), + ); + assert_eq!( + type_info.get_field_value("specifiedByUrl"), + Some(&graphql_value!("https://tools.ietf.org/html/rfc4122")), + ); }) .await; } diff --git a/integration_tests/juniper_tests/src/codegen/scalar_value_transparent.rs b/integration_tests/juniper_tests/src/codegen/scalar_value_transparent.rs index f2a45af1..828f12d3 100644 --- a/integration_tests/juniper_tests/src/codegen/scalar_value_transparent.rs +++ b/integration_tests/juniper_tests/src/codegen/scalar_value_transparent.rs @@ -14,7 +14,7 @@ struct CustomUserId(String); /// The doc comment... #[derive(GraphQLScalarValue, Debug, Eq, PartialEq)] -#[graphql(transparent)] +#[graphql(transparent, specified_by_url = "https://tools.ietf.org/html/rfc4122")] struct IdWithDocComment(i32); #[derive(GraphQLObject)] @@ -64,6 +64,7 @@ fn test_scalar_value_custom() { let meta = CustomUserId::meta(&(), &mut registry); assert_eq!(meta.name(), Some("MyUserId")); assert_eq!(meta.description(), Some("custom description...")); + assert_eq!(meta.specified_by_url(), None); let input: InputValue = serde_json::from_value(serde_json::json!("userId1")).unwrap(); let output: CustomUserId = FromInputValue::from_input_value(&input).unwrap(); @@ -79,4 +80,8 @@ fn test_scalar_value_doc_comment() { let mut registry: Registry = Registry::new(FnvHashMap::default()); let meta = IdWithDocComment::meta(&(), &mut registry); assert_eq!(meta.description(), Some("The doc comment...")); + assert_eq!( + meta.specified_by_url(), + Some("https://tools.ietf.org/html/rfc4122"), + ); } diff --git a/juniper/CHANGELOG.md b/juniper/CHANGELOG.md index 8736f53f..3ab147d6 100644 --- a/juniper/CHANGELOG.md +++ b/juniper/CHANGELOG.md @@ -20,6 +20,9 @@ - Add `From` impls to `InputValue` mirroring the ones for `Value` and provide better support for `Option` handling. ([#996](https://github.com/graphql-rust/juniper/pull/996)) - Implement `graphql_input_value!` and `graphql_vars!` macros. ([#996](https://github.com/graphql-rust/juniper/pull/996)) - Support [`time` crate](https://docs.rs/time) types as GraphQL scalars behind `time` feature. ([#1006](https://github.com/graphql-rust/juniper/pull/1006)) +- Add `specified_by_url` attribute argument to `#[derive(GraphQLScalarValue)]` and `#[graphql_scalar]` macros. ([#1003](https://github.com/graphql-rust/juniper/pull/1003), [#1000](https://github.com/graphql-rust/juniper/pull/1000)) +- Support `isRepeatable` field on directives. ([#1003](https://github.com/graphql-rust/juniper/pull/1003), [#1000](https://github.com/graphql-rust/juniper/pull/1000)) +- Support `__Schema.description`, `__Type.specifiedByURL` and `__Directive.isRepeatable` fields in introspection. ([#1003](https://github.com/graphql-rust/juniper/pull/1003), [#1000](https://github.com/graphql-rust/juniper/pull/1000)) ## Fixes diff --git a/juniper/src/executor_tests/introspection/mod.rs b/juniper/src/executor_tests/introspection/mod.rs index 07e7d668..a14a0f29 100644 --- a/juniper/src/executor_tests/introspection/mod.rs +++ b/juniper/src/executor_tests/introspection/mod.rs @@ -492,6 +492,7 @@ async fn scalar_introspection() { name kind description + specifiedByUrl fields { name } interfaces { name } possibleTypes { name } @@ -527,6 +528,7 @@ async fn scalar_introspection() { "name": "SampleScalar", "kind": "SCALAR", "description": null, + "specifiedByUrl": null, "fields": null, "interfaces": null, "possibleTypes": null, diff --git a/juniper/src/introspection/mod.rs b/juniper/src/introspection/mod.rs index 39011324..aedd4ef9 100644 --- a/juniper/src/introspection/mod.rs +++ b/juniper/src/introspection/mod.rs @@ -1,10 +1,10 @@ -/// From +/// From pub(crate) const INTROSPECTION_QUERY: &str = include_str!("./query.graphql"); pub(crate) const INTROSPECTION_QUERY_WITHOUT_DESCRIPTIONS: &str = include_str!("./query_without_descriptions.graphql"); /// The desired GraphQL introspection format for the canonical query -/// () +/// () pub enum IntrospectionFormat { /// The canonical GraphQL introspection query. All, diff --git a/juniper/src/introspection/query.graphql b/juniper/src/introspection/query.graphql index 38ff1797..fe522f4e 100644 --- a/juniper/src/introspection/query.graphql +++ b/juniper/src/introspection/query.graphql @@ -1,5 +1,6 @@ query IntrospectionQuery { __schema { + description queryType { name } @@ -15,6 +16,7 @@ query IntrospectionQuery { directives { name description + isRepeatable locations args { ...InputValue @@ -26,6 +28,7 @@ fragment FullType on __Type { kind name description + specifiedByUrl fields(includeDeprecated: true) { name description diff --git a/juniper/src/introspection/query_without_descriptions.graphql b/juniper/src/introspection/query_without_descriptions.graphql index 57aa6368..0699bc5b 100644 --- a/juniper/src/introspection/query_without_descriptions.graphql +++ b/juniper/src/introspection/query_without_descriptions.graphql @@ -14,6 +14,7 @@ query IntrospectionQuery { } directives { name + isRepeatable locations args { ...InputValue @@ -24,6 +25,7 @@ query IntrospectionQuery { fragment FullType on __Type { kind name + specifiedByUrl fields(includeDeprecated: true) { name args { diff --git a/juniper/src/schema/meta.rs b/juniper/src/schema/meta.rs index fdb6e50b..d4f35084 100644 --- a/juniper/src/schema/meta.rs +++ b/juniper/src/schema/meta.rs @@ -48,6 +48,8 @@ pub struct ScalarMeta<'a, S> { pub name: Cow<'a, str>, #[doc(hidden)] pub description: Option, + #[doc(hidden)] + pub specified_by_url: Option>, pub(crate) try_parse_fn: for<'b> fn(&'b InputValue) -> Result<(), FieldError>, pub(crate) parse_fn: for<'b> fn(ScalarToken<'b>) -> Result>, } @@ -250,9 +252,24 @@ impl<'a, S> MetaType<'a, S> { } } + /// Accesses the [specification URL][0], if applicable. + /// + /// Only custom GraphQL scalars can have a [specification URL][0]. + /// + /// [0]: https://spec.graphql.org/October2021#sec--specifiedBy + pub fn specified_by_url(&self) -> Option<&str> { + match self { + Self::Scalar(ScalarMeta { + specified_by_url, .. + }) => specified_by_url.as_deref(), + _ => None, + } + } + /// Construct a `TypeKind` for a given type /// /// # Panics + /// /// Panics if the type represents a placeholder or nullable type. pub fn type_kind(&self) -> TypeKind { match *self { @@ -421,6 +438,7 @@ impl<'a, S> ScalarMeta<'a, S> { Self { name, description: None, + specified_by_url: None, try_parse_fn: try_parse_fn::, parse_fn: >::from_str, } @@ -434,6 +452,16 @@ impl<'a, S> ScalarMeta<'a, S> { self } + /// Sets the [specification URL][0] for this [`ScalarMeta`] type. + /// + /// Overwrites any previously set [specification URL][0]. + /// + /// [0]: https://spec.graphql.org/October2021#sec--specifiedBy + pub fn specified_by_url(mut self, url: impl Into>) -> Self { + self.specified_by_url = Some(url.into()); + self + } + /// Wraps this [`ScalarMeta`] type into a generic [`MetaType`]. pub fn into_meta(self) -> MetaType<'a, S> { MetaType::Scalar(self) diff --git a/juniper/src/schema/model.rs b/juniper/src/schema/model.rs index 953904cb..cc48f592 100644 --- a/juniper/src/schema/model.rs +++ b/juniper/src/schema/model.rs @@ -1,4 +1,4 @@ -use std::fmt; +use std::{borrow::Cow, fmt}; use fnv::FnvHashMap; #[cfg(feature = "graphql-parser-integration")] @@ -49,6 +49,7 @@ pub struct RootNode< /// Metadata for a schema #[derive(Debug)] pub struct SchemaType<'a, S> { + pub(crate) description: Option>, pub(crate) types: FnvHashMap>, pub(crate) query_type_name: String, pub(crate) mutation_type_name: Option, @@ -71,6 +72,7 @@ pub struct DirectiveType<'a, S> { pub description: Option, pub locations: Vec, pub arguments: Vec>, + pub is_repeatable: bool, } #[derive(Clone, PartialEq, Eq, Debug, GraphQLEnum)] @@ -80,6 +82,7 @@ pub enum DirectiveLocation { Mutation, Subscription, Field, + Scalar, #[graphql(name = "FRAGMENT_DEFINITION")] FragmentDefinition, #[graphql(name = "FRAGMENT_SPREAD")] @@ -211,6 +214,10 @@ impl<'a, S> SchemaType<'a, S> { "include".to_owned(), DirectiveType::new_include(&mut registry), ); + directives.insert( + "specifiedBy".to_owned(), + DirectiveType::new_specified_by(&mut registry), + ); let mut meta_fields = vec![ registry.field::>("__schema", &()), @@ -235,6 +242,7 @@ impl<'a, S> SchemaType<'a, S> { } } SchemaType { + description: None, types: registry.types, query_type_name, mutation_type_name: if &mutation_type_name != "_EmptyMutation" { @@ -251,6 +259,11 @@ impl<'a, S> SchemaType<'a, S> { } } + /// Add a description. + pub fn set_description(&mut self, description: impl Into>) { + self.description = Some(description.into()); + } + /// Add a directive like `skip` or `include`. pub fn add_directive(&mut self, directive: DirectiveType<'a, S>) { self.directives.insert(directive.name.clone(), directive); @@ -489,12 +502,14 @@ where name: &str, locations: &[DirectiveLocation], arguments: &[Argument<'a, S>], + is_repeatable: bool, ) -> DirectiveType<'a, S> { DirectiveType { name: name.to_owned(), description: None, locations: locations.to_vec(), arguments: arguments.to_vec(), + is_repeatable, } } @@ -510,6 +525,7 @@ where DirectiveLocation::InlineFragment, ], &[registry.arg::("if", &())], + false, ) } @@ -525,6 +541,19 @@ where DirectiveLocation::InlineFragment, ], &[registry.arg::("if", &())], + false, + ) + } + + fn new_specified_by(registry: &mut Registry<'a, S>) -> DirectiveType<'a, S> + where + S: ScalarValue, + { + Self::new( + "specifiedBy", + &[DirectiveLocation::Scalar], + &[registry.arg::("url", &())], + false, ) } @@ -544,6 +573,7 @@ impl fmt::Display for DirectiveLocation { DirectiveLocation::FragmentDefinition => "fragment definition", DirectiveLocation::FragmentSpread => "fragment spread", DirectiveLocation::InlineFragment => "inline fragment", + DirectiveLocation::Scalar => "scalar", }) } } diff --git a/juniper/src/schema/schema.rs b/juniper/src/schema/schema.rs index ee947fee..30906478 100644 --- a/juniper/src/schema/schema.rs +++ b/juniper/src/schema/schema.rs @@ -137,6 +137,10 @@ where internal, )] impl<'a, S: ScalarValue + 'a> SchemaType<'a, S> { + fn description(&self) -> Option<&str> { + self.description.as_deref() + } + fn types(&self) -> Vec> { self.type_list() .into_iter() @@ -192,6 +196,13 @@ impl<'a, S: ScalarValue + 'a> TypeType<'a, S> { } } + fn specified_by_url(&self) -> Option<&str> { + match self { + Self::Concrete(t) => t.specified_by_url(), + Self::NonNull(_) | Self::List(..) => None, + } + } + fn kind(&self) -> TypeKind { match self { TypeType::Concrete(t) => t.type_kind(), @@ -401,6 +412,10 @@ impl<'a, S: ScalarValue + 'a> DirectiveType<'a, S> { &self.locations } + fn is_repeatable(&self) -> bool { + self.is_repeatable + } + fn args(&self) -> &[Argument] { &self.arguments } diff --git a/juniper/src/tests/introspection_tests.rs b/juniper/src/tests/introspection_tests.rs index 0ab33da5..16416b2f 100644 --- a/juniper/src/tests/introspection_tests.rs +++ b/juniper/src/tests/introspection_tests.rs @@ -208,6 +208,12 @@ async fn test_introspection_directives() { "INLINE_FRAGMENT", ], }, + { + "name": "specifiedBy", + "locations": [ + "SCALAR", + ], + }, ], }, }); diff --git a/juniper/src/tests/schema_introspection.rs b/juniper/src/tests/schema_introspection.rs index 5926235b..fbea3b98 100644 --- a/juniper/src/tests/schema_introspection.rs +++ b/juniper/src/tests/schema_introspection.rs @@ -36,6 +36,7 @@ pub(super) fn sort_schema_value(value: &mut Value) { pub(crate) fn schema_introspection_result() -> Value { let mut v = graphql_value!({ "__schema": { + "description": null, "queryType": { "name": "Query" }, @@ -46,6 +47,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "OBJECT", "name": "Human", "description": "A humanoid creature in the Star Wars universe.", + "specifiedByUrl": null, "fields": [ { "name": "id", @@ -151,6 +153,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "SCALAR", "name": "Boolean", "description": null, + "specifiedByUrl": null, "fields": null, "inputFields": null, "interfaces": null, @@ -161,6 +164,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "OBJECT", "name": "__InputValue", "description": null, + "specifiedByUrl": null, "fields": [ { "name": "name", @@ -228,6 +232,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "SCALAR", "name": "String", "description": null, + "specifiedByUrl": null, "fields": null, "inputFields": null, "interfaces": null, @@ -238,6 +243,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "OBJECT", "name": "__Field", "description": null, + "specifiedByUrl": null, "fields": [ { "name": "name", @@ -345,6 +351,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "ENUM", "name": "__TypeKind", "description": "GraphQL type kind\n\nThe GraphQL specification defines a number of type kinds - the meta type of a type.", + "specifiedByUrl": null, "fields": null, "inputFields": null, "interfaces": null, @@ -404,6 +411,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "OBJECT", "name": "__Type", "description": null, + "specifiedByUrl": null, "fields": [ { "name": "name", @@ -548,6 +556,18 @@ pub(crate) fn schema_introspection_result() -> Value { "isDeprecated": false, "deprecationReason": null }, + { + "name": "specifiedByUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "enumValues", "description": null, @@ -589,6 +609,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "OBJECT", "name": "__Schema", "description": null, + "specifiedByUrl": null, "fields": [ { "name": "types", @@ -614,6 +635,18 @@ pub(crate) fn schema_introspection_result() -> Value { "isDeprecated": false, "deprecationReason": null }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "queryType", "description": null, @@ -688,6 +721,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "OBJECT", "name": "Droid", "description": "A mechanical creature in the Star Wars universe.", + "specifiedByUrl": null, "fields": [ { "name": "id", @@ -793,6 +827,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "OBJECT", "name": "Query", "description": "The root query object of the schema", + "specifiedByUrl": null, "fields": [ { "name": "human", @@ -882,6 +917,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "OBJECT", "name": "__EnumValue", "description": null, + "specifiedByUrl": null, "fields": [ { "name": "name", @@ -949,6 +985,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "ENUM", "name": "Episode", "description": null, + "specifiedByUrl": null, "fields": null, "inputFields": null, "interfaces": null, @@ -978,6 +1015,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "ENUM", "name": "__DirectiveLocation", "description": null, + "specifiedByUrl": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1023,6 +1061,12 @@ pub(crate) fn schema_introspection_result() -> Value { "description": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "SCALAR", + "description": null, + "isDeprecated": false, + "deprecationReason": null } ], "possibleTypes": null @@ -1031,6 +1075,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "INTERFACE", "name": "Character", "description": "A character in the Star Wars Trilogy", + "specifiedByUrl": null, "fields": [ { "name": "id", @@ -1129,6 +1174,7 @@ pub(crate) fn schema_introspection_result() -> Value { "kind": "OBJECT", "name": "__Directive", "description": null, + "specifiedByUrl": null, "fields": [ { "name": "name", @@ -1158,6 +1204,22 @@ pub(crate) fn schema_introspection_result() -> Value { "isDeprecated": false, "deprecationReason": null }, + { + "name": "isRepeatable", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "locations", "description": null, @@ -1265,6 +1327,7 @@ pub(crate) fn schema_introspection_result() -> Value { { "name": "skip", "description": null, + "isRepeatable": false, "locations": [ "FIELD", "FRAGMENT_SPREAD", @@ -1290,6 +1353,7 @@ pub(crate) fn schema_introspection_result() -> Value { { "name": "include", "description": null, + "isRepeatable": false, "locations": [ "FIELD", "FRAGMENT_SPREAD", @@ -1311,6 +1375,30 @@ pub(crate) fn schema_introspection_result() -> Value { "defaultValue": null } ] + }, + { + "name": "specifiedBy", + "description": null, + "isRepeatable": false, + "locations": [ + "SCALAR" + ], + "args": [ + { + "name": "url", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ] } ] } @@ -1331,6 +1419,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "OBJECT", "name": "Human", + "specifiedByUrl": null, "fields": [ { "name": "id", @@ -1430,6 +1519,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "SCALAR", "name": "Boolean", + "specifiedByUrl": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1439,6 +1529,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "OBJECT", "name": "__InputValue", + "specifiedByUrl": null, "fields": [ { "name": "name", @@ -1501,6 +1592,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "SCALAR", "name": "String", + "specifiedByUrl": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1510,6 +1602,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "OBJECT", "name": "__Field", + "specifiedByUrl": null, "fields": [ { "name": "name", @@ -1610,6 +1703,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "ENUM", "name": "__TypeKind", + "specifiedByUrl": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1660,6 +1754,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "OBJECT", "name": "__Type", + "specifiedByUrl": null, "fields": [ { "name": "name", @@ -1795,6 +1890,17 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { "isDeprecated": false, "deprecationReason": null }, + { + "name": "specifiedByUrl", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "enumValues", "args": [ @@ -1833,7 +1939,19 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "OBJECT", "name": "__Schema", + "specifiedByUrl": null, "fields": [ + { + "name": "description", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "types", "args": [], @@ -1926,6 +2044,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "OBJECT", "name": "Droid", + "specifiedByUrl": null, "fields": [ { "name": "id", @@ -2025,6 +2144,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "OBJECT", "name": "Query", + "specifiedByUrl": null, "fields": [ { "name": "human", @@ -2106,6 +2226,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "OBJECT", "name": "__EnumValue", + "specifiedByUrl": null, "fields": [ { "name": "name", @@ -2168,6 +2289,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "ENUM", "name": "Episode", + "specifiedByUrl": null, "fields": null, "inputFields": null, "interfaces": null, @@ -2193,6 +2315,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "ENUM", "name": "__DirectiveLocation", + "specifiedByUrl": null, "fields": null, "inputFields": null, "interfaces": null, @@ -2231,6 +2354,11 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { "name": "INLINE_FRAGMENT", "isDeprecated": false, "deprecationReason": null + }, + { + "name": "SCALAR", + "isDeprecated": false, + "deprecationReason": null } ], "possibleTypes": null @@ -2238,6 +2366,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "INTERFACE", "name": "Character", + "specifiedByUrl": null, "fields": [ { "name": "id", @@ -2331,6 +2460,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { { "kind": "OBJECT", "name": "__Directive", + "specifiedByUrl": null, "fields": [ { "name": "name", @@ -2358,6 +2488,21 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { "isDeprecated": false, "deprecationReason": null }, + { + "name": "isRepeatable", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "locations", "args": [], @@ -2459,6 +2604,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { "directives": [ { "name": "skip", + "isRepeatable": false, "locations": [ "FIELD", "FRAGMENT_SPREAD", @@ -2482,6 +2628,7 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { }, { "name": "include", + "isRepeatable": false, "locations": [ "FIELD", "FRAGMENT_SPREAD", @@ -2502,6 +2649,28 @@ pub(crate) fn schema_introspection_result_without_descriptions() -> Value { "defaultValue": null } ] + }, + { + "name": "specifiedBy", + "isRepeatable": false, + "locations": [ + "SCALAR" + ], + "args": [ + { + "name": "url", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ] } ] } diff --git a/juniper/src/validation/test_harness.rs b/juniper/src/validation/test_harness.rs index 368fcf7e..3d1cb710 100644 --- a/juniper/src/validation/test_harness.rs +++ b/juniper/src/validation/test_harness.rs @@ -855,31 +855,37 @@ where "onQuery", &[DirectiveLocation::Query], &[], + false, )); root.schema.add_directive(DirectiveType::new( "onMutation", &[DirectiveLocation::Mutation], &[], + false, )); root.schema.add_directive(DirectiveType::new( "onField", &[DirectiveLocation::Field], &[], + false, )); root.schema.add_directive(DirectiveType::new( "onFragmentDefinition", &[DirectiveLocation::FragmentDefinition], &[], + false, )); root.schema.add_directive(DirectiveType::new( "onFragmentSpread", &[DirectiveLocation::FragmentSpread], &[], + false, )); root.schema.add_directive(DirectiveType::new( "onInlineFragment", &[DirectiveLocation::InlineFragment], &[], + false, )); let doc = diff --git a/juniper_codegen/Cargo.toml b/juniper_codegen/Cargo.toml index 28d44c13..acd1a33e 100644 --- a/juniper_codegen/Cargo.toml +++ b/juniper_codegen/Cargo.toml @@ -22,6 +22,7 @@ proc-macro-error = "1.0.2" proc-macro2 = "1.0.1" quote = "1.0.3" syn = { version = "1.0.60", features = ["extra-traits", "full", "parsing"], default-features = false } +url = "2.0" [dev-dependencies] derive_more = "0.99.7" diff --git a/juniper_codegen/src/derive_scalar_value.rs b/juniper_codegen/src/derive_scalar_value.rs index a3c87c67..3e758224 100644 --- a/juniper_codegen/src/derive_scalar_value.rs +++ b/juniper_codegen/src/derive_scalar_value.rs @@ -6,12 +6,14 @@ use crate::{ use proc_macro2::TokenStream; use quote::quote; use syn::{spanned::Spanned, token, Data, Fields, Ident, Variant}; +use url::Url; #[derive(Debug, Default)] struct TransparentAttributes { transparent: Option, name: Option, description: Option, + specified_by_url: Option, scalar: Option, } @@ -21,6 +23,7 @@ impl syn::parse::Parse for TransparentAttributes { transparent: None, name: None, description: None, + specified_by_url: None, scalar: None, }; @@ -37,6 +40,14 @@ impl syn::parse::Parse for TransparentAttributes { let val = input.parse::()?; output.description = Some(val.value()); } + "specified_by_url" => { + input.parse::()?; + let val: syn::LitStr = input.parse::()?; + output.specified_by_url = + Some(val.value().parse().map_err(|e| { + syn::Error::new(val.span(), format!("Invalid URL: {}", e)) + })?); + } "transparent" => { output.transparent = Some(true); } @@ -101,10 +112,11 @@ fn impl_scalar_struct( let inner_ty = &field.ty; let name = attrs.name.unwrap_or_else(|| ident.to_string()); - let description = match attrs.description { - Some(val) => quote!( .description( #val ) ), - None => quote!(), - }; + let description = attrs.description.map(|val| quote!(.description(#val))); + let specified_by_url = attrs.specified_by_url.map(|url| { + let url_lit = url.as_str(); + quote!(.specified_by_url(#url_lit)) + }); let scalar = attrs .scalar @@ -159,6 +171,7 @@ fn impl_scalar_struct( { registry.build_scalar_type::(info) #description + #specified_by_url .into_meta() } } diff --git a/juniper_codegen/src/impl_scalar.rs b/juniper_codegen/src/impl_scalar.rs index 94c8481c..a790abac 100644 --- a/juniper_codegen/src/impl_scalar.rs +++ b/juniper_codegen/src/impl_scalar.rs @@ -202,10 +202,11 @@ pub fn build_scalar( .name .map(SpanContainer::into_inner) .unwrap_or_else(|| impl_for_type.ident.to_string()); - let description = match attrs.description { - Some(val) => quote!(.description(#val)), - None => quote!(), - }; + let description = attrs.description.map(|val| quote!(.description(#val))); + let specified_by_url = attrs.specified_by_url.map(|url| { + let url_lit = url.as_str(); + quote!(.specified_by_url(#url_lit)) + }); let async_generic_type = match input.custom_data_type_is_struct { true => quote!(__S), _ => quote!(#custom_data_type), @@ -273,6 +274,7 @@ pub fn build_scalar( { registry.build_scalar_type::(info) #description + #specified_by_url .into_meta() } } diff --git a/juniper_codegen/src/lib.rs b/juniper_codegen/src/lib.rs index 14f4c1a7..1e426af3 100644 --- a/juniper_codegen/src/lib.rs +++ b/juniper_codegen/src/lib.rs @@ -177,6 +177,8 @@ pub fn derive_input_object(input: TokenStream) -> TokenStream { /// // A description can also specified in the attribute. /// // This will the doc comment, if one exists. /// description = "...", +/// // A specification URL. +/// specified_by_url = "https://tools.ietf.org/html/rfc4122", /// )] /// struct UserId(String); /// ``` @@ -221,7 +223,10 @@ pub fn derive_scalar_value(input: TokenStream) -> TokenStream { /// name = "MyName", /// // You can also specify a description here. /// // If present, doc comments will be ignored. -/// description = "An opaque identifier, represented as a string")] +/// description = "An opaque identifier, represented as a string", +/// // A specification URL. +/// specified_by_url = "https://tools.ietf.org/html/rfc4122", +/// )] /// impl GraphQLScalar for UserID /// where /// S: juniper::ScalarValue diff --git a/juniper_codegen/src/util/mod.rs b/juniper_codegen/src/util/mod.rs index d8002f51..4109ce7e 100644 --- a/juniper_codegen/src/util/mod.rs +++ b/juniper_codegen/src/util/mod.rs @@ -17,6 +17,7 @@ use syn::{ spanned::Spanned, token, Attribute, Ident, Lit, Meta, MetaList, MetaNameValue, NestedMeta, }; +use url::Url; use crate::common::parse::ParseBufferExt as _; @@ -454,6 +455,7 @@ pub enum FieldAttributeParseMode { enum FieldAttribute { Name(SpanContainer), Description(SpanContainer), + SpecifiedByUrl(SpanContainer), Deprecation(SpanContainer), Skip(SpanContainer), Arguments(HashMap), @@ -488,6 +490,15 @@ impl Parse for FieldAttribute { lit, ))) } + "specified_by_url" => { + input.parse::()?; + let lit = input.parse::()?; + Ok(FieldAttribute::SpecifiedByUrl(SpanContainer::new( + ident.span(), + Some(lit.span()), + lit, + ))) + } "deprecated" | "deprecation" => { let reason = if input.peek(token::Eq) { input.parse::()?; @@ -542,7 +553,9 @@ pub struct FieldAttributes { pub name: Option>, pub description: Option>, pub deprecation: Option>, - // Only relevant for GraphQLObject derive. + /// Only relevant for scalar impl macro. + pub specified_by_url: Option>, + /// Only relevant for GraphQLObject derive. pub skip: Option>, /// Only relevant for object macro. pub arguments: HashMap, @@ -564,6 +577,18 @@ impl Parse for FieldAttributes { FieldAttribute::Description(name) => { output.description = Some(name.map(|val| val.value())); } + FieldAttribute::SpecifiedByUrl(url) => { + output.specified_by_url = Some( + url.map(|val| Url::parse(&val.value())) + .transpose() + .map_err(|e| { + syn::Error::new( + e.span_ident(), + format!("Invalid URL: {}", e.inner()), + ) + })?, + ); + } FieldAttribute::Deprecation(attr) => { output.deprecation = Some(attr); } diff --git a/juniper_codegen/src/util/span_container.rs b/juniper_codegen/src/util/span_container.rs index 370f17a7..2040a48f 100644 --- a/juniper_codegen/src/util/span_container.rs +++ b/juniper_codegen/src/util/span_container.rs @@ -58,6 +58,15 @@ impl SpanContainer { } } +impl SpanContainer> { + pub fn transpose(self) -> Result, SpanContainer> { + match self.val { + Ok(v) => Ok(SpanContainer::new(self.ident, self.expr, v)), + Err(e) => Err(SpanContainer::new(self.ident, self.expr, e)), + } + } +} + impl AsRef for SpanContainer { fn as_ref(&self) -> &T { &self.val