diff --git a/README.md b/README.md index 3444d2991..b06b6cd41 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,7 @@ see the [hyper][hyper_examples], [rocket][rocket_examples], [iron][iron_examples Juniper supports the full GraphQL query language according to the [specification][graphql_spec], including interfaces, unions, schema -introspection, and validations. -It does not, however, support the schema language. +introspection, and validations. It can also output the schema in the [GraphQL Schema Language][schema_language]. As an exception to other GraphQL libraries for other languages, Juniper builds non-null types by default. A field of type `Vec` will be converted into @@ -86,6 +85,7 @@ Juniper has not reached 1.0 yet, thus some API instability should be expected. [playground]: https://github.com/prisma/graphql-playground [iron]: http://ironframework.io [graphql_spec]: http://facebook.github.io/graphql +[schema_language]: https://graphql.org/learn/schema/#type-language [test_schema_rs]: https://github.com/graphql-rust/juniper/blob/master/juniper/src/tests/schema.rs [tokio]: https://github.com/tokio-rs/tokio [hyper_examples]: https://github.com/graphql-rust/juniper/tree/master/juniper_hyper/examples diff --git a/juniper/CHANGELOG.md b/juniper/CHANGELOG.md index 93356570d..26de674c6 100644 --- a/juniper/CHANGELOG.md +++ b/juniper/CHANGELOG.md @@ -2,13 +2,31 @@ - The minimum required Rust version is now `1.30.0`. - The `ScalarValue` custom derive has been renamed to `GraphQLScalarValue`. -- Fix introspection query validity - The DirectiveLocation::InlineFragment had an invalid literal value, +- Fix introspection query validity. + `DirectiveLocation::InlineFragment` had an invalid literal value, which broke third party tools like apollo cli. - Added GraphQL Playground integration - The DirectiveLocation::InlineFragment had an invalid literal value, - which broke third party tools like apollo cli. - The return type of `value::object::Object::iter/iter_mut` has changed to `impl Iter` [#312](https://github.com/graphql-rust/juniper/pull/312) +- Added the `schema-language` feature (on by default). This feature enables converting + the schema to a `String` in the + [GraphQL Schema Language](https://graphql.org/learn/schema/#type-language) + format (usually written to a file called `schema.graphql`). + + Example: + + ```rust + use juniper::{RootNode, EmptyMutation}; + + #[derive(GraphQLObject)] + struct Query{ + foo: bool + }; + let s = RootNode::new(Query, EmptyMutation::<()>::new()); + println!("{}, s.as_schema_language()); + ``` + + Note: The `schema-language` feature brings in more dependencies. + If you don't use the schema language you may want to turn the feature off. # [0.11.1] 2018-12-19 diff --git a/juniper/Cargo.toml b/juniper/Cargo.toml index fec422031..f9d68f5cb 100644 --- a/juniper/Cargo.toml +++ b/juniper/Cargo.toml @@ -24,10 +24,13 @@ path = "benches/bench.rs" [features] nightly = [] expose-test-schema = [] +schema-language = ["graphql-parser-integration"] +graphql-parser-integration = ["graphql-parser"] default = [ "chrono", "url", "uuid", + "schema-language", ] [dependencies] @@ -42,6 +45,7 @@ chrono = { version = "0.4.0", optional = true } serde_json = { version="1.0.2", optional = true } url = { version = "1.5.1", optional = true } uuid = { version = "0.7", optional = true } +graphql-parser = {version = "0.2.2", optional = true } [dev-dependencies] bencher = "0.1.2" diff --git a/juniper/src/lib.rs b/juniper/src/lib.rs index 6d37632d0..c819316b2 100644 --- a/juniper/src/lib.rs +++ b/juniper/src/lib.rs @@ -103,13 +103,16 @@ extern crate fnv; extern crate indexmap; -#[cfg(any(test, feature = "chrono"))] +#[cfg(feature = "graphql-parser-integration")] +extern crate graphql_parser; + +#[cfg(feature = "chrono")] extern crate chrono; -#[cfg(any(test, feature = "url"))] +#[cfg(feature = "url")] extern crate url; -#[cfg(any(test, feature = "uuid"))] +#[cfg(feature = "uuid")] extern crate uuid; // Depend on juniper_codegen and re-export everything in it. diff --git a/juniper/src/schema/meta.rs b/juniper/src/schema/meta.rs index a294e996d..4dba3d3c0 100644 --- a/juniper/src/schema/meta.rs +++ b/juniper/src/schema/meta.rs @@ -165,6 +165,14 @@ pub struct Field<'a, S> { pub deprecation_status: DeprecationStatus, } +impl<'a, S> Field<'a, S> { + /// Returns true if the type is built-in to GraphQL. + pub fn is_builtin(&self) -> bool { + // "used exclusively by GraphQL’s introspection system" + self.name.starts_with("__") + } +} + /// Metadata for an argument to a field #[derive(Debug, Clone)] pub struct Argument<'a, S> { @@ -178,6 +186,14 @@ pub struct Argument<'a, S> { pub default_value: Option>, } +impl<'a, S> Argument<'a, S> { + /// Returns true if the type is built-in to GraphQL. + pub fn is_builtin(&self) -> bool { + // "used exclusively by GraphQL’s introspection system" + self.name.starts_with("__") + } +} + /// Metadata for a single value in an enum #[derive(Debug, Clone)] pub struct EnumValue { @@ -364,6 +380,22 @@ impl<'a, S> MetaType<'a, S> { } } + /// Returns true if the type is built-in to GraphQL. + pub fn is_builtin(&self) -> bool { + if let Some(name) = self.name() { + // "used exclusively by GraphQL’s introspection system" + { + name.starts_with("__") || + // + name == "Boolean" || name == "String" || name == "Int" || name == "Float" || name == "ID" || + // Our custom empty mutation marker + name == "_EmptyMutation" + } + } else { + false + } + } + pub(crate) fn fields<'b>(&self, schema: &'b SchemaType) -> Option>> { schema .lookup_type(&self.as_type()) diff --git a/juniper/src/schema/mod.rs b/juniper/src/schema/mod.rs index ae361c990..d612f5cf3 100644 --- a/juniper/src/schema/mod.rs +++ b/juniper/src/schema/mod.rs @@ -1,3 +1,4 @@ pub mod meta; pub mod model; pub mod schema; +pub mod translate; diff --git a/juniper/src/schema/model.rs b/juniper/src/schema/model.rs index acd47cffc..4030d9bad 100644 --- a/juniper/src/schema/model.rs +++ b/juniper/src/schema/model.rs @@ -4,7 +4,14 @@ use fnv::FnvHashMap; use ast::Type; use executor::{Context, Registry}; +#[cfg(feature = "graphql-parser-integration")] +use graphql_parser::schema::Document; use schema::meta::{Argument, InterfaceMeta, MetaType, ObjectMeta, PlaceholderMeta, UnionMeta}; +#[cfg(feature = "graphql-parser-integration")] +use schema::translate::graphql_parser::GraphQLParserTranslator; +#[cfg(feature = "graphql-parser-integration")] +use schema::translate::SchemaTranslator; + use types::base::GraphQLType; use types::name::Name; use value::{DefaultScalarValue, ScalarRefValue, ScalarValue}; @@ -35,8 +42,8 @@ where #[derive(Debug)] pub struct SchemaType<'a, S> { pub(crate) types: FnvHashMap>, - query_type_name: String, - mutation_type_name: Option, + pub(crate) query_type_name: String, + pub(crate) mutation_type_name: Option, directives: FnvHashMap>, } @@ -88,6 +95,22 @@ where { RootNode::new_with_info(query_obj, mutation_obj, (), ()) } + + #[cfg(feature = "schema-language")] + /// The schema definition as a `String` in the + /// [GraphQL Schema Language](https://graphql.org/learn/schema/#type-language) + /// format. + pub fn as_schema_language(&self) -> String { + let doc = self.as_parser_document(); + format!("{}", doc) + } + + #[cfg(feature = "graphql-parser-integration")] + /// The schema definition as a [`graphql_parser`](https://crates.io/crates/graphql-parser) + /// [`Document`](https://docs.rs/graphql-parser/latest/graphql_parser/schema/struct.Document.html). + pub fn as_parser_document(&self) -> Document { + GraphQLParserTranslator::translate_schema(&self.schema) + } } impl<'a, S, QueryT, MutationT> RootNode<'a, QueryT, MutationT, S> @@ -466,3 +489,164 @@ impl<'a, S> fmt::Display for TypeType<'a, S> { } } } + +#[cfg(test)] +mod test { + + #[cfg(feature = "graphql-parser-integration")] + mod graphql_parser_integration { + use EmptyMutation; + + #[test] + fn graphql_parser_doc() { + struct Query; + graphql_object!(Query: () |&self| { + field blah() -> bool { + true + } + }); + let schema = crate::RootNode::new(Query, EmptyMutation::<()>::new()); + let ast = graphql_parser::parse_schema( + r#" + type Query { + blah: Boolean! + } + + schema { + query: Query + } + "#, + ) + .unwrap(); + assert_eq!( + format!("{}", ast), + format!("{}", schema.as_parser_document()), + ); + } + } + + #[cfg(feature = "schema-language")] + mod schema_language { + use crate as juniper; + use EmptyMutation; + + #[test] + fn schema_language() { + #[derive(GraphQLObject, Default)] + struct Cake { + fresh: bool, + }; + #[derive(GraphQLObject, Default)] + struct IceCream { + cold: bool, + }; + enum Sweet { + Cake(Cake), + IceCream(IceCream), + } + enum GlutenFree { + Cake(Cake), + IceCream(IceCream), + } + graphql_interface!(Sweet: () where Scalar = |&self| { + field is_brownie() -> bool { false } + instance_resolvers: |_| { + &Cake => match *self { Sweet::Cake(ref x) => Some(x), _ => None }, + &IceCream => match *self { Sweet::IceCream(ref x) => Some(x), _ => None }, + } + }); + graphql_union!(GlutenFree: () where Scalar = |&self| { + instance_resolvers: |_| { + &Cake => match *self { GlutenFree::Cake(ref x) => Some(x), _ => None }, + &IceCream => match *self { GlutenFree::IceCream(ref x) => Some(x), _ => None }, + } + }); + #[derive(GraphQLEnum)] + enum Fruit { + Apple, + Orange, + } + #[derive(GraphQLInputObject)] + struct Coordinate { + latitude: f64, + longitude: f64, + } + struct Query; + graphql_object!(Query: () |&self| { + field blah() -> bool { + true + } + /// This is whatever's description. + field whatever() -> String { + "foo".to_string() + } + field fizz(buzz: String as "Buzz description") -> Option { + if buzz == "whatever" { + Some(Sweet::Cake(Cake::default())) + } else { + Some(Sweet::IceCream(IceCream::default())) + } + } + field arr(stuff: Vec) -> Option<&str> { + None + } + field fruit() -> Fruit { + Fruit::Apple + } + field gluten_free(people = 1: i32 ) -> GlutenFree { + if people > 1 { + GlutenFree::Cake(Cake::default()) + } else { + GlutenFree::IceCream(IceCream::default()) + } } + #[deprecated] + field old() -> i32 { + 42 + } + #[deprecated(note="This field is deprecated, use another.")] + field really_old() -> f64 { + 42.0 + } + }); + let schema = crate::RootNode::new(Query, EmptyMutation::<()>::new()); + let ast = graphql_parser::parse_schema( + r#" + union GlutenFree = Cake | IceCream + enum Fruit { + APPLE + ORANGE + } + interface Sweet { + isBrownie: Boolean! + } + type Cake { + fresh: Boolean! + } + type IceCream { + cold: Boolean! + } + type Query { + blah: Boolean! + "This is whatever's description." + whatever: String! + fizz("Buzz description" buzz: String!): Sweet + arr(stuff: [Coordinate!]!): String + fruit: Fruit! + glutenFree(people: Int = 1): GlutenFree! + old: Int! @deprecated + reallyOld: Float! @deprecated(reason: "This field is deprecated, use another.") + } + input Coordinate { + latitude: Float! + longitude: Float! + } + schema { + query: Query + } + "#, + ) + .unwrap(); + assert_eq!(format!("{}", ast), schema.as_schema_language()); + } + } +} diff --git a/juniper/src/schema/translate/graphql_parser.rs b/juniper/src/schema/translate/graphql_parser.rs new file mode 100644 index 000000000..7902716c1 --- /dev/null +++ b/juniper/src/schema/translate/graphql_parser.rs @@ -0,0 +1,268 @@ +use std::boxed::Box; +use std::collections::BTreeMap; + +use graphql_parser::query::{ + Directive as ExternalDirective, Number as ExternalNumber, Type as ExternalType, +}; +use graphql_parser::schema::{Definition, Document, SchemaDefinition}; +use graphql_parser::schema::{ + EnumType as ExternalEnum, EnumValue as ExternalEnumValue, Field as ExternalField, + InputObjectType as ExternalInputObjectType, InputValue as ExternalInputValue, + InterfaceType as ExternalInterfaceType, ObjectType as ExternalObjectType, + ScalarType as ExternalScalarType, TypeDefinition as ExternalTypeDefinition, + UnionType as ExternalUnionType, Value as ExternalValue, +}; +use graphql_parser::Pos; + +use ast::{InputValue, Type}; +use schema::meta::DeprecationStatus; +use schema::meta::{Argument, EnumValue, Field, MetaType}; +use schema::model::SchemaType; +use schema::translate::SchemaTranslator; +use value::ScalarValue; + +pub struct GraphQLParserTranslator; + +impl<'a, S> From> for Document +where + S: ScalarValue, +{ + fn from(input: SchemaType<'a, S>) -> Document { + GraphQLParserTranslator::translate_schema(&input) + } +} + +impl SchemaTranslator for GraphQLParserTranslator { + fn translate_schema<'a, S>(input: &SchemaType<'a, S>) -> graphql_parser::schema::Document + where + S: ScalarValue, + { + let mut doc = Document::default(); + + // Translate type defs. + let mut types = input + .types + .iter() + .filter(|(_, meta)| !meta.is_builtin()) + .map(|(_, meta)| GraphQLParserTranslator::translate_meta(meta)) + .map(|x| Definition::TypeDefinition(x)) + .collect::>(); + doc.definitions.append(&mut types); + + doc.definitions + .push(Definition::SchemaDefinition(SchemaDefinition { + position: Pos::default(), + directives: vec![], + query: Some(input.query_type_name.clone()), + mutation: input.mutation_type_name.clone(), + // TODO: implement once we support subscriptions. + subscription: None, + })); + + doc + } +} + +impl GraphQLParserTranslator { + fn translate_argument<'a, S>(input: &Argument<'a, S>) -> ExternalInputValue + where + S: ScalarValue, + { + ExternalInputValue { + position: Pos::default(), + description: input.description.clone(), + name: input.name.clone(), + value_type: GraphQLParserTranslator::translate_type(&input.arg_type), + default_value: match input.default_value { + None => None, + Some(ref v) => Some(GraphQLParserTranslator::translate_value(v)), + }, + directives: vec![], + } + } + + fn translate_value(input: &InputValue) -> ExternalValue + where + S: ScalarValue, + { + match input { + InputValue::Null => ExternalValue::Null, + InputValue::Scalar(x) => { + if let Some(v) = x.as_string() { + ExternalValue::String(v) + } else if let Some(v) = x.as_int() { + ExternalValue::Int(ExternalNumber::from(v)) + } else if let Some(v) = x.as_float() { + ExternalValue::Float(v) + } else if let Some(v) = x.as_boolean() { + ExternalValue::Boolean(v) + } else { + panic!("unknown argument type") + } + } + InputValue::Enum(x) => ExternalValue::Enum(x.clone()), + InputValue::Variable(x) => ExternalValue::Variable(x.clone()), + InputValue::List(x) => ExternalValue::List( + x.iter() + .map(|s| GraphQLParserTranslator::translate_value(&s.item)) + .collect(), + ), + InputValue::Object(x) => { + let mut fields = BTreeMap::new(); + x.iter().for_each(|(name_span, value_span)| { + fields.insert( + name_span.item.clone(), + GraphQLParserTranslator::translate_value(&value_span.item), + ); + }); + ExternalValue::Object(fields) + } + } + } + + fn translate_type(input: &Type) -> ExternalType { + match input { + Type::Named(x) => ExternalType::NamedType(x.as_ref().to_string()), + Type::List(x) => ExternalType::ListType(Box::new( + GraphQLParserTranslator::translate_type(x.as_ref()), + )), + Type::NonNullNamed(x) => { + ExternalType::NonNullType(Box::new(ExternalType::NamedType(x.as_ref().to_string()))) + } + Type::NonNullList(x) => ExternalType::NonNullType(Box::new(ExternalType::ListType( + Box::new(GraphQLParserTranslator::translate_type(x.as_ref())), + ))), + } + } + + fn translate_meta<'empty, S>(input: &MetaType<'empty, S>) -> ExternalTypeDefinition + where + S: ScalarValue, + { + match input { + MetaType::Scalar(x) => ExternalTypeDefinition::Scalar(ExternalScalarType { + position: Pos::default(), + description: x.description.clone(), + name: x.name.to_string(), + directives: vec![], + }), + MetaType::Enum(x) => ExternalTypeDefinition::Enum(ExternalEnum { + position: Pos::default(), + description: x.description.clone(), + name: x.name.to_string(), + directives: vec![], + values: x + .values + .iter() + .map(GraphQLParserTranslator::translate_enum_value) + .collect(), + }), + MetaType::Union(x) => ExternalTypeDefinition::Union(ExternalUnionType { + position: Pos::default(), + description: x.description.clone(), + name: x.name.to_string(), + directives: vec![], + types: x.of_type_names.clone(), + }), + MetaType::Interface(x) => ExternalTypeDefinition::Interface(ExternalInterfaceType { + position: Pos::default(), + description: x.description.clone(), + name: x.name.to_string(), + directives: vec![], + fields: x + .fields + .iter() + .filter(|x| !x.is_builtin()) + .map(GraphQLParserTranslator::translate_field) + .collect(), + }), + MetaType::InputObject(x) => { + ExternalTypeDefinition::InputObject(ExternalInputObjectType { + position: Pos::default(), + description: x.description.clone(), + name: x.name.to_string(), + directives: vec![], + fields: x + .input_fields + .iter() + .filter(|x| !x.is_builtin()) + .map(GraphQLParserTranslator::translate_argument) + .collect(), + }) + } + MetaType::Object(x) => ExternalTypeDefinition::Object(ExternalObjectType { + position: Pos::default(), + description: x.description.clone(), + name: x.name.to_string(), + directives: vec![], + fields: x + .fields + .iter() + .filter(|x| !x.is_builtin()) + .map(GraphQLParserTranslator::translate_field) + .collect(), + implements_interfaces: x.interface_names.clone(), + }), + _ => panic!("unknown meta type when translating"), + } + } + + fn translate_enum_value(input: &EnumValue) -> ExternalEnumValue { + ExternalEnumValue { + position: Pos::default(), + name: input.name.clone(), + description: input.description.clone(), + directives: generate_directives(&input.deprecation_status), + } + } + + fn translate_field(input: &Field) -> ExternalField + where + S: ScalarValue, + { + ExternalField { + position: Pos::default(), + name: input.name.clone(), + description: input.description.clone(), + directives: generate_directives(&input.deprecation_status), + field_type: GraphQLParserTranslator::translate_type(&input.field_type), + arguments: input + .clone() + .arguments + .unwrap_or(vec![]) + .iter() + .filter(|x| !x.is_builtin()) + .map(GraphQLParserTranslator::translate_argument) + .collect(), + } + } +} + +fn deprecation_to_directive(status: &DeprecationStatus) -> Option { + match status { + DeprecationStatus::Current => None, + DeprecationStatus::Deprecated(reason) => Some(ExternalDirective { + position: Pos::default(), + name: "deprecated".to_string(), + arguments: if let Some(reason) = reason { + vec![( + "reason".to_string(), + ExternalValue::String(reason.to_string()), + )] + } else { + vec![] + }, + }), + } +} + +// Right now the only directive supported is `@deprecated`. `@skip` and `@include` +// are dealt with elsewhere. +// +fn generate_directives(status: &DeprecationStatus) -> Vec { + if let Some(d) = deprecation_to_directive(&status) { + vec![d] + } else { + vec![] + } +} diff --git a/juniper/src/schema/translate/mod.rs b/juniper/src/schema/translate/mod.rs new file mode 100644 index 000000000..a582e394a --- /dev/null +++ b/juniper/src/schema/translate/mod.rs @@ -0,0 +1,9 @@ +use schema::model::SchemaType; +use value::ScalarValue; + +pub trait SchemaTranslator { + fn translate_schema<'a, S: ScalarValue>(s: &SchemaType<'a, S>) -> T; +} + +#[cfg(feature = "graphql-parser-integration")] +pub mod graphql_parser;