diff --git a/README.md b/README.md index c6690a1..e3846d5 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,12 @@ Your build can contain multiple schemas. They are stored in the `graphqlSchemas` This allows to compare arbitrary schemas, write schema.json files for each of them and validate your queries against them. -There are already two schemas predefined. The `build` schema and the `prod` schema. +There is already one schemas predefined. The `build` schema is defined by the `graphqlSchemaGen` task. +You can configure the `graphqlSchemas` label with -* The `build` schema is defined by the `graphqlSchemaGen` task. -* The `prod` schema is defined by the `graphqlProductionSchema`. +```sbt +name in graphqlSchemaGen := "local-build" +``` ### Add a schema @@ -86,16 +88,6 @@ Schemas are defined via a `GraphQLSchema` case class. You need to define * a `description`. Explain where this schema comes from and what it represents * a `schemaTask`. A sbt task that generates the schema -This is how the `prod` schema is defined. - -```scala -graphqlSchemas += GraphQLSchema( - GraphQLSchemaLabels.PROD, - "schema generated by the graphqlProductionSchema task", - graphqlProductionSchema.taskValue -) -``` - You can also define a schema from a `SchemaLoader`. This requires defining an anonymous sbt task. ```scala diff --git a/build.sbt b/build.sbt index 3ea2a7d..ce58bd8 100644 --- a/build.sbt +++ b/build.sbt @@ -8,11 +8,14 @@ libraryDependencies ++= Seq( "org.sangria-graphql" %% "sangria-circe" % "1.1.0", "io.circe" %% "circe-core" % circeVersion, "io.circe" %% "circe-parser" % circeVersion, - "org.scalaj" %% "scalaj-http" % "2.3.0" + "org.scalaj" %% "scalaj-http" % "2.3.0", + "org.scalameta" %% "scalameta" % "2.1.2", + "org.scalatest" %% "scalatest" % "3.0.4" % Test ) // scripted test settings scriptedLaunchOpts += "-Dproject.version=" + version.value +scriptedLaunchOpts += "-Dcodegen.samples.dir=" + ((baseDirectory in ThisBuild).value / "src/test/resources") // project meta data licenses := Seq( diff --git a/project/build.properties b/project/build.properties index 394cb75..8b697bb 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.0.4 +sbt.version=1.1.0 diff --git a/src/main/scala/rocks/muki/graphql/GraphQLCodegenPlugin.scala b/src/main/scala/rocks/muki/graphql/GraphQLCodegenPlugin.scala new file mode 100644 index 0000000..6234098 --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/GraphQLCodegenPlugin.scala @@ -0,0 +1,70 @@ +package rocks.muki.graphql + +import rocks.muki.graphql.codegen._ +import rocks.muki.graphql.schema.SchemaLoader +import sbt.Keys._ +import sbt.{Result => _, _} + +import scala.meta._ + +object GraphQLCodegenPlugin extends AutoPlugin { + + override def requires: Plugins = GraphQLPlugin + + object autoImport { + val graphqlCodegenSchema = taskKey[File]("GraphQL schema file") + val graphqlCodegenQueries = taskKey[Seq[File]]("GraphQL query documents") + + val graphqlCodegenStyle = + settingKey[CodeGenStyles.Style]("The resulting code generation style") + + val graphqlCodegenPackage = + settingKey[String]("Package for the generated code") + val graphqlCodegen = taskKey[Seq[File]]("Generate GraphQL API code") + + val Apollo = CodeGenStyles.Apollo + val Sangria = CodeGenStyles.Sangria + + } + import autoImport._ + + override def projectSettings: Seq[Setting[_]] = Seq( + graphqlCodegenStyle := Apollo, + graphqlCodegenSchema := (resourceDirectory in Compile).value / "schema.graphql", + resourceDirectories in graphqlCodegen := (resourceDirectories in Compile).value, + includeFilter in graphqlCodegen := "*.graphql", + excludeFilter in graphqlCodegen := HiddenFileFilter, + graphqlCodegenQueries := Defaults + .collectFiles(resourceDirectories in graphqlCodegen, + includeFilter in graphqlCodegen, + excludeFilter in graphqlCodegen) + .value, + sourceGenerators in Compile += graphqlCodegen.taskValue, + graphqlCodegenPackage := "graphql.codegen", + name in graphqlCodegen := "GraphQLCodegen", + graphqlCodegen := { + val log = streams.value.log + val targetDir = sourceManaged.value / "sbt-graphql" + //val generator = ScalametaGenerator((name in graphqlCodegen).value) + val queries = graphqlCodegenQueries.value + log.info(s"Generate code for ${queries.length} queries") + log.info( + s"Use schema ${graphqlCodegenSchema.value} for query validation") + + val packageName = graphqlCodegenPackage.value + val schema = + SchemaLoader.fromFile(graphqlCodegenSchema.value).loadSchema() + + val moduleName = (name in graphqlCodegen).value + val context = CodeGenContext(schema, + targetDir, + queries, + packageName, + moduleName, + log) + + graphqlCodegenStyle.value(context) + } + ) + +} diff --git a/src/main/scala/rocks/muki/graphql/GraphQLPlugin.scala b/src/main/scala/rocks/muki/graphql/GraphQLPlugin.scala new file mode 100644 index 0000000..49b8e37 --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/GraphQLPlugin.scala @@ -0,0 +1,75 @@ +package rocks.muki.graphql + +import sbt.Keys._ +import sbt._ +import rocks.muki.graphql.schema.{GraphQLSchemas, SchemaLoader} + +/** + * == GraphQL Plugin == + * + * Root plugin for all other graphql plugins. Provides a schema registry that can be used for + * + * - validating queries against a specific schema + * - comparing schemas + * - code generation based on a specific schema + * + */ +object GraphQLPlugin extends AutoPlugin { + + object autoImport { + + /** + * Helper to load schemas from different places + */ + val GraphQLSchemaLoader: SchemaLoader.type = + rocks.muki.graphql.schema.SchemaLoader + + val GraphQLSchema: rocks.muki.graphql.schema.GraphQLSchema.type = + rocks.muki.graphql.schema.GraphQLSchema + + /** + * Contains all schemas available in this build. + * + * @example Adding a new schema + * {{{ + * graphqlSchemas += GraphQLSchema( + * "temporary", + * "schema loaded from schema.json in the base directory", + * SchemaLoader.fromFile(baseDirectory.value / "schema.json")), + * }}} + * + */ + val graphqlSchemas: SettingKey[GraphQLSchemas] = + settingKey[GraphQLSchemas]("all schemas available in this build") + + /** + * Renders the given schema into a graphql file. + * The input is the label in the graphqlSchemas setting. + */ + val graphqlRenderSchema: InputKey[File] = + inputKey[File]("renders the given schema to a graphql file") + + } + import autoImport._ + + override def projectSettings: Seq[Setting[_]] = Seq( + graphqlSchemas := GraphQLSchemas(), + // schema rendering + target in graphqlRenderSchema := (target in Compile).value / "graphql", + graphqlRenderSchema := graphqlRenderSchemaTask.evaluated + ) + + private val graphqlRenderSchemaTask = Def.inputTaskDyn[File] { + val log = streams.value.log + val schemaDefinition = singleGraphQLSchemaParser.parsed + val file = (target in graphqlRenderSchema).value / s"${schemaDefinition.label}.graphql" + log.info(s"Rendering schema to: ${file.getPath}") + + Def.task { + val schema = schemaDefinition.schemaTask.value + IO.write(file, schema.renderPretty) + file + } + } + +} diff --git a/src/main/scala/rocks/muki/graphql/GraphQLQueryPlugin.scala b/src/main/scala/rocks/muki/graphql/GraphQLQueryPlugin.scala index 7d55d58..f9f20bc 100644 --- a/src/main/scala/rocks/muki/graphql/GraphQLQueryPlugin.scala +++ b/src/main/scala/rocks/muki/graphql/GraphQLQueryPlugin.scala @@ -17,58 +17,67 @@ object GraphQLQueryPlugin extends AutoPlugin { */ val graphqlValidateQueries: TaskKey[Unit] = taskKey[Unit]("validate all queries in the graphql source directory") + + val graphqlQueryDirectory: SettingKey[File] = + settingKey[File]("directory that contains all graphql queries") } import autoImport._ import GraphQLSchemaPlugin.autoImport._ - override def projectSettings: Seq[Setting[_]] = Seq( - sourceDirectory in (Compile, graphqlValidateQueries) := (sourceDirectory in Compile).value / "graphql", - graphqlValidateQueries := { - val log = streams.value.log - val schemaFile = IO.read(graphqlSchemaGen.value) - val schemaDocument = QueryParser - .parse(schemaFile) - .getOrElse( - sys.error( - "Invalid graphql schema generated by `graphqlSchemaGen` task") - ) - val schema = Schema.buildFromAst(schemaDocument) + // TODO separate these into two auto plugins + override def projectSettings: Seq[Setting[_]] = + pluginSettings(Compile) ++ pluginSettings(IntegrationTest) - val src = (sourceDirectory in (Compile, graphqlValidateQueries)).value - val graphqlFiles = (src ** "*.graphql").get - val violations = graphqlFiles.flatMap { - file => - log.info(s"Validate ${file.getPath}") - val query = IO.read(file) - val violations = QueryParser - .parse(query) - .fold( - error => Vector(InvalidQueryValidation(error)), - query => QueryValidator.default.validateQuery(schema, query) + private def pluginSettings(config: Configuration): Seq[Setting[_]] = + inConfig(config)( + Seq( + graphqlQueryDirectory := (sourceDirectory in Compile).value / "graphql", + graphqlValidateQueries := { + val log = streams.value.log + val schemaFile = IO.read(graphqlSchemaGen.value) + val schemaDocument = QueryParser + .parse(schemaFile) + .getOrElse( + sys.error( + "Invalid graphql schema generated by `graphqlSchemaGen` task") ) - if (violations.nonEmpty) { - log.error(s"File: ${file.getAbsolutePath}") - log.error("## Query ##") - log.error(query) - log.error("## Violations ##") - violations.foreach(v => log.error(v.errorMessage)) - List(QueryViolations(file, query, violations)) - } else { - Nil + val schema = Schema.buildFromAst(schemaDocument) + + log.info(s"Checking graphql files in ${graphqlQueryDirectory.value}") + val graphqlFiles = (graphqlQueryDirectory.value ** "*.graphql").get + val violations = graphqlFiles.flatMap { + file => + log.info(s"Validate ${file.getPath}") + val query = IO.read(file) + val violations = QueryParser + .parse(query) + .fold( + error => Vector(InvalidQueryValidation(error)), + query => QueryValidator.default.validateQuery(schema, query) + ) + if (violations.nonEmpty) { + log.error(s"File: ${file.getAbsolutePath}") + log.error("## Query ##") + log.error(query) + log.error("## Violations ##") + violations.foreach(v => log.error(v.errorMessage)) + List(QueryViolations(file, query, violations)) + } else { + Nil + } } - } - if (violations.nonEmpty) { - log.error("Validation errors in") - violations.foreach { queryViolation => - log.error(s"File: ${queryViolation.file.getAbsolutePath}") + if (violations.nonEmpty) { + log.error("Validation errors in") + violations.foreach { queryViolation => + log.error(s"File: ${queryViolation.file.getAbsolutePath}") + } + quietError("Some queries contain validation violations") + } + log.success(s"All ${graphqlFiles.size} graphql files are valid") } - quietError("Some queries contain validation violations") - } - log.success(s"All ${graphqlFiles.size} graphql files are valid") - } - ) + )) /** * Aggregates violations for a single file diff --git a/src/main/scala/rocks/muki/graphql/GraphQLSchemaPlugin.scala b/src/main/scala/rocks/muki/graphql/GraphQLSchemaPlugin.scala index 6b62d90..9c0a649 100644 --- a/src/main/scala/rocks/muki/graphql/GraphQLSchemaPlugin.scala +++ b/src/main/scala/rocks/muki/graphql/GraphQLSchemaPlugin.scala @@ -1,16 +1,15 @@ package rocks.muki.graphql -import sangria.ast.Document +import rocks.muki.graphql.releasenotes.MarkdownReleaseNotes +import rocks.muki.graphql.schema.SchemaLoader import sangria.schema._ import sbt._ import sbt.Keys._ -import complete.{FixedSetExamples, Parser} -import complete.DefaultParsers._ -import rocks.muki.graphql.releasenotes.MarkdownReleaseNotes -import rocks.muki.graphql.schema.{GraphQLSchemas, SchemaLoader} object GraphQLSchemaPlugin extends AutoPlugin { + override val requires: Plugins = GraphQLPlugin + // The main class for the schema generator class private val mainClass = "SchemaGen" // The package for the schema generated class @@ -18,43 +17,6 @@ object GraphQLSchemaPlugin extends AutoPlugin { object autoImport { - /** - * Helper to load schemas from different places - */ - val GraphQLSchemaLoader: SchemaLoader.type = - rocks.muki.graphql.schema.SchemaLoader - - val GraphQLSchema: rocks.muki.graphql.schema.GraphQLSchema.type = - rocks.muki.graphql.schema.GraphQLSchema - - object GraphQLSchemaLabels { - - /** - * Label for the schema generated by the project build - */ - val BUILD: String = "build" - - /** - * Label for the production schema - */ - val PROD: String = "prod" - } - - /** - * Contains all schemas available in this build. - * - * @example Adding a new schema - * {{{ - * graphqlSchemas += GraphQLSchema( - * "temporary", - * "schema loaded from schema.json in the base directory", - * SchemaLoader.fromFile(baseDirectory.value / "schema.json")), - * }}} - * - */ - val graphqlSchemas: SettingKey[GraphQLSchemas] = - settingKey[GraphQLSchemas]("all schemas available in this build") - /** * A scala snippet that returns the [[sangria.schema.Schema]] for your graphql application. * @@ -72,13 +34,6 @@ object GraphQLSchemaPlugin extends AutoPlugin { val graphqlSchemaGen: TaskKey[File] = taskKey[File]("generates a graphql schema file") - /** - * Renders the given schema into a graphql file. - * The input is the label in the graphqlSchemas setting. - */ - val graphqlRenderSchema: InputKey[File] = - inputKey[File]("renders the given schema to a graphql file") - /** * Returns the changes between the two schemas defined as parameters. * @@ -94,12 +49,6 @@ object GraphQLSchemaPlugin extends AutoPlugin { val graphqlSchemaChanges: InputKey[Vector[SchemaChange]] = inputKey[Vector[SchemaChange]]("compares two schemas") - /** - * The currently active / deployed graphql schema. - */ - val graphqlProductionSchema: TaskKey[Schema[Any, Any]] = - taskKey[Schema[Any, Any]]("Graphql schema from your production system") - /** * Validates the new schema against existing queries and the production schema */ @@ -114,10 +63,10 @@ object GraphQLSchemaPlugin extends AutoPlugin { } import autoImport._ + import GraphQLPlugin.autoImport._ override def projectSettings: Seq[Setting[_]] = Seq( graphqlSchemaSnippet := """sys.error("Configure the `graphqlSchemaSnippet` setting with the correct scala code snippet to access your sangria schema")""", - graphqlProductionSchema := Schema.buildFromAst(Document.emptyStub), graphqlSchemaChanges := graphqlSchemaChangesTask.evaluated, target in graphqlSchemaGen := (target in Compile).value / "sbt-graphql", graphqlSchemaGen := { @@ -131,36 +80,18 @@ object GraphQLSchemaPlugin extends AutoPlugin { streams.value.log.info(s"Generating schema in $schemaFile") schemaFile }, - graphqlSchemas := GraphQLSchemas(), - graphqlSchemas += GraphQLSchema( - GraphQLSchemaLabels.BUILD, + // add the schema produced by this build to the graphqlSchemas + (name in graphqlSchemaGen) := "build", + graphqlSchemas += schema.GraphQLSchema( + (name in graphqlSchemaGen).value, "schema generated by this build (graphqlSchemaGen task)", graphqlSchemaGen.map(SchemaLoader.fromFile(_).loadSchema()).taskValue ), - graphqlSchemas += GraphQLSchema( - GraphQLSchemaLabels.PROD, - "schema generated by the graphqlProductionSchema task", - graphqlProductionSchema.taskValue), graphqlValidateSchema := graphqlValidateSchemaTask.evaluated, graphqlReleaseNotes := (new MarkdownReleaseNotes) .generateReleaseNotes(graphqlSchemaChanges.evaluated), // Generates a small snippet that generates a graphql schema - sourceGenerators in Compile += generateSchemaGeneratorClass(), - graphqlSchemas += GraphQLSchema( - "staging", - "staging schema at staging.your-graphql.net/graphql", - Def - .task( - GraphQLSchemaLoader - .fromIntrospection("http://staging.your-graphql.net/graphql", - streams.value.log) - .loadSchema() - ) - .taskValue - ), - // schema rendering - target in graphqlRenderSchema := (target in Compile).value / "graphql", - graphqlRenderSchema := graphqlRenderSchemaTask.evaluated + sourceGenerators in Compile += generateSchemaGeneratorClass() ) /** @@ -196,44 +127,16 @@ object GraphQLSchemaPlugin extends AutoPlugin { Seq(file) } - /** - * @param labels list of available schemas by label - * @return a parser for the given labels - */ - private def schemaLabelParser(labels: Iterable[String]): Parser[String] = { - val schemaParser = StringBasic.examples(FixedSetExamples(labels)) - token(Space ~> schemaParser) - } - - private val singleSchemaLabelParser: Def.Initialize[Parser[String]] = - Def.setting { - val labels = graphqlSchemas.value.schemas.map(_.label) - // create a dependent parser. A label can only be selected once - schemaLabelParser(labels) - } - - /** - * Parses two schema labels - */ - private val graphqlSchemaChangesParser - : Def.Initialize[Parser[(String, String)]] = Def.setting { - val labels = graphqlSchemas.value.schemas.map(_.label) - // create a depened parser. A label can only be selected once - schemaLabelParser(labels).flatMap { selectedLabel => - success(selectedLabel) ~ schemaLabelParser( - labels.filterNot(_ == selectedLabel)) - } - } - private val graphqlSchemaChangesTask = Def.inputTaskDyn { val log = streams.value.log - val (oldSchemaLabel, newSchemaLabel) = graphqlSchemaChangesParser.parsed + val (oldSchemaDefinition, newSchemaDefinition) = + tupleGraphQLSchemaParser.parsed - val schemas = graphqlSchemas.value.schemaByLabel Def.task { - val newSchema = schemas(newSchemaLabel).schemaTask.value - val oldSchema = schemas(oldSchemaLabel).schemaTask.value - log.info(s"Comparing $oldSchemaLabel with $newSchemaLabel schema") + val newSchema = newSchemaDefinition.schemaTask.value + val oldSchema = oldSchemaDefinition.schemaTask.value + log.info( + s"Comparing ${oldSchemaDefinition.label} with ${newSchemaDefinition.label} schema") oldSchema compare newSchema } } @@ -248,22 +151,4 @@ object GraphQLSchemaPlugin extends AutoPlugin { } } - private val graphqlRenderSchemaTask = Def.inputTaskDyn[File] { - val log = streams.value.log - val label = singleSchemaLabelParser.parsed - val file = (target in graphqlRenderSchema).value / s"$label.graphql" - val schemaDefinition = graphqlSchemas.value.schemaByLabel.getOrElse( - label, - sys.error(s"The schema '$label' is not defined in graphqlSchemas") - ) - log.info(s"Rendering schema to: ${file.getPath}") - - Def.task { - val schema = schemaDefinition.schemaTask.value - IO.write(file, schema.renderPretty) - file - } - - } - } diff --git a/src/main/scala/rocks/muki/graphql/codegen/ApolloSourceGenerator.scala b/src/main/scala/rocks/muki/graphql/codegen/ApolloSourceGenerator.scala new file mode 100644 index 0000000..e7fca50 --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/codegen/ApolloSourceGenerator.scala @@ -0,0 +1,279 @@ +/* + * Copyright 2017 Mediative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rocks.muki.graphql.codegen + +import sangria.schema + +import scala.meta._ + +/** + * Generate code using Scalameta. + */ +case class ApolloSourceGenerator(fileName: String, + additionalImports: List[Import], + additionalInits: List[Init]) + extends Generator[List[Stat]] { + + override def apply(document: TypedDocument.Api): Result[List[Stat]] = { + + // TODO refactor Generator trait into something more flexible + + val operations = document.operations.map { operation => + val typeName = Term.Name( + operation.name.getOrElse(throw new IllegalArgumentException( + "Anonymous operations are not support"))) + // TODO input variables can be recursive. Generate case classes along + val inputParams = generateFieldParams(operation.variables, List.empty) + val dataParams = + generateFieldParams(operation.selection.fields, List.empty) + val data = + operation.selection.fields.flatMap(selectionStats(_, List.empty)) + + // render the document into the query object. + // replacing single $ with $$ for escaping + val escapedDocumentString = + operation.original.renderPretty.replaceAll("\\$", "\\$\\$") + val document = Term.Interpolate(Term.Name("graphql"), + Lit.String(escapedDocumentString) :: Nil, + Nil) + + q""" + object $typeName extends ..$additionalInits { + val Document = $document + case class Variables(..$inputParams) + case class Data(..$dataParams) + ..$data + }""" + } + val interfaces = document.interfaces.map(generateInterface) + val types = document.types.flatMap(generateType) + val objectName = fileName.replaceAll("\\.graphql$|\\.gql$", "") + + Right( + additionalImports ++ + List( + q"import sangria.macros._", + q""" + object ${Term.Name(objectName)} { + ..$operations + ..$interfaces + ..$types + } + """ + )) + } + + private def selectionStats(field: TypedDocument.Field, + typeQualifiers: List[String]): List[Stat] = + field match { + // render enumerations (union types) + case TypedDocument.Field(name, _, None, unionTypes) + if unionTypes.nonEmpty => + // create the union types + + val unionName = Type.Name(name.capitalize) + val unionCompanionObject = Term.Name(unionName.value) + val unionTrait = generateTemplate(List(unionName.value)) + + // create concrete case classes for each union type + val unionValues = unionTypes.flatMap { + case TypedDocument.UnionSelection(unionType, unionSelection) => + // get nested selections + val innerSelections = unionSelection.fields.flatMap(field => + selectionStats(field, List.empty)) + val params = generateFieldParams(unionSelection.fields, + typeQualifiers :+ unionName.value) + val unionTypeName = Type.Name(unionType.name) + val unionTermName = Term.Name(unionType.name) + + List(q"case class $unionTypeName(..$params) extends $unionTrait") ++ + Option(innerSelections) + .filter(_.nonEmpty) + .map { stats => + q"object $unionTermName { ..$stats }" + } + .toList + } + + List[Stat]( + q"sealed trait $unionName", + q"object $unionCompanionObject { ..$unionValues }" + ) + + // render a nested case class for a deeper selection + case TypedDocument.Field(name, tpe, Some(fieldSelection), _) => + // Recursive call - create more case classes + + val fieldName = Type.Name(name.capitalize) + val termName = Term.Name(name.capitalize) + val template = generateTemplate(fieldSelection.interfaces) + + // The inner stats don't require the typeQualifiers as they are packed into a separate + // object, which is like a fresh start. + val innerStats = + fieldSelection.fields.flatMap(selectionStats(_, List.empty)) + + // Add + val params = generateFieldParams(fieldSelection.fields, + typeQualifiers :+ termName.value) + List( + // "// nice comment".parse[Stat].get, + q"case class $fieldName(..$params) extends $template" + ) ++ Option(innerStats).filter(_.nonEmpty).map { stats => + q"object $termName { ..$stats }" + } + case TypedDocument.Field(_, _, _, _) => + // scalar types, e.g. String, Option, List + List.empty + } + + private def generateFieldParams( + fields: List[TypedDocument.Field], + typeQualifiers: List[String]): List[Term.Param] = + fields.map { field => + val tpe = parameterFieldType(field, typeQualifiers) + termParam(field.name, tpe) + } + + private def termParam(paramName: String, tpe: Type) = + Term.Param(List.empty, Term.Name(paramName), Some(tpe), None) + + /** + * Turns a Type + * @param field + * @return + */ + private def parameterFieldType(field: TypedDocument.Field, + typeQualifiers: List[String]): Type = + generateFieldType(field) { tpe => + if (field.isObjectLike || field.isUnion) { + // prepend the type qualifier for nested object/case class structures + Type.Name((typeQualifiers :+ field.name.capitalize).mkString(".")) + } else { + // this branch handles non-enum or case class types, which means we don't need the + // typeQualifiers here. + Type.Name(tpe.namedType.name) + } + } + + /** + * Generates a scala meta Type from a TypeDocument.Field + * + * @param field + * @param genType + * @return scala type + */ + private def generateFieldType(field: TypedDocument.Field)( + genType: schema.Type => Type): Type = { + // recursive function + def typeOf(tpe: schema.Type): Type = tpe match { + case schema.OptionType(wrapped) => + t"Option[${typeOf(wrapped)}]" + case schema.OptionInputType(wrapped) => + t"Option[${typeOf(wrapped)}]" + case schema.ListType(wrapped) => + t"List[${typeOf(wrapped)}]" + case schema.ListInputType(wrapped) => + t"List[${typeOf(wrapped)}]" + case tpe: schema.ScalarType[_] if tpe == schema.IDType => + Type.Name("ID") + case tpe: schema.Type => + genType(tpe) + } + typeOf(field.tpe) + } + + private def generateTemplate(traits: List[String]): Template = { + + // val ctorNames = traits.map(Ctor.Name.apply) + val emptySelf = Self(Name.Anonymous(), None) + val templateInits = + traits.map(name => Init(Type.Name(name), Name.Anonymous(), Nil)) + Template(early = Nil, inits = templateInits, emptySelf, stats = Nil) + } + + private def generateInterface(interface: TypedDocument.Interface): Stat = { + val defs = interface.fields.map { field => + val fieldName = Term.Name(field.name) + val tpe = generateFieldType(field) { tpe => + field.selection.map(_.interfaces).filter(_.nonEmpty) match { + case Some(interfaces) => + interfaces.map(x => Type.Name(x): Type).reduce(Type.With(_, _)) + case None => + Type.Name(tpe.namedType.name) + } + } + q"def $fieldName: $tpe" + } + val traitName = Type.Name(interface.name) + q"trait $traitName { ..$defs }" + } + + private def generateObject(obj: TypedDocument.Object, + interfaces: List[String]): Stat = { + val params = obj.fields.map { field => + val tpe = generateFieldType(field)(t => Type.Name(t.namedType.name)) + termParam(field.name, tpe) + } + val className = Type.Name(obj.name) + val template = generateTemplate(interfaces) + q"case class $className(..$params) extends $template": Stat + } + + /** + * Generates the general types for this document. + * + * @param tree input node + * @return generated code + */ + private def generateType(tree: TypedDocument.Type): List[Stat] = tree match { + case interface: TypedDocument.Interface => + List(generateInterface(interface)) + + case obj: TypedDocument.Object => + List(generateObject(obj, List.empty)) + + case TypedDocument.Enum(name, values) => + val enumValues = values.map { value => + val template = generateTemplate(List(name)) + val valueName = Term.Name(value) + q"case object $valueName extends $template" + } + + val enumName = Type.Name(name) + val objectName = Term.Name(name) + List[Stat]( + q"sealed trait $enumName", + q"object $objectName { ..$enumValues }" + ) + + case TypedDocument.TypeAlias(from, to) => + val alias = Type.Name(from) + val underlying = Type.Name(to) + List(q"type $alias = $underlying": Stat) + + case TypedDocument.Union(name, types) => + val unionValues = types.map(obj => generateObject(obj, List(name))) + val unionName = Type.Name(name) + val objectName = Term.Name(name) + List[Stat]( + q"sealed trait $unionName", + q"object $objectName { ..$unionValues }" + ) + } + +} diff --git a/src/main/scala/rocks/muki/graphql/codegen/CodeGenContext.scala b/src/main/scala/rocks/muki/graphql/codegen/CodeGenContext.scala new file mode 100644 index 0000000..5d3d3a2 --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/codegen/CodeGenContext.scala @@ -0,0 +1,27 @@ +package rocks.muki.graphql.codegen + +import java.io.File + +import sangria.schema.Schema +import sbt.Logger + +/** + * == CodeGen Context == + * + * Initial context to kickoff code generation. + * + * @param schema the graphql schema + * @param targetDirectory the target directory where the source code will be placed + * @param graphQLFiles input files that should be processed + * @param packageName the scala package name + * @param moduleName optional module name for single-file based generators + * @param log output log + */ +case class CodeGenContext( + schema: Schema[_, _], + targetDirectory: File, + graphQLFiles: Seq[File], + packageName: String, + moduleName: String, + log: Logger +) diff --git a/src/main/scala/rocks/muki/graphql/codegen/CodeGenStyles.scala b/src/main/scala/rocks/muki/graphql/codegen/CodeGenStyles.scala new file mode 100644 index 0000000..618fbef --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/codegen/CodeGenStyles.scala @@ -0,0 +1,113 @@ +package rocks.muki.graphql.codegen + +import java.io.File + +import sangria.schema.Schema +import sbt._ + +import scala.meta._ +import scala.util.Success + +/** + * == CodeGen Styles == + * + * Object that contains different code generation styles + * + */ +object CodeGenStyles { + + type Style = (CodeGenContext) => Seq[File] + + /** + * == Apollo CodeGen style == + * + * Generates a source file per graphql input file. + * Every query will extend the `GraphQLQuery` trait to allow a generic client implementation. + * + */ + val Apollo: Style = context => { + val schema = context.schema + val inputFiles = context.graphQLFiles + val packageName = Term.Name(context.packageName) + + // Generate the GraphQLQuery trait + val graphQLQueryFile = context.targetDirectory / s"${GraphQLQueryGenerator.name}.scala" + SourceCodeWriter.write( + graphQLQueryFile, + GraphQLQueryGenerator.sourceCode(context.packageName)) + + val additionalImports = GraphQLQueryGenerator.imports(context.packageName) + val additionalInits = GraphQLQueryGenerator.inits + + // Process all the graphql files + val files = inputFiles.map { inputFile => + for { + queryDocument <- DocumentLoader.single(schema, inputFile) + typedDocument <- TypedDocumentParser(schema, queryDocument) + .parse() + sourceCode <- ApolloSourceGenerator(inputFile.getName, + additionalImports, + additionalInits)(typedDocument) + } yield { + val stats = + q"""package $packageName { + ..$sourceCode + }""" + + val outputFile = SourceCodeWriter.write(context, inputFile, stats) + context.log.info(s"Generated source $outputFile from $inputFile ") + outputFile + } + } + + // split errors and success + val success = files.collect { + case Right(file) => file + } + + val errors = files.collect { + case Left(error) => error + } + + if (errors.nonEmpty) { + context.log.err(s"${errors.size} error(s) during code generation") + errors.foreach(error => context.log.error(error.message)) + sys.error("Code generation failed") + } + + // return all generated files + success :+ graphQLQueryFile + } + + val Sangria: Style = context => { + val schema = context.schema + val inputFiles = context.graphQLFiles + val packageName = Term.Name(context.packageName) + + val result = for { + queryDocument <- DocumentLoader.merged(schema, inputFiles.toList) + typedDocument <- TypedDocumentParser(schema, queryDocument) + .parse() + sourceCode <- ScalametaGenerator(context.moduleName)(typedDocument) + } yield { + val sourceCodeStats: List[Stat] = List(sourceCode) + val pkg = + q"""package $packageName { + ..$sourceCodeStats + }""" + + val outputFile = context.targetDirectory / s"${context.moduleName.capitalize}.scala" + SourceCodeWriter.write(outputFile, pkg) + } + + result match { + case Right(file) => + List(file) + case Left(error) => + context.log.err(s"Error during code genreation $error") + sys.error("Code generation failed") + } + + } + +} diff --git a/src/main/scala/rocks/muki/graphql/codegen/DocumentLoader.scala b/src/main/scala/rocks/muki/graphql/codegen/DocumentLoader.scala new file mode 100644 index 0000000..6173c39 --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/codegen/DocumentLoader.scala @@ -0,0 +1,85 @@ +/* + * Copyright 2017 Mediative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rocks.muki.graphql.codegen + +import java.io.File +import scala.io.Source +import cats.syntax.either._ +import sangria.parser.QueryParser +import sangria.validation.QueryValidator +import sangria.schema._ +import sangria.ast.Document + +object DocumentLoader { + + /** + * Loads and parses all files and merge them into a single document + * @param schema used to validate parsed files + * @param files the files that should be loaded + * @return + */ + def merged(schema: Schema[_, _], files: List[File]): Result[Document] = { + files + .map(single(schema, _)) + .foldLeft[Result[Document]](Right(Document.emptyStub)) { + case (Left(failure), Left(nextFailure)) => + Left(Failure(failure.message + "\n" + nextFailure.message)) + case (Left(failure), _) => Left(failure) + case (_, Left(firstFailure)) => Left(firstFailure) + case (Right(document), Right(nextDocument)) => + Right(document.merge(nextDocument)) + } + } + + /** + * Load a single, validated query file. + * @param schema + * @param file + * @return + */ + def single(schema: Schema[_, _], file: File): Result[Document] = { + for { + document <- parseDocument(file) + violations = QueryValidator.default.validateQuery(schema, document) + _ <- Either.cond( + violations.isEmpty, + document, + Failure( + s"Invalid query: ${violations.map(_.errorMessage).mkString(", ")}")) + } yield document + } + + private def parseSchema(file: File): Result[Schema[_, _]] = + for { + document <- parseDocument(file) + schema <- Either.catchNonFatal(Schema.buildFromAst(document)).leftMap { + error => + Failure(s"Failed to read schema $file: ${error.getMessage}") + } + } yield schema + + private def parseDocument(file: File): Result[Document] = + for { + input <- Either.catchNonFatal(Source.fromFile(file).mkString).leftMap { + error => + Failure(s"Failed to read $file: ${error.getMessage}") + } + document <- Either.fromTry(QueryParser.parse(input)).leftMap { error => + Failure(s"Failed to parse $file: ${error.getMessage}") + } + } yield document +} diff --git a/src/main/scala/rocks/muki/graphql/codegen/Failure.scala b/src/main/scala/rocks/muki/graphql/codegen/Failure.scala new file mode 100644 index 0000000..a3b25f4 --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/codegen/Failure.scala @@ -0,0 +1,22 @@ +/* + * Copyright 2017 Mediative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rocks.muki.graphql.codegen + +/** + * A generic error type for codegen failures. + */ +case class Failure(message: String) extends Exception(message) diff --git a/src/main/scala/rocks/muki/graphql/codegen/Generator.scala b/src/main/scala/rocks/muki/graphql/codegen/Generator.scala new file mode 100644 index 0000000..c601778 --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/codegen/Generator.scala @@ -0,0 +1,22 @@ +/* + * Copyright 2017 Mediative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rocks.muki.graphql.codegen + +/** + * Generate a result from the loaded tree. + */ +trait Generator[T] extends (TypedDocument.Api => Result[T]) diff --git a/src/main/scala/rocks/muki/graphql/codegen/GraphQLQueryGenerator.scala b/src/main/scala/rocks/muki/graphql/codegen/GraphQLQueryGenerator.scala new file mode 100644 index 0000000..4e06543 --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/codegen/GraphQLQueryGenerator.scala @@ -0,0 +1,69 @@ +package rocks.muki.graphql.codegen + +import scala.meta._ + +/** + * Provides a `GraphQLQuery` trait. This is heavily inspired by the apollo scalajs code generator. + * + * + * {{{ + * trait GraphQLQuery { + * // the graphql document that should be executed + * type Document + * + * // the input variables + * type Variables + * + * // the returned data + * type Data + * } + * }}} + */ +object GraphQLQueryGenerator { + + val name = "GraphQLQuery" + val termName: Term.Name = Term.Name(name) + val typeName: Type.Name = Type.Name(name) + + private val traitDefinition: Defn.Trait = + q"""trait $typeName { + type Document + type Variables + type Data + } + """ + + /** + * Generates the actual source code. + * + * @param packageName the package in which the GraphQLQuery trait should be rendered + * @return the GraphQLTrait source code + */ + def sourceCode(packageName: String): Pkg = + q"""package ${Term.Name(packageName)} { + $traitDefinition + }""" + + /** + * Add these imports to your generated code. + * + * @param packageName the GraphQLQuery package + * @return + */ + def imports(packageName: String): List[Import] = { + val importer = + Importer(Term.Name(packageName), List(Importee.Name(Name(name)))) + List( + q"import ..${List(importer)}" + ) + } + + /** + * Scala meta `Init` definitions. Use these to extend a generated class with the + * GraphQLQuery trait. + */ + val inits: List[Init] = List( + Init(GraphQLQueryGenerator.typeName, Name.Anonymous(), Nil) + ) + +} diff --git a/src/main/scala/rocks/muki/graphql/codegen/ScalaSourceGenerator.scala b/src/main/scala/rocks/muki/graphql/codegen/ScalaSourceGenerator.scala new file mode 100644 index 0000000..0dbbd3d --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/codegen/ScalaSourceGenerator.scala @@ -0,0 +1,253 @@ +/* + * Copyright 2017 Mediative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rocks.muki.graphql.codegen + +import scala.meta._ +import sangria.schema + +/** + * Generate code using Scalameta. + */ +case class ScalametaGenerator(moduleName: Term.Name, + emitInterfaces: Boolean = false, + stats: List[Stat] = List.empty) + extends Generator[Defn.Object] { + + override def apply(api: TypedDocument.Api): Result[Defn.Object] = { + val operations = api.operations.flatMap(generateOperation) + val fragments = + if (emitInterfaces) + api.interfaces.map(generateInterface) + else + List.empty + val types = api.types.flatMap(generateType) + + Right( + q""" + object $moduleName { + ..$operations + ..$fragments + ..$types + ..$stats + } + """ + ) + } + + def termParam(paramName: String, tpe: Type) = + Term.Param(List.empty, Term.Name(paramName), Some(tpe), None) + + def generateTemplate(traits: List[String], + prefix: String = moduleName.value + "."): Template = { + // TODO fix constructor names + val templateInits = traits + .map(prefix + _) + .map(name => Init(Type.Name(name), Name.Anonymous(), Nil)) + val emptySelf = Self(Name.Anonymous(), None) + + Template(Nil, templateInits, emptySelf, List.empty) + } + + def generateFieldType(field: TypedDocument.Field)( + genType: schema.Type => Type): Type = { + def typeOf(tpe: schema.Type): Type = tpe match { + case schema.OptionType(wrapped) => + t"Option[${typeOf(wrapped)}]" + case schema.OptionInputType(wrapped) => + t"Option[${typeOf(wrapped)}]" + case schema.ListType(wrapped) => + t"List[${typeOf(wrapped)}]" + case schema.ListInputType(wrapped) => + t"List[${typeOf(wrapped)}]" + case tpe: schema.ScalarType[_] if tpe == schema.IDType => + Type.Name(moduleName.value + ".ID") + case tpe: schema.Type => + genType(tpe) + } + typeOf(field.tpe) + } + + def generateOperation(operation: TypedDocument.Operation): List[Stat] = { + def fieldType(field: TypedDocument.Field, prefix: String = ""): Type = + generateFieldType(field) { tpe => + if (field.isObjectLike || field.isUnion) + Type.Name(prefix + field.name.capitalize) + else + Type.Name(tpe.namedType.name) + } + + def generateSelectionParams(prefix: String)( + selection: TypedDocument.Selection): List[Term.Param] = + selection.fields.map { field => + val tpe = fieldType(field, prefix) + termParam(field.name, tpe) + } + + def generateSelectionStats(prefix: String)( + selection: TypedDocument.Selection): List[Stat] = + selection.fields.flatMap { + // render enumerations (union types) + case TypedDocument.Field(name, tpe, None, unionTypes) + if unionTypes.nonEmpty => + val unionName = Type.Name(name.capitalize) + val objectName = Term.Name(unionName.value) + val template = generateTemplate(List(unionName.value), prefix) + val unionValues = unionTypes.flatMap { + case TypedDocument.UnionSelection(unionType, unionSelection) => + val path = prefix + unionName.value + "." + unionType.name + "." + val stats = generateSelectionStats(path)(unionSelection) + val params = generateSelectionParams(path)(unionSelection) + val tpeName = Type.Name(unionType.name) + val termName = Term.Name(unionType.name) + + List(q"case class $tpeName(..$params) extends $template") ++ + Option(stats) + .filter(_.nonEmpty) + .map { stats => + q"object $termName { ..$stats }" + } + .toList + } + + List[Stat]( + q"sealed trait $unionName", + q"object $objectName { ..$unionValues }" + ) + + // render a nested case class for a deeper selection + case TypedDocument.Field(name, tpe, Some(selection), _) => + val stats = + generateSelectionStats(prefix + name.capitalize + ".")(selection) + val params = + generateSelectionParams(prefix + name.capitalize + ".")(selection) + + val tpeName = Type.Name(name.capitalize) + val termName = Term.Name(name.capitalize) + val interfaces = + if (emitInterfaces) selection.interfaces + else List.empty + val template = generateTemplate(interfaces) + + List(q"case class $tpeName(..$params) extends $template") ++ + Option(stats) + .filter(_.nonEmpty) + .map { stats => + q"object $termName { ..$stats }" + } + .toList + + case TypedDocument.Field(_, _, _, _) => + List.empty + } + + val variables = operation.variables.map { varDef => + termParam(varDef.name, fieldType(varDef)) + } + + val name = operation.name.getOrElse(sys.error("found unnamed operation")) + val prefix = moduleName.value + "." + name + "." + val stats = generateSelectionStats(prefix)(operation.selection) + val params = generateSelectionParams(prefix)(operation.selection) + + val tpeName = Type.Name(name) + val termName = Term.Name(name) + val variableTypeName = Type.Name(name + "Variables") + + List[Stat]( + q"case class $tpeName(..$params)", + q""" + object $termName { + case class $variableTypeName(..$variables) + ..$stats + } + """ + ) + } + + def generateInterface(interface: TypedDocument.Interface): Stat = { + val defs = interface.fields.map { field => + val fieldName = Term.Name(field.name) + val tpe = generateFieldType(field) { tpe => + field.selection.map(_.interfaces).filter(_.nonEmpty) match { + case Some(interfaces) => + interfaces.map(x => Type.Name(x): Type).reduce(Type.With(_, _)) + case None => + Type.Name(tpe.namedType.name) + } + } + q"def $fieldName: $tpe" + } + val traitName = Type.Name(interface.name) + q"trait $traitName { ..$defs }" + } + + def generateObject(obj: TypedDocument.Object, + interfaces: List[String]): Stat = { + val params = obj.fields.map { field => + val tpe = generateFieldType(field)(t => Type.Name(t.namedType.name)) + termParam(field.name, tpe) + } + val className = Type.Name(obj.name) + val template = generateTemplate(interfaces) + q"case class $className(..$params) extends $template": Stat + } + + def generateType(tree: TypedDocument.Type): List[Stat] = tree match { + case interface: TypedDocument.Interface => + if (emitInterfaces) + List(generateInterface(interface)) + else + List.empty + + case obj: TypedDocument.Object => + List(generateObject(obj, List.empty)) + + case TypedDocument.Enum(name, values) => + val enumValues = values.map { value => + val template = generateTemplate(List(name)) + val valueName = Term.Name(value) + q"case object $valueName extends $template" + } + + val enumName = Type.Name(name) + val objectName = Term.Name(name) + List[Stat]( + q"sealed trait $enumName", + q"object $objectName { ..$enumValues }" + ) + + case TypedDocument.TypeAlias(from, to) => + val alias = Type.Name(from) + val underlying = Type.Name(to) + List(q"type $alias = $underlying": Stat) + + case TypedDocument.Union(name, types) => + val unionValues = types.map(obj => generateObject(obj, List(name))) + val unionName = Type.Name(name) + val objectName = Term.Name(name) + List[Stat]( + q"sealed trait $unionName", + q"object $objectName { ..$unionValues }" + ) + } + +} + +object ScalametaGenerator { + def apply(moduleName: String): ScalametaGenerator = + ScalametaGenerator(Term.Name(moduleName)) +} diff --git a/src/main/scala/rocks/muki/graphql/codegen/SourceCodeWriter.scala b/src/main/scala/rocks/muki/graphql/codegen/SourceCodeWriter.scala new file mode 100644 index 0000000..a0f1a05 --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/codegen/SourceCodeWriter.scala @@ -0,0 +1,48 @@ +package rocks.muki.graphql.codegen + +import sbt._ +import java.io.File + +import scala.meta._ + +/** + * == Source Code Writer == + * + * Writes scalameta ASTs to scala source files. + * + */ +object SourceCodeWriter { + + /** + * Writes the `sourceCode` to a new scala source file. + * The source file is produced by + * + * + * @param context code generation context + * @param graphqlFile the input graphql file + * @param sourceCode the generated source code + * @return the generated scala source file + */ + def write(context: CodeGenContext, + graphqlFile: File, + sourceCode: Pkg): File = { + val fileName = graphqlFile.getName + .replaceAll("\\.graphql$|\\.gql$", ".scala") + .capitalize + val outputFile = context.targetDirectory / fileName + IO.write(outputFile, sourceCode.show[Syntax]) + outputFile + } + + /** + * Writes the `sourceCode` to the given `file`. + * @param file the destination file + * @param sourceCode the source code + * @return the target file + */ + def write(file: File, sourceCode: Pkg): File = { + IO.write(file, sourceCode.show[Syntax]) + file + } + +} diff --git a/src/main/scala/rocks/muki/graphql/codegen/TypedDocument.scala b/src/main/scala/rocks/muki/graphql/codegen/TypedDocument.scala new file mode 100644 index 0000000..3c95ad4 --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/codegen/TypedDocument.scala @@ -0,0 +1,102 @@ +/* + * Copyright 2017 Mediative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rocks.muki.graphql.codegen + +import sangria.{ast, schema} + +/** + * AST representing the extracted GraphQL types. + */ +sealed trait TypedDocument +object TypedDocument { + + /** + * Selcted GraphQL field + * + * @param name - the field name + * @param tpe - the field type extraced from the schema + * @param selection - + * @param union + */ + case class Field(name: String, + tpe: schema.Type, + selection: Option[Selection] = None, + union: List[UnionSelection] = List.empty) + extends TypedDocument { + def isObjectLike = selection.nonEmpty + def isUnion = union.nonEmpty + } + + case class Selection(fields: List[Field], + interfaces: List[String] = List.empty) + extends TypedDocument { + def +(that: Selection) = + Selection((this.fields ++ that.fields).distinct, + this.interfaces ++ that.interfaces) + } + object Selection { + final val empty = Selection(List.empty) + def apply(field: Field): Selection = + Selection(List(field)) + } + + case class UnionSelection(tpe: schema.ObjectType[_, _], selection: Selection) + extends TypedDocument + + /** + * Operations represent API calls and are the entry points to the API. + * + * @param name the operation name + * @param variables input variables + * @param selection the selected fields + * @param original the original sangria OperationDefinition + * + */ + case class Operation(name: Option[String], + variables: List[Field], + selection: Selection, + original: ast.OperationDefinition) + extends TypedDocument + + /** + * Marker trait for GraphQL input and output types. + */ + sealed trait Type extends TypedDocument { + def name: String + } + case class Object(name: String, fields: List[Field]) extends Type + case class Interface(name: String, fields: List[Field]) extends Type + case class Enum(name: String, values: List[String]) extends Type + case class TypeAlias(name: String, tpe: String) extends Type + case class Union(name: String, types: List[Object]) extends Type + + /** + * The API based on one or more GraphQL query documents using a given schema. + * + * It includes only the operations, interfaces and input/output types + * referenced in the query documents. + * + * @param operations all operations + * @param interfaces defined interfaces + * @param types all types that are not operations related. This includes predefined types (e.g. ID) + * and input variable types. + * + */ + case class Api(operations: List[Operation], + interfaces: List[Interface], + types: List[Type]) +} diff --git a/src/main/scala/rocks/muki/graphql/codegen/TypedDocumentParser.scala b/src/main/scala/rocks/muki/graphql/codegen/TypedDocumentParser.scala new file mode 100644 index 0000000..4494471 --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/codegen/TypedDocumentParser.scala @@ -0,0 +1,199 @@ +/* + * Copyright 2017 Mediative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rocks.muki.graphql.codegen + +import sangria.validation.TypeInfo +import sangria.schema._ +import sangria.ast + +case class TypedDocumentParser(schema: Schema[_, _], document: ast.Document) { + + /** + * We need the schema to generate any type info from a parsed ast.Document + */ + private val typeInfo = new TypeInfo(schema) + + /** + * Aggregates all types seen during the document parsing + */ + private val types = scala.collection.mutable.Set[Type]() + + def parse(): Result[TypedDocument.Api] = + Right( + TypedDocument.Api( + document.operations.values.map(generateOperation).toList, + document.fragments.values.toList.map(generateFragment), + // Include only types that have been used in the document + schema.typeList.filter(types).collect(generateType).toList + )) + + /** + * Marks a schema type so it is added to the imported AST. + * + * Must be explicitly called for each type that a field references. For example, + * to generate a field which has an enum type this method should be called. + */ + private def touchType(tpe: Type): Unit = tpe.namedType match { + case IDType => + types += tpe + () + case _: ScalarType[_] => + // Nothing + () + case input: InputObjectType[_] => + types += input + input.fields.foreach(field => touchType(field.fieldType)) + case union: UnionType[_] => + types += union + union.types.flatMap(_.fields.map(_.fieldType)).foreach(touchType) + case underlying: OutputType[_] => + types += underlying + () + } + + private def generateSelections( + selections: Vector[ast.Selection], + typeConditions: Set[Type] = Set.empty): TypedDocument.Selection = + selections + .map(generateSelection(typeConditions)) + .foldLeft(TypedDocument.Selection.empty)(_ + _) + + private def generateSelection(typeConditions: Set[Type])( + node: ast.Selection): TypedDocument.Selection = { + def conditionalFragment( + f: => TypedDocument.Selection): TypedDocument.Selection = + if (typeConditions.isEmpty || typeConditions(typeInfo.tpe.get)) + f + else + TypedDocument.Selection.empty + + typeInfo.enter(node) + val result = node match { + case field: ast.Field => + require(typeInfo.tpe.isDefined, s"Field without type: $field") + val tpe = typeInfo.tpe.get + tpe.namedType match { + case union: UnionType[_] => + val types = union.types.map { tpe => + // Prepend the union type name to include and descend into fragment spreads + val conditions = Set[Type](union, tpe) ++ tpe.interfaces + val selection = generateSelections(field.selections, conditions) + TypedDocument.UnionSelection(tpe, selection) + } + TypedDocument.Selection( + TypedDocument.Field(field.outputName, tpe, union = types)) + + case obj @ (_: ObjectLikeType[_, _] | _: InputObjectType[_]) => + val gen = generateSelections(field.selections) + TypedDocument.Selection( + TypedDocument + .Field(field.outputName, tpe, selection = Some(gen))) + + case _ => + touchType(tpe) + TypedDocument.Selection(TypedDocument.Field(field.outputName, tpe)) + } + + case fragmentSpread: ast.FragmentSpread => + val name = fragmentSpread.name + val fragment = document.fragments(fragmentSpread.name) + // Sangria's TypeInfo abstraction does not resolve fragment spreads + // when traversing, so explicitly enter resolved fragment. + typeInfo.enter(fragment) + val result = conditionalFragment( + generateSelections(fragment.selections, typeConditions) + .copy(interfaces = List(name))) + typeInfo.leave(fragment) + result + + case inlineFragment: ast.InlineFragment => + conditionalFragment(generateSelections(inlineFragment.selections)) + + case unknown => + sys.error("Unknown selection: " + unknown.toString) + } + typeInfo.leave(node) + result + } + + private def generateOperation( + operation: ast.OperationDefinition): TypedDocument.Operation = { + typeInfo.enter(operation) + val variables = operation.variables.toList.map { varDef => + schema.getInputType(varDef.tpe) match { + case Some(tpe) => + touchType(tpe) + TypedDocument.Field(varDef.name, tpe) + case None => + sys.error("Unknown input type: " + varDef.tpe) + } + } + + val selection = generateSelections(operation.selections) + typeInfo.leave(operation) + TypedDocument.Operation(operation.name, variables, selection, operation) + } + + private def generateFragment( + fragment: ast.FragmentDefinition): TypedDocument.Interface = { + typeInfo.enter(fragment) + val selection = generateSelections(fragment.selections) + typeInfo.leave(fragment) + TypedDocument.Interface(fragment.name, selection.fields) + } + + private def generateObject(obj: ObjectType[_, _]): TypedDocument.Object = { + val fields = obj.uniqueFields.map { field => + touchType(field.fieldType) + TypedDocument.Field(field.name, field.fieldType) + } + TypedDocument.Object(obj.name, fields.toList) + } + + /** + * Map from a sangria schema.Type to a + * @return + */ + private def generateType: PartialFunction[Type, TypedDocument.Type] = { + case interface: InterfaceType[_, _] => + val fields = interface.uniqueFields.map { field => + touchType(field.fieldType) + TypedDocument.Field(field.name, field.fieldType) + } + TypedDocument.Interface(interface.name, fields.toList) + + case obj: ObjectType[_, _] => + generateObject(obj) + + case enum: EnumType[_] => + val values = enum.values.map(_.name) + TypedDocument.Enum(enum.name, values) + + case union: UnionType[_] => + TypedDocument.Union(union.name, union.types.map(generateObject)) + + case inputObj: InputObjectType[_] => + val fields = inputObj.fields.map { field => + touchType(field.fieldType) + TypedDocument.Field(field.name, field.fieldType) + } + TypedDocument.Object(inputObj.name, fields) + + case IDType => + TypedDocument.TypeAlias("ID", "String") + } +} diff --git a/src/main/scala/rocks/muki/graphql/codegen/package.scala b/src/main/scala/rocks/muki/graphql/codegen/package.scala new file mode 100644 index 0000000..653f0a0 --- /dev/null +++ b/src/main/scala/rocks/muki/graphql/codegen/package.scala @@ -0,0 +1,21 @@ +/* + * Copyright 2017 Mediative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rocks.muki.graphql + +package object codegen { + type Result[T] = Either[Failure, T] +} diff --git a/src/main/scala/rocks/muki/graphql/package.scala b/src/main/scala/rocks/muki/graphql/package.scala index 14a00c7..1d2d574 100644 --- a/src/main/scala/rocks/muki/graphql/package.scala +++ b/src/main/scala/rocks/muki/graphql/package.scala @@ -1,10 +1,70 @@ package rocks.muki +import rocks.muki.graphql.GraphQLPlugin.autoImport.graphqlSchemas +import rocks.muki.graphql.schema.{GraphQLSchema, GraphQLSchemas} +import sbt._ +import sbt.complete.DefaultParsers._ +import sbt.complete.{FixedSetExamples, Parser} + package object graphql { + /** + * Throw an exception without a stacktrace. + * + * @param msg the error message + * @return nothing - throws an exception + */ def quietError(msg: String): Nothing = { val exc = new RuntimeException(msg) exc.setStackTrace(Array.empty) throw exc } + + /** + * @return a parser that parses exactly one schema l + */ + val singleGraphQLSchemaParser: Def.Initialize[Parser[GraphQLSchema]] = + Def.setting { + val gqlSchema = graphqlSchemas.value + val labels = gqlSchema.schemas.map(_.label) + // create a dependent parser. A label can only be selected once + schemaLabelParser(labels).map(label => schemaOrError(label, gqlSchema)) + } + + /** + * Parses two schema labels + */ + val tupleGraphQLSchemaParser + : Def.Initialize[Parser[(GraphQLSchema, GraphQLSchema)]] = + Def.setting { + val gqlSchemas = graphqlSchemas.value + val labels = gqlSchemas.schemas.map(_.label) + // create a depended parser. A label can only be selected once + schemaLabelParser(labels).flatMap { + case selectedLabel if labels.contains(selectedLabel) => + success(schemaOrError(selectedLabel, gqlSchemas)) ~ schemaLabelParser( + labels.filterNot(_ == selectedLabel)).map(label => + schemaOrError(label, gqlSchemas)) + case selectedLabel => + failure( + s"$selectedLabel is not available. Use: [${labels.mkString(" | ")}]") + } + } + + /** + * @param labels list of available schemas by label + * @return a parser for the given labels + */ + private[this] def schemaLabelParser( + labels: Iterable[String]): Parser[String] = { + val schemaParser = StringBasic.examples(FixedSetExamples(labels)) + token(Space.? ~> schemaParser) + } + + private def schemaOrError(label: String, + graphQLSchema: GraphQLSchemas): GraphQLSchema = + graphQLSchema.schemaByLabel.getOrElse( + label, + sys.error(s"The schema '$label' is not defined in graphqlSchemas")) + } diff --git a/src/main/scala/rocks/muki/graphql/schema/SchemaLoader.scala b/src/main/scala/rocks/muki/graphql/schema/SchemaLoader.scala index a0da083..125ce06 100644 --- a/src/main/scala/rocks/muki/graphql/schema/SchemaLoader.scala +++ b/src/main/scala/rocks/muki/graphql/schema/SchemaLoader.scala @@ -59,6 +59,7 @@ object SchemaLoader { class FileSchemaLoader(file: File) extends SchemaLoader { override def loadSchema(): Schema[Any, Any] = { + // TODO check if it's a json or graphql file and parse accordingly val schemaJson = IO.read(file) QueryParser.parse(schemaJson) match { case Success(document) => Schema.buildFromAst(document) diff --git a/src/sbt-test/codegen/apollo/build.sbt b/src/sbt-test/codegen/apollo/build.sbt new file mode 100644 index 0000000..de856fb --- /dev/null +++ b/src/sbt-test/codegen/apollo/build.sbt @@ -0,0 +1,12 @@ +name := "test" +enablePlugins(GraphQLCodegenPlugin) +scalaVersion := "2.12.4" + +graphqlCodegenStyle := Apollo + +TaskKey[Unit]("check") := { + val generatedFiles = (graphqlCodegen in Compile).value + val queryFile = generatedFiles.find(_.getName == "HeroNameQuery.scala") + + assert(queryFile.isDefined, s"Could not find generated scala class. Available files\n ${generatedFiles.mkString("\n ")}") +} diff --git a/src/sbt-test/codegen/apollo/project/plugins.sbt b/src/sbt-test/codegen/apollo/project/plugins.sbt new file mode 100644 index 0000000..231e0dd --- /dev/null +++ b/src/sbt-test/codegen/apollo/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("rocks.muki" % "sbt-graphql" % sys.props("project.version")) diff --git a/src/sbt-test/codegen/apollo/src/main/resources/HeroNameQuery.graphql b/src/sbt-test/codegen/apollo/src/main/resources/HeroNameQuery.graphql new file mode 100644 index 0000000..a8c9deb --- /dev/null +++ b/src/sbt-test/codegen/apollo/src/main/resources/HeroNameQuery.graphql @@ -0,0 +1,5 @@ +query HeroNameQuery { + hero { + name + } +} diff --git a/src/sbt-test/codegen/apollo/src/main/resources/schema.graphql b/src/sbt-test/codegen/apollo/src/main/resources/schema.graphql new file mode 100644 index 0000000..3fa59fe --- /dev/null +++ b/src/sbt-test/codegen/apollo/src/main/resources/schema.graphql @@ -0,0 +1,74 @@ +# A character in the Star Wars Trilogy +interface Character { + # The id of the character. + id: String! + + # The name of the character. + name: String + + # The friends of the character, or an empty list if they have none. + friends: [Character!]! + + # Which movies they appear in. + appearsIn: [Episode] +} + +# A mechanical creature in the Star Wars universe. +type Droid implements Character { + # The id of the droid. + id: String! + + # The name of the droid. + name: String + + # The friends of the droid, or an empty list if they have none. + friends: [Character!]! + + # Which movies they appear in. + appearsIn: [Episode] + + # The primary function of the droid. + primaryFunction: String +} + +# One of the films in the Star Wars Trilogy +enum Episode { + # Released in 1977. + NEWHOPE + + # Released in 1980. + EMPIRE + + # Released in 1983. + JEDI +} + +# A humanoid creature in the Star Wars universe. +type Human implements Character { + # The id of the human. + id: String! + + # The name of the human. + name: String + + # The friends of the human, or an empty list if they have none. + friends: [Character!]! + + # Which movies they appear in. + appearsIn: [Episode] + + # The home planet of the human, or null if unknown. + homePlanet: String +} + +type Query { + hero( + # If omitted, returns the hero of the whole saga. If provided, returns the hero of that particular episode. + episode: Episode): Character! @deprecated(reason: "Use `human` or `droid` fields instead") + human( + # id of the character + id: String!): Human + droid( + # id of the character + id: String!): Droid! +} diff --git a/src/sbt-test/codegen/apollo/test b/src/sbt-test/codegen/apollo/test new file mode 100644 index 0000000..762c625 --- /dev/null +++ b/src/sbt-test/codegen/apollo/test @@ -0,0 +1,2 @@ +> update +> check diff --git a/src/sbt-test/codegen/generate-named/build.sbt b/src/sbt-test/codegen/generate-named/build.sbt new file mode 100644 index 0000000..dc3e530 --- /dev/null +++ b/src/sbt-test/codegen/generate-named/build.sbt @@ -0,0 +1,27 @@ +import scala.sys.process._ + +name := "test" +enablePlugins(GraphQLCodegenPlugin) +scalaVersion := "2.12.4" + +graphqlCodegenStyle := Sangria + +val StarWarsDir = file(sys.props("codegen.samples.dir")) / "starwars" + +graphqlCodegenSchema := StarWarsDir / "schema.graphql" +resourceDirectories in graphqlCodegen += StarWarsDir +includeFilter in graphqlCodegen := "MultiQuery.graphql" +name in graphqlCodegen := "MultiQueryApi" + +TaskKey[Unit]("check") := { + val file = graphqlCodegen.value.head + val expected = StarWarsDir / "MultiQuery.scala" + + assert(file.exists) + + // Drop the package line before comparing + val compare = IO.readLines(file).drop(1).mkString("\n").trim == IO.read(expected).trim + if (!compare) + s"diff -u $expected $file".! + assert(compare, s"$file does not equal $expected") +} diff --git a/src/sbt-test/codegen/generate-named/project/plugins.sbt b/src/sbt-test/codegen/generate-named/project/plugins.sbt new file mode 100644 index 0000000..231e0dd --- /dev/null +++ b/src/sbt-test/codegen/generate-named/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("rocks.muki" % "sbt-graphql" % sys.props("project.version")) diff --git a/src/sbt-test/codegen/generate-named/test b/src/sbt-test/codegen/generate-named/test new file mode 100644 index 0000000..eb02e42 --- /dev/null +++ b/src/sbt-test/codegen/generate-named/test @@ -0,0 +1,3 @@ +> update +> compile +> check diff --git a/src/sbt-test/codegen/generate-schema-and-code/build.sbt b/src/sbt-test/codegen/generate-schema-and-code/build.sbt new file mode 100644 index 0000000..7b4f3e1 --- /dev/null +++ b/src/sbt-test/codegen/generate-schema-and-code/build.sbt @@ -0,0 +1,38 @@ +import scala.sys.process._ + +name := "test" +scalaVersion in ThisBuild := "2.12.4" + +val StarWarsDir = file(sys.props("codegen.samples.dir")) / "starwars" + +val server = project + .enablePlugins(GraphQLSchemaPlugin) + .settings( + libraryDependencies += "org.sangria-graphql" %% "sangria" % "1.3.2", + graphqlSchemaSnippet := + "com.example.starwars.TestSchema.StarWarsSchema" + ) + +val client = project + .enablePlugins(GraphQLCodegenPlugin) + .settings( + graphqlCodegenStyle := Sangria, + graphqlCodegenSchema := (graphqlSchemaGen in server).value, + resourceDirectories in graphqlCodegen += StarWarsDir, + includeFilter in graphqlCodegen := "MultiQuery.graphql", + graphqlCodegenPackage := "com.example.client.api", + name in graphqlCodegen := "MultiQueryApi" + ) + +TaskKey[Unit]("check") := { + val file = (graphqlCodegen in client).value.head + val expected = StarWarsDir / "MultiQuery.scala" + + assert(file.exists) + + // Drop the package line before comparing + val compare = IO.readLines(file).drop(1).mkString("\n").trim == IO.read(expected).trim + if (!compare) + s"diff -u $expected $file".! + assert(compare, s"$file does not equal $expected") +} diff --git a/src/sbt-test/codegen/generate-schema-and-code/client/src/main/scala/com.example.client/Main.scala b/src/sbt-test/codegen/generate-schema-and-code/client/src/main/scala/com.example.client/Main.scala new file mode 100644 index 0000000..54e4318 --- /dev/null +++ b/src/sbt-test/codegen/generate-schema-and-code/client/src/main/scala/com.example.client/Main.scala @@ -0,0 +1,11 @@ +package com.example.client + +import com.example.client.api.MultiQueryApi +import MultiQueryApi.HeroAndNestedFriends.Hero + +object Main { + def main(args: Array[String]): Unit = { + println(MultiQueryApi.Episode.JEDI) + println(Hero.Friends.Friends.Friends.Friends(name = Some("Far out friend"))) + } +} diff --git a/src/sbt-test/codegen/generate-schema-and-code/project/plugins.sbt b/src/sbt-test/codegen/generate-schema-and-code/project/plugins.sbt new file mode 100644 index 0000000..231e0dd --- /dev/null +++ b/src/sbt-test/codegen/generate-schema-and-code/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("rocks.muki" % "sbt-graphql" % sys.props("project.version")) diff --git a/src/sbt-test/codegen/generate-schema-and-code/server/src/main/scala/com.example.starwars/TestData.scala b/src/sbt-test/codegen/generate-schema-and-code/server/src/main/scala/com.example.starwars/TestData.scala new file mode 100644 index 0000000..6c1ccc8 --- /dev/null +++ b/src/sbt-test/codegen/generate-schema-and-code/server/src/main/scala/com.example.starwars/TestData.scala @@ -0,0 +1,123 @@ +/* + * Copyright 2017 Oleg Ilyenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.starwars + +import sangria.execution.deferred.{Deferred, DeferredResolver} + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.Try + +object TestData { + object Episode extends Enumeration { + val NEWHOPE, EMPIRE, JEDI = Value + } + + trait Character { + def id: String + def name: Option[String] + def friends: List[String] + def appearsIn: List[Episode.Value] + } + + case class Human( + id: String, + name: Option[String], + friends: List[String], + appearsIn: List[Episode.Value], + homePlanet: Option[String]) + extends Character + case class Droid( + id: String, + name: Option[String], + friends: List[String], + appearsIn: List[Episode.Value], + primaryFunction: Option[String]) + extends Character + + case class DeferFriends(friends: List[String]) extends Deferred[List[Option[Character]]] + + val characters = List[Character]( + Human( + id = "1000", + name = Some("Luke Skywalker"), + friends = List("1002", "1003", "2000", "2001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = Some("Tatooine") + ), + Human( + id = "1001", + name = Some("Darth Vader"), + friends = List("1004"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = Some("Tatooine")), + Human( + id = "1002", + name = Some("Han Solo"), + friends = List("1000", "1003", "2001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = None), + Human( + id = "1003", + name = Some("Leia Organa"), + friends = List("1000", "1002", "2000", "2001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = Some("Alderaan") + ), + Human( + id = "1004", + name = Some("Wilhuff Tarkin"), + friends = List("1001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = None), + Droid( + id = "2000", + name = Some("C-3PO"), + friends = List("1000", "1002", "1003", "2001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + primaryFunction = Some("Protocol") + ), + Droid( + id = "2001", + name = Some("R2-D2"), + friends = List("1000", "1002", "1003"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + primaryFunction = Some("Astromech") + ) + ) + + class FriendsResolver extends DeferredResolver[Any] { + override def resolve(deferred: Vector[Deferred[Any]], ctx: Any, queryState: Any)( + implicit ec: ExecutionContext) = deferred map { + case DeferFriends(friendIds) ⇒ + Future.fromTry(Try(friendIds map (id ⇒ characters.find(_.id == id)))) + } + } + + class CharacterRepo { + def getHero(episode: Option[Episode.Value]) = + episode flatMap (_ ⇒ getHuman("1000")) getOrElse characters.last + + def getHuman(id: String): Option[Human] = + characters.find(c ⇒ c.isInstanceOf[Human] && c.id == id).asInstanceOf[Option[Human]] + + def getDroid(id: String): Option[Droid] = + characters.find(c ⇒ c.isInstanceOf[Droid] && c.id == id).asInstanceOf[Option[Droid]] + + def getCharacters(ids: Seq[String]): Seq[Character] = + ids.flatMap(id ⇒ characters.find(_.id == id)) + } +} diff --git a/src/sbt-test/codegen/generate-schema-and-code/server/src/main/scala/com.example.starwars/TestSchema.scala b/src/sbt-test/codegen/generate-schema-and-code/server/src/main/scala/com.example.starwars/TestSchema.scala new file mode 100644 index 0000000..80b0dfe --- /dev/null +++ b/src/sbt-test/codegen/generate-schema-and-code/server/src/main/scala/com.example.starwars/TestSchema.scala @@ -0,0 +1,166 @@ +/* + * Copyright 2017 Oleg Ilyenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.starwars + +import sangria.execution.UserFacingError +import sangria.schema._ + +import scala.concurrent.Future + +object TestSchema { + import TestData._ + + case class PrivacyError(message: String) extends Exception(message) with UserFacingError + + val EpisodeEnum = EnumType( + "Episode", + Some("One of the films in the Star Wars Trilogy"), + List( + EnumValue( + "NEWHOPE", + value = TestData.Episode.NEWHOPE, + description = Some("Released in 1977.")), + EnumValue("EMPIRE", value = TestData.Episode.EMPIRE, description = Some("Released in 1980.")), + EnumValue("JEDI", value = TestData.Episode.JEDI, description = Some("Released in 1983.")))) + + val Character: InterfaceType[Unit, TestData.Character] = + InterfaceType( + "Character", + "A character in the Star Wars Trilogy", + () ⇒ + fields[Unit, TestData.Character]( + Field("id", StringType, Some("The id of the character."), resolve = _.value.id), + Field( + "name", + OptionType(StringType), + Some("The name of the character."), + resolve = _.value.name), + Field( + "friends", + OptionType(ListType(OptionType(Character))), + Some("The friends of the character, or an empty list if they have none."), + resolve = ctx ⇒ DeferFriends(ctx.value.friends) + ), + Field( + "appearsIn", + OptionType(ListType(OptionType(EpisodeEnum))), + Some("Which movies they appear in."), + resolve = _.value.appearsIn map (e ⇒ Some(e))), + Field( + "secretBackstory", + OptionType(StringType), + Some("Where are they from and how they came to be who they are."), + resolve = _ ⇒ throw PrivacyError("secretBackstory is secret.") + ) + )) + + val Human = + ObjectType( + "Human", + "A humanoid creature in the Star Wars universe.", + interfaces[Unit, Human](PossibleInterface[Unit, Human](Character)), + fields[Unit, Human]( + Field("id", StringType, Some("The id of the human."), resolve = _.value.id), + Field( + "name", + OptionType(StringType), + Some("The name of the human."), + resolve = _.value.name), + Field( + "friends", + OptionType(ListType(OptionType(Character))), + Some("The friends of the human, or an empty list if they have none."), + resolve = (ctx) ⇒ DeferFriends(ctx.value.friends) + ), + Field( + "appearsIn", + OptionType(ListType(OptionType(EpisodeEnum))), + Some("Which movies they appear in."), + resolve = _.value.appearsIn map (e ⇒ Some(e))), + Field( + "homePlanet", + OptionType(StringType), + Some("The home planet of the human, or null if unknown."), + resolve = _.value.homePlanet) + ) + ) + + val Droid = ObjectType( + "Droid", + "A mechanical creature in the Star Wars universe.", + interfaces[Unit, Droid](PossibleInterface[Unit, Droid](Character)), + fields[Unit, Droid]( + Field( + "id", + StringType, + Some("The id of the droid."), + tags = ProjectionName("_id") :: Nil, + resolve = _.value.id), + Field( + "name", + OptionType(StringType), + Some("The name of the droid."), + resolve = ctx ⇒ Future.successful(ctx.value.name)), + Field( + "friends", + OptionType(ListType(OptionType(Character))), + Some("The friends of the droid, or an empty list if they have none."), + resolve = ctx ⇒ DeferFriends(ctx.value.friends) + ), + Field( + "appearsIn", + OptionType(ListType(OptionType(EpisodeEnum))), + Some("Which movies they appear in."), + resolve = _.value.appearsIn map (e ⇒ Some(e))), + Field( + "primaryFunction", + OptionType(StringType), + Some("The primary function of the droid."), + resolve = _.value.primaryFunction) + ) + ) + + val ID = Argument("id", StringType, description = "id of the character") + + val EpisodeArg = Argument( + "episode", + OptionInputType(EpisodeEnum), + description = + "If omitted, returns the hero of the whole saga. If provided, returns the hero of that particular episode.") + + val Query = ObjectType[CharacterRepo, Unit]( + "Query", + fields[CharacterRepo, Unit]( + Field( + "hero", + Character, + arguments = EpisodeArg :: Nil, + resolve = (ctx) ⇒ ctx.ctx.getHero(ctx.arg(EpisodeArg))), + Field( + "human", + OptionType(Human), + arguments = ID :: Nil, + resolve = ctx ⇒ ctx.ctx.getHuman(ctx arg ID)), + Field( + "droid", + Droid, + arguments = ID :: Nil, + resolve = Projector((ctx, f) ⇒ ctx.ctx.getDroid(ctx arg ID).get)) + )) + + val StarWarsSchema = Schema(Query) +} diff --git a/src/sbt-test/codegen/generate-schema-and-code/test b/src/sbt-test/codegen/generate-schema-and-code/test new file mode 100644 index 0000000..133c9cc --- /dev/null +++ b/src/sbt-test/codegen/generate-schema-and-code/test @@ -0,0 +1,3 @@ +> update +> check +> client/compile diff --git a/src/sbt-test/codegen/generate/build.sbt b/src/sbt-test/codegen/generate/build.sbt new file mode 100644 index 0000000..ee11112 --- /dev/null +++ b/src/sbt-test/codegen/generate/build.sbt @@ -0,0 +1,37 @@ +import scala.sys.process._ + +name := "test" +enablePlugins(GraphQLCodegenPlugin) +scalaVersion := "2.12.4" + +graphqlCodegenStyle := Sangria + +TaskKey[Unit]("check") := { + val file = (graphqlCodegen in Compile).value.head + val expected = + """package graphql.codegen + |object GraphQLCodegen { + | case class HeroNameQuery(hero: GraphQLCodegen.HeroNameQuery.Hero) + | object HeroNameQuery { + | case class HeroNameQueryVariables() + | case class Hero(name: Option[String]) + | } + |} + """.stripMargin.trim + + assert(file.exists) + val generated = IO.read(file).trim + + // Drop the package line before comparing + val compare = IO.readLines(file).drop(1).mkString("\n").trim == expected.trim + if (!compare) { + IO.withTemporaryDirectory { dir => + val expectedFile = dir / "expected.scala" + IO.write(expectedFile, expected) + s"diff -u $expectedFile $file".! + + } + } + + assert(generated == expected, s"Generated file:\n$generated") +} diff --git a/src/sbt-test/codegen/generate/project/plugins.sbt b/src/sbt-test/codegen/generate/project/plugins.sbt new file mode 100644 index 0000000..231e0dd --- /dev/null +++ b/src/sbt-test/codegen/generate/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("rocks.muki" % "sbt-graphql" % sys.props("project.version")) diff --git a/src/sbt-test/codegen/generate/src/main/resources/HeroNameQuery.graphql b/src/sbt-test/codegen/generate/src/main/resources/HeroNameQuery.graphql new file mode 100644 index 0000000..a8c9deb --- /dev/null +++ b/src/sbt-test/codegen/generate/src/main/resources/HeroNameQuery.graphql @@ -0,0 +1,5 @@ +query HeroNameQuery { + hero { + name + } +} diff --git a/src/sbt-test/codegen/generate/src/main/resources/schema.graphql b/src/sbt-test/codegen/generate/src/main/resources/schema.graphql new file mode 100644 index 0000000..3fa59fe --- /dev/null +++ b/src/sbt-test/codegen/generate/src/main/resources/schema.graphql @@ -0,0 +1,74 @@ +# A character in the Star Wars Trilogy +interface Character { + # The id of the character. + id: String! + + # The name of the character. + name: String + + # The friends of the character, or an empty list if they have none. + friends: [Character!]! + + # Which movies they appear in. + appearsIn: [Episode] +} + +# A mechanical creature in the Star Wars universe. +type Droid implements Character { + # The id of the droid. + id: String! + + # The name of the droid. + name: String + + # The friends of the droid, or an empty list if they have none. + friends: [Character!]! + + # Which movies they appear in. + appearsIn: [Episode] + + # The primary function of the droid. + primaryFunction: String +} + +# One of the films in the Star Wars Trilogy +enum Episode { + # Released in 1977. + NEWHOPE + + # Released in 1980. + EMPIRE + + # Released in 1983. + JEDI +} + +# A humanoid creature in the Star Wars universe. +type Human implements Character { + # The id of the human. + id: String! + + # The name of the human. + name: String + + # The friends of the human, or an empty list if they have none. + friends: [Character!]! + + # Which movies they appear in. + appearsIn: [Episode] + + # The home planet of the human, or null if unknown. + homePlanet: String +} + +type Query { + hero( + # If omitted, returns the hero of the whole saga. If provided, returns the hero of that particular episode. + episode: Episode): Character! @deprecated(reason: "Use `human` or `droid` fields instead") + human( + # id of the character + id: String!): Human + droid( + # id of the character + id: String!): Droid! +} diff --git a/src/sbt-test/codegen/generate/test b/src/sbt-test/codegen/generate/test new file mode 100644 index 0000000..eb02e42 --- /dev/null +++ b/src/sbt-test/codegen/generate/test @@ -0,0 +1,3 @@ +> update +> compile +> check diff --git a/src/test/resources/apollo/blog/BlogArticleQuery.graphql b/src/test/resources/apollo/blog/BlogArticleQuery.graphql new file mode 100644 index 0000000..d290515 --- /dev/null +++ b/src/test/resources/apollo/blog/BlogArticleQuery.graphql @@ -0,0 +1,6 @@ +query BlogArticleQuery($query: ArticleQuery!) { + articles(query: $query) { + id + status + } +} diff --git a/src/test/resources/apollo/blog/BlogArticleQuery.scala b/src/test/resources/apollo/blog/BlogArticleQuery.scala new file mode 100644 index 0000000..263d9b5 --- /dev/null +++ b/src/test/resources/apollo/blog/BlogArticleQuery.scala @@ -0,0 +1,22 @@ +import com.example.GraphQLQuery +import sangria.macros._ +object BlogArticleQuery { + object BlogArticleQuery extends GraphQLQuery { + val Document = graphql"""query BlogArticleQuery($$query: ArticleQuery!) { + articles(query: $$query) { + id + status + } +}""" + case class Variables(query: ArticleQuery) + case class Data(articles: List[Articles]) + case class Articles(id: ID, status: ArticleStatus) + } + case class ArticleQuery(ids: Option[List[ID]], statuses: Option[List[ArticleStatus]]) + sealed trait ArticleStatus + object ArticleStatus { + case object DRAFT extends ArticleStatus + case object PUBLISHED extends ArticleStatus + } + type ID = String +} diff --git a/src/test/resources/apollo/blog/BlogByID.graphql b/src/test/resources/apollo/blog/BlogByID.graphql new file mode 100644 index 0000000..28aeae5 --- /dev/null +++ b/src/test/resources/apollo/blog/BlogByID.graphql @@ -0,0 +1,5 @@ +query Blog($blogId: ID!) { + blog(id: $blogId) { + title + } +} diff --git a/src/test/resources/apollo/blog/BlogByID.scala b/src/test/resources/apollo/blog/BlogByID.scala new file mode 100644 index 0000000..5ee182b --- /dev/null +++ b/src/test/resources/apollo/blog/BlogByID.scala @@ -0,0 +1,15 @@ +import com.example.GraphQLQuery +import sangria.macros._ +object BlogByID { + object Blog extends GraphQLQuery { + val Document = graphql"""query Blog($$blogId: ID!) { + blog(id: $$blogId) { + title + } +}""" + case class Variables(blogId: ID) + case class Data(blog: Blog) + case class Blog(title: String) + } + type ID = String +} diff --git a/src/test/resources/apollo/blog/schema.graphql b/src/test/resources/apollo/blog/schema.graphql new file mode 100644 index 0000000..0d31b63 --- /dev/null +++ b/src/test/resources/apollo/blog/schema.graphql @@ -0,0 +1,74 @@ +schema { + query: Query + mutation: Mutation +} + +type Query { + blogs(pagination: Pagination!, query: String): [Blog!]! + blog(id: ID!): Blog! + articles(query: ArticleQuery): [Article!]! + search(text: String!, pagination: Pagination!): [SearchResult!]! +} + +input Pagination { + first: Int! + count: Int! + order: PaginationOrder = ASC +} + +enum PaginationOrder { + ASC + DESC +} + +input ArticleQuery { + ids: [ID!] + statuses: [ArticleStatus!] +} + +interface Identifiable { + id: ID! +} + +type Blog implements Identifiable { + id: ID! + title: String! + url: String! @deprecated(reason: "Use `uri` instead") + uri: String! + articles(pagination: Pagination!, statuses: [ArticleStatus!] = [PUBLISHED]): [Article!]! +} + +type Article implements Identifiable { + id: ID! + title: String! + body: String! + status: ArticleStatus! + author: Author! + tags: [String!]! +} + +enum ArticleStatus { + DRAFT + PUBLISHED +} + +type Author implements Identifiable { + id: ID! + name: String! + articles(pagination: Pagination!): [Article!]! +} + +union SearchResult = Blog | Article | Author + +type Mutation { + addBlog(title: String!, uri: String!): Blog! + addArticle(content: ArticleContent!): Article! + updateArticle(id: ID!, content: ArticleContent!): Article! + publishArticle(id: ID!): Article! +} + +input ArticleContent { + title: String! + body: String! + tags: [String!] +} diff --git a/src/test/resources/apollo/starwars/HeroAndFriends.graphql b/src/test/resources/apollo/starwars/HeroAndFriends.graphql new file mode 100644 index 0000000..ac71776 --- /dev/null +++ b/src/test/resources/apollo/starwars/HeroAndFriends.graphql @@ -0,0 +1,17 @@ +query HeroAndFriends { + hero { + name + friends { + name + friends { + name + friends { + name + friends { + name + } + } + } + } + } +} diff --git a/src/test/resources/apollo/starwars/HeroAndFriends.scala b/src/test/resources/apollo/starwars/HeroAndFriends.scala new file mode 100644 index 0000000..6361514 --- /dev/null +++ b/src/test/resources/apollo/starwars/HeroAndFriends.scala @@ -0,0 +1,36 @@ +import com.example.GraphQLQuery +import sangria.macros._ +object HeroAndFriends { + object HeroAndFriends extends GraphQLQuery { + val Document = graphql"""query HeroAndFriends { + hero { + name + friends { + name + friends { + name + friends { + name + friends { + name + } + } + } + } + } +}""" + case class Variables() + case class Data(hero: Hero) + case class Hero(name: Option[String], friends: Option[List[Option[Hero.Friends]]]) + object Hero { + case class Friends(name: Option[String], friends: Option[List[Option[Friends.Friends]]]) + object Friends { + case class Friends(name: Option[String], friends: Option[List[Option[Friends.Friends]]]) + object Friends { + case class Friends(name: Option[String], friends: Option[List[Option[Friends.Friends]]]) + object Friends { case class Friends(name: Option[String]) } + } + } + } + } +} \ No newline at end of file diff --git a/src/test/resources/apollo/starwars/HeroFragmentQuery.graphql b/src/test/resources/apollo/starwars/HeroFragmentQuery.graphql new file mode 100644 index 0000000..55c39c6 --- /dev/null +++ b/src/test/resources/apollo/starwars/HeroFragmentQuery.graphql @@ -0,0 +1,12 @@ +query HeroFragmentQuery { + hero { + ...CharacterInfo + } + human(id: "Lea") { + ...CharacterInfo + } +} + +fragment CharacterInfo on Character { + name +} diff --git a/src/test/resources/apollo/starwars/HeroFragmentQuery.scala b/src/test/resources/apollo/starwars/HeroFragmentQuery.scala new file mode 100644 index 0000000..88406d1 --- /dev/null +++ b/src/test/resources/apollo/starwars/HeroFragmentQuery.scala @@ -0,0 +1,19 @@ +import com.example.GraphQLQuery +import sangria.macros._ +object HeroFragmentQuery { + object HeroFragmentQuery extends GraphQLQuery { + val Document = graphql"""query HeroFragmentQuery { + hero { + ...CharacterInfo + } + human(id: "Lea") { + ...CharacterInfo + } +}""" + case class Variables() + case class Data(hero: Hero, human: Option[Human]) + case class Hero(name: Option[String]) extends CharacterInfo + case class Human(name: Option[String]) extends CharacterInfo + } + trait CharacterInfo { def name: Option[String] } +} diff --git a/src/test/resources/apollo/starwars/HeroNameQuery.graphql b/src/test/resources/apollo/starwars/HeroNameQuery.graphql new file mode 100644 index 0000000..a8c9deb --- /dev/null +++ b/src/test/resources/apollo/starwars/HeroNameQuery.graphql @@ -0,0 +1,5 @@ +query HeroNameQuery { + hero { + name + } +} diff --git a/src/test/resources/apollo/starwars/HeroNameQuery.scala b/src/test/resources/apollo/starwars/HeroNameQuery.scala new file mode 100644 index 0000000..fc6c0c4 --- /dev/null +++ b/src/test/resources/apollo/starwars/HeroNameQuery.scala @@ -0,0 +1,14 @@ +import com.example.GraphQLQuery +import sangria.macros._ +object HeroNameQuery { + object HeroNameQuery extends GraphQLQuery { + val Document = graphql"""query HeroNameQuery { + hero { + name + } +}""" + case class Variables() + case class Data(hero: Hero) + case class Hero(name: Option[String]) + } +} \ No newline at end of file diff --git a/src/test/resources/apollo/starwars/InputVariables.graphql b/src/test/resources/apollo/starwars/InputVariables.graphql new file mode 100644 index 0000000..d895d4d --- /dev/null +++ b/src/test/resources/apollo/starwars/InputVariables.graphql @@ -0,0 +1,6 @@ +query InputVariables($humanId: String!) { + human(id: $humanId) { + name, + homePlanet + } +} diff --git a/src/test/resources/apollo/starwars/InputVariables.scala b/src/test/resources/apollo/starwars/InputVariables.scala new file mode 100644 index 0000000..ae7e100 --- /dev/null +++ b/src/test/resources/apollo/starwars/InputVariables.scala @@ -0,0 +1,15 @@ +import com.example.GraphQLQuery +import sangria.macros._ +object InputVariables { + object InputVariables extends GraphQLQuery { + val Document = graphql"""query InputVariables($$humanId: String!) { + human(id: $$humanId) { + name + homePlanet + } +}""" + case class Variables(humanId: String) + case class Data(human: Option[Human]) + case class Human(name: Option[String], homePlanet: Option[String]) + } +} \ No newline at end of file diff --git a/src/test/resources/apollo/starwars/SearchQuery.graphql b/src/test/resources/apollo/starwars/SearchQuery.graphql new file mode 100644 index 0000000..57a2cab --- /dev/null +++ b/src/test/resources/apollo/starwars/SearchQuery.graphql @@ -0,0 +1,17 @@ +query SearchQuery($text: String!) { + search(text: $text) { + __typename + ... on Human { + name + secretBackstory + } + ... on Droid { + name + primaryFunction + } + ... on Starship { + name + } + } + +} \ No newline at end of file diff --git a/src/test/resources/apollo/starwars/SearchQuery.scala b/src/test/resources/apollo/starwars/SearchQuery.scala new file mode 100644 index 0000000..ad88530 --- /dev/null +++ b/src/test/resources/apollo/starwars/SearchQuery.scala @@ -0,0 +1,30 @@ +import com.example.GraphQLQuery +import sangria.macros._ +object SearchQuery { + object SearchQuery extends GraphQLQuery { + val Document = graphql"""query SearchQuery($$text: String!) { + search(text: $$text) { + __typename + ... on Human { + name + secretBackstory + } + ... on Droid { + name + primaryFunction + } + ... on Starship { + name + } + } +}""" + case class Variables(text: String) + case class Data(search: List[Search]) + sealed trait Search + object Search { + case class Human(__typename: String, name: Option[String], secretBackstory: Option[String]) extends Search + case class Droid(__typename: String, name: Option[String], primaryFunction: Option[String]) extends Search + case class Starship(__typename: String, name: Option[String]) extends Search + } + } +} \ No newline at end of file diff --git a/src/test/resources/apollo/starwars/schema.graphql b/src/test/resources/apollo/starwars/schema.graphql new file mode 100644 index 0000000..23f6d1e --- /dev/null +++ b/src/test/resources/apollo/starwars/schema.graphql @@ -0,0 +1,93 @@ +# A character in the Star Wars Trilogy +interface Character { + # The id of the character. + id: String! + + # The name of the character. + name: String + + # The friends of the character, or an empty list if they have none. + friends: [Character] + + # Which movies they appear in. + appearsIn: [Episode] + + # Where are they from and how they came to be who they are. + secretBackstory: String +} + +# A mechanical creature in the Star Wars universe. +type Droid implements Character { + # The id of the droid. + id: String! + + # The name of the droid. + name: String + + # The friends of the droid, or an empty list if they have none. + friends: [Character] + + # Which movies they appear in. + appearsIn: [Episode] + + # The primary function of the droid. + primaryFunction: String + + # Where are they from and how they came to be who they are. + secretBackstory: String +} + +# One of the films in the Star Wars Trilogy +enum Episode { + # Released in 1977. + NEWHOPE + + # Released in 1980. + EMPIRE + + # Released in 1983. + JEDI +} + +# A humanoid creature in the Star Wars universe. +type Human implements Character { + # The id of the human. + id: String! + + # The name of the human. + name: String + + # The friends of the human, or an empty list if they have none. + friends: [Character] + + # Which movies they appear in. + appearsIn: [Episode] + + # The home planet of the human, or null if unknown. + homePlanet: String + + # Where are they from and how they came to be who they are. + secretBackstory: String +} + +type Starship { + id: ID! + name: String +} + +# A search result can have different types +union SearchResult = Human | Droid | Starship + +type Query { + hero( + # If omitted, returns the hero of the whole saga. If provided, returns the hero of that particular episode. + episode: Episode): Character! + human( + # id of the character + id: String!): Human + droid( + # id of the character + id: String!): Droid! + + search(text: String!): [SearchResult!]! +} diff --git a/src/test/resources/blog/BlogAllTheWayDown.graphql b/src/test/resources/blog/BlogAllTheWayDown.graphql new file mode 100644 index 0000000..28aeae5 --- /dev/null +++ b/src/test/resources/blog/BlogAllTheWayDown.graphql @@ -0,0 +1,5 @@ +query Blog($blogId: ID!) { + blog(id: $blogId) { + title + } +} diff --git a/src/test/resources/blog/BlogAllTheWayDown.scala b/src/test/resources/blog/BlogAllTheWayDown.scala new file mode 100644 index 0000000..403c23c --- /dev/null +++ b/src/test/resources/blog/BlogAllTheWayDown.scala @@ -0,0 +1,8 @@ +object BlogAllTheWayDownApi { + case class Blog(blog: BlogAllTheWayDownApi.Blog.Blog) + object Blog { + case class BlogVariables(blogId: BlogAllTheWayDownApi.ID) + case class Blog(title: String) + } + type ID = String +} diff --git a/src/test/resources/blog/BlogArticleQuery.graphql b/src/test/resources/blog/BlogArticleQuery.graphql new file mode 100644 index 0000000..d290515 --- /dev/null +++ b/src/test/resources/blog/BlogArticleQuery.graphql @@ -0,0 +1,6 @@ +query BlogArticleQuery($query: ArticleQuery!) { + articles(query: $query) { + id + status + } +} diff --git a/src/test/resources/blog/BlogArticleQuery.scala b/src/test/resources/blog/BlogArticleQuery.scala new file mode 100644 index 0000000..3a2431c --- /dev/null +++ b/src/test/resources/blog/BlogArticleQuery.scala @@ -0,0 +1,14 @@ +object BlogArticleQueryApi { + case class BlogArticleQuery(articles: List[BlogArticleQueryApi.BlogArticleQuery.Articles]) + object BlogArticleQuery { + case class BlogArticleQueryVariables(query: ArticleQuery) + case class Articles(id: BlogArticleQueryApi.ID, status: ArticleStatus) + } + case class ArticleQuery(ids: Option[List[BlogArticleQueryApi.ID]], statuses: Option[List[ArticleStatus]]) + sealed trait ArticleStatus + object ArticleStatus { + case object DRAFT extends BlogArticleQueryApi.ArticleStatus + case object PUBLISHED extends BlogArticleQueryApi.ArticleStatus + } + type ID = String +} diff --git a/src/test/resources/blog/BlogArticles.graphql b/src/test/resources/blog/BlogArticles.graphql new file mode 100644 index 0000000..0a7e66a --- /dev/null +++ b/src/test/resources/blog/BlogArticles.graphql @@ -0,0 +1,14 @@ +query BlogArticles($blogId: ID!, $pagination: Pagination!) { + blog(id: $blogId) { + title + articles(pagination: $pagination) { + title + body + tags + status + author { + name + } + } + } +} diff --git a/src/test/resources/blog/BlogArticles.scala b/src/test/resources/blog/BlogArticles.scala new file mode 100644 index 0000000..3ab9f49 --- /dev/null +++ b/src/test/resources/blog/BlogArticles.scala @@ -0,0 +1,23 @@ +object BlogArticlesApi { + case class BlogArticles(blog: BlogArticlesApi.BlogArticles.Blog) + object BlogArticles { + case class BlogArticlesVariables(blogId: BlogArticlesApi.ID, pagination: Pagination) + case class Blog(title: String, articles: List[BlogArticlesApi.BlogArticles.Blog.Articles]) + object Blog { + case class Articles(title: String, body: String, tags: List[String], status: ArticleStatus, author: BlogArticlesApi.BlogArticles.Blog.Articles.Author) + object Articles { case class Author(name: String) } + } + } + sealed trait ArticleStatus + object ArticleStatus { + case object DRAFT extends BlogArticlesApi.ArticleStatus + case object PUBLISHED extends BlogArticlesApi.ArticleStatus + } + case class Pagination(first: Int, count: Int, order: Option[PaginationOrder]) + sealed trait PaginationOrder + object PaginationOrder { + case object ASC extends BlogArticlesApi.PaginationOrder + case object DESC extends BlogArticlesApi.PaginationOrder + } + type ID = String +} diff --git a/src/test/resources/blog/BlogFragments.graphql b/src/test/resources/blog/BlogFragments.graphql new file mode 100644 index 0000000..be0489e --- /dev/null +++ b/src/test/resources/blog/BlogFragments.graphql @@ -0,0 +1,37 @@ +query BlogFragments($blogId: ID!, $pagination: Pagination!) { + blog(id: $blogId) { + title + articles(pagination: $pagination) { + ...ArticleFragment + } + + articlesWithAuthorId: articles(pagination: $pagination) { + ...IdFragment + ...ArticleWithAuthorIdFragment + } + } +} + +fragment ArticleFragment on Article { + title + author { + ...AuthorFragment + } +} + +fragment AuthorFragment on Author { + id + name +} + +fragment IdFragment on Identifiable { + id +} + +fragment ArticleWithAuthorIdFragment on Article { + title + author { + ...IdFragment + ...AuthorFragment + } +} diff --git a/src/test/resources/blog/BlogFragments.scala b/src/test/resources/blog/BlogFragments.scala new file mode 100644 index 0000000..da0e5e3 --- /dev/null +++ b/src/test/resources/blog/BlogFragments.scala @@ -0,0 +1,20 @@ +object BlogFragmentsApi { + case class BlogFragments(blog: BlogFragmentsApi.BlogFragments.Blog) + object BlogFragments { + case class BlogFragmentsVariables(blogId: BlogFragmentsApi.ID, pagination: Pagination) + case class Blog(title: String, articles: List[BlogFragmentsApi.BlogFragments.Blog.Articles], articlesWithAuthorId: List[BlogFragmentsApi.BlogFragments.Blog.ArticlesWithAuthorId]) + object Blog { + case class Articles(title: String, author: BlogFragmentsApi.BlogFragments.Blog.Articles.Author) + object Articles { case class Author(id: BlogFragmentsApi.ID, name: String) } + case class ArticlesWithAuthorId(id: BlogFragmentsApi.ID, title: String, author: BlogFragmentsApi.BlogFragments.Blog.ArticlesWithAuthorId.Author) + object ArticlesWithAuthorId { case class Author(id: BlogFragmentsApi.ID, name: String) } + } + } + case class Pagination(first: Int, count: Int, order: Option[PaginationOrder]) + sealed trait PaginationOrder + object PaginationOrder { + case object ASC extends BlogFragmentsApi.PaginationOrder + case object DESC extends BlogFragmentsApi.PaginationOrder + } + type ID = String +} diff --git a/src/test/resources/blog/BlogListing.graphql b/src/test/resources/blog/BlogListing.graphql new file mode 100644 index 0000000..0043678 --- /dev/null +++ b/src/test/resources/blog/BlogListing.graphql @@ -0,0 +1,7 @@ +query BlogListing($pagination: Pagination!) { + blogs(pagination: $pagination) { + id + title + uri + } +} diff --git a/src/test/resources/blog/BlogListing.scala b/src/test/resources/blog/BlogListing.scala new file mode 100644 index 0000000..00eea0d --- /dev/null +++ b/src/test/resources/blog/BlogListing.scala @@ -0,0 +1,14 @@ +object BlogListingApi { + case class BlogListing(blogs: List[BlogListingApi.BlogListing.Blogs]) + object BlogListing { + case class BlogListingVariables(pagination: Pagination) + case class Blogs(id: BlogListingApi.ID, title: String, uri: String) + } + case class Pagination(first: Int, count: Int, order: Option[PaginationOrder]) + sealed trait PaginationOrder + object PaginationOrder { + case object ASC extends BlogListingApi.PaginationOrder + case object DESC extends BlogListingApi.PaginationOrder + } + type ID = String +} diff --git a/src/test/resources/blog/BlogSearch.graphql b/src/test/resources/blog/BlogSearch.graphql new file mode 100644 index 0000000..8399108 --- /dev/null +++ b/src/test/resources/blog/BlogSearch.graphql @@ -0,0 +1,81 @@ +query BlogSearch($text: String!, $pagination: Pagination!) { + search(text: $text, pagination: $pagination) { + ... on Identifiable { + ...IdFragment + } + ... on Blog { + ...BlogFragment + } + ... on Article { + ...ArticleFragment + } + ... on Author { + ...AuthorFragment + } + } + + searchWithFragmentSpread: search(text: $text, pagination: $pagination) { + ...SearchResultFragment + } + + searchOnImplements: search(text: $text, pagination: $pagination) { + ...SearchResultOnIdentifiableFragment + } +} + +fragment IdFragment on Identifiable { + id +} + +fragment BlogFragment on Blog { + __typename + title +} + +fragment ArticleFragment on Article { + __typename + title + status + author { + ...AuthorFragment + } +} + +fragment AuthorFragment on Author { + __typename + name +} + +fragment SearchResultFragment on SearchResult { + ... on Identifiable { + ...IdFragment + } + ... on Blog { + ...BlogFragment + } + ... on Article { + ...ArticleFragment + } + ... on Author { + ...AuthorFragment + } +} + +fragment SearchResultOnIdentifiableFragment on Identifiable { + id + ... on Blog { + ...BlogFragment + } + ... on Article { + __typename + title + status + author { + name + } + } + ... on Author { + __typename + name + } +} diff --git a/src/test/resources/blog/BlogSearch.scala b/src/test/resources/blog/BlogSearch.scala new file mode 100644 index 0000000..593bc08 --- /dev/null +++ b/src/test/resources/blog/BlogSearch.scala @@ -0,0 +1,39 @@ +object BlogSearchApi { + case class BlogSearch(search: List[BlogSearchApi.BlogSearch.Search], searchWithFragmentSpread: List[BlogSearchApi.BlogSearch.SearchWithFragmentSpread], searchOnImplements: List[BlogSearchApi.BlogSearch.SearchOnImplements]) + object BlogSearch { + case class BlogSearchVariables(text: String, pagination: Pagination) + sealed trait Search + object Search { + case class Blog(id: BlogSearchApi.ID, __typename: String, title: String) extends BlogSearchApi.BlogSearch.Search + case class Article(id: BlogSearchApi.ID, __typename: String, title: String, status: ArticleStatus, author: BlogSearchApi.BlogSearch.Search.Article.Author) extends BlogSearchApi.BlogSearch.Search + object Article { case class Author(__typename: String, name: String) } + case class Author(id: BlogSearchApi.ID, __typename: String, name: String) extends BlogSearchApi.BlogSearch.Search + } + sealed trait SearchWithFragmentSpread + object SearchWithFragmentSpread { + case class Blog(id: BlogSearchApi.ID, __typename: String, title: String) extends BlogSearchApi.BlogSearch.SearchWithFragmentSpread + case class Article(id: BlogSearchApi.ID, __typename: String, title: String, status: ArticleStatus, author: BlogSearchApi.BlogSearch.SearchWithFragmentSpread.Article.Author) extends BlogSearchApi.BlogSearch.SearchWithFragmentSpread + object Article { case class Author(__typename: String, name: String) } + case class Author(id: BlogSearchApi.ID, __typename: String, name: String) extends BlogSearchApi.BlogSearch.SearchWithFragmentSpread + } + sealed trait SearchOnImplements + object SearchOnImplements { + case class Blog(id: BlogSearchApi.ID, __typename: String, title: String) extends BlogSearchApi.BlogSearch.SearchOnImplements + case class Article(id: BlogSearchApi.ID, __typename: String, title: String, status: ArticleStatus, author: BlogSearchApi.BlogSearch.SearchOnImplements.Article.Author) extends BlogSearchApi.BlogSearch.SearchOnImplements + object Article { case class Author(name: String) } + case class Author(id: BlogSearchApi.ID, __typename: String, name: String) extends BlogSearchApi.BlogSearch.SearchOnImplements + } + } + sealed trait ArticleStatus + object ArticleStatus { + case object DRAFT extends BlogSearchApi.ArticleStatus + case object PUBLISHED extends BlogSearchApi.ArticleStatus + } + case class Pagination(first: Int, count: Int, order: Option[PaginationOrder]) + sealed trait PaginationOrder + object PaginationOrder { + case object ASC extends BlogSearchApi.PaginationOrder + case object DESC extends BlogSearchApi.PaginationOrder + } + type ID = String +} diff --git a/src/test/resources/blog/schema.graphql b/src/test/resources/blog/schema.graphql new file mode 100644 index 0000000..0d31b63 --- /dev/null +++ b/src/test/resources/blog/schema.graphql @@ -0,0 +1,74 @@ +schema { + query: Query + mutation: Mutation +} + +type Query { + blogs(pagination: Pagination!, query: String): [Blog!]! + blog(id: ID!): Blog! + articles(query: ArticleQuery): [Article!]! + search(text: String!, pagination: Pagination!): [SearchResult!]! +} + +input Pagination { + first: Int! + count: Int! + order: PaginationOrder = ASC +} + +enum PaginationOrder { + ASC + DESC +} + +input ArticleQuery { + ids: [ID!] + statuses: [ArticleStatus!] +} + +interface Identifiable { + id: ID! +} + +type Blog implements Identifiable { + id: ID! + title: String! + url: String! @deprecated(reason: "Use `uri` instead") + uri: String! + articles(pagination: Pagination!, statuses: [ArticleStatus!] = [PUBLISHED]): [Article!]! +} + +type Article implements Identifiable { + id: ID! + title: String! + body: String! + status: ArticleStatus! + author: Author! + tags: [String!]! +} + +enum ArticleStatus { + DRAFT + PUBLISHED +} + +type Author implements Identifiable { + id: ID! + name: String! + articles(pagination: Pagination!): [Article!]! +} + +union SearchResult = Blog | Article | Author + +type Mutation { + addBlog(title: String!, uri: String!): Blog! + addArticle(content: ArticleContent!): Article! + updateArticle(id: ID!, content: ArticleContent!): Article! + publishArticle(id: ID!): Article! +} + +input ArticleContent { + title: String! + body: String! + tags: [String!] +} diff --git a/src/test/resources/starwars/HeroAndFriends.graphql b/src/test/resources/starwars/HeroAndFriends.graphql new file mode 100644 index 0000000..ac71776 --- /dev/null +++ b/src/test/resources/starwars/HeroAndFriends.graphql @@ -0,0 +1,17 @@ +query HeroAndFriends { + hero { + name + friends { + name + friends { + name + friends { + name + friends { + name + } + } + } + } + } +} diff --git a/src/test/resources/starwars/HeroAndFriends.scala b/src/test/resources/starwars/HeroAndFriends.scala new file mode 100644 index 0000000..dceee06 --- /dev/null +++ b/src/test/resources/starwars/HeroAndFriends.scala @@ -0,0 +1,17 @@ +object HeroAndFriendsApi { + case class HeroAndFriends(hero: HeroAndFriendsApi.HeroAndFriends.Hero) + object HeroAndFriends { + case class HeroAndFriendsVariables() + case class Hero(name: Option[String], friends: Option[List[Option[HeroAndFriendsApi.HeroAndFriends.Hero.Friends]]]) + object Hero { + case class Friends(name: Option[String], friends: Option[List[Option[HeroAndFriendsApi.HeroAndFriends.Hero.Friends.Friends]]]) + object Friends { + case class Friends(name: Option[String], friends: Option[List[Option[HeroAndFriendsApi.HeroAndFriends.Hero.Friends.Friends.Friends]]]) + object Friends { + case class Friends(name: Option[String], friends: Option[List[Option[HeroAndFriendsApi.HeroAndFriends.Hero.Friends.Friends.Friends.Friends]]]) + object Friends { case class Friends(name: Option[String]) } + } + } + } + } +} diff --git a/src/test/resources/starwars/HeroFragments.graphql b/src/test/resources/starwars/HeroFragments.graphql new file mode 100644 index 0000000..b58f608 --- /dev/null +++ b/src/test/resources/starwars/HeroFragments.graphql @@ -0,0 +1,51 @@ +query HeroAppearances { + hero { + ...Appearances + } +} + +query HeroAppearancesAndInline { + hero { + ...Appearances + ... on Character { + id + } + } +} + +query HeroAppearancesAndInlineAlias { + hero { + ...Appearances + ... on Character { + heroId: id + } + } +} + +query HeroNameAliasAndAppearances { + hero { + ...NameAlias + ...Appearances + } +} + +query HeroFragmentOverlap { + hero { + ...Identifiable + ...Appearances + } +} + +fragment Appearances on Character { + name + appearsIn +} + +fragment Identifiable on Character { + id + name +} + +fragment NameAlias on Character { + alias: name +} diff --git a/src/test/resources/starwars/HeroFragments.scala b/src/test/resources/starwars/HeroFragments.scala new file mode 100644 index 0000000..7bb0784 --- /dev/null +++ b/src/test/resources/starwars/HeroFragments.scala @@ -0,0 +1,33 @@ +object HeroFragmentsApi { + case class HeroAppearancesAndInline(hero: HeroFragmentsApi.HeroAppearancesAndInline.Hero) + object HeroAppearancesAndInline { + case class HeroAppearancesAndInlineVariables() + case class Hero(name: Option[String], appearsIn: Option[List[Option[Episode]]], id: String) + } + case class HeroAppearances(hero: HeroFragmentsApi.HeroAppearances.Hero) + object HeroAppearances { + case class HeroAppearancesVariables() + case class Hero(name: Option[String], appearsIn: Option[List[Option[Episode]]]) + } + case class HeroAppearancesAndInlineAlias(hero: HeroFragmentsApi.HeroAppearancesAndInlineAlias.Hero) + object HeroAppearancesAndInlineAlias { + case class HeroAppearancesAndInlineAliasVariables() + case class Hero(name: Option[String], appearsIn: Option[List[Option[Episode]]], heroId: String) + } + case class HeroFragmentOverlap(hero: HeroFragmentsApi.HeroFragmentOverlap.Hero) + object HeroFragmentOverlap { + case class HeroFragmentOverlapVariables() + case class Hero(id: String, name: Option[String], appearsIn: Option[List[Option[Episode]]]) + } + case class HeroNameAliasAndAppearances(hero: HeroFragmentsApi.HeroNameAliasAndAppearances.Hero) + object HeroNameAliasAndAppearances { + case class HeroNameAliasAndAppearancesVariables() + case class Hero(alias: Option[String], name: Option[String], appearsIn: Option[List[Option[Episode]]]) + } + sealed trait Episode + object Episode { + case object NEWHOPE extends HeroFragmentsApi.Episode + case object EMPIRE extends HeroFragmentsApi.Episode + case object JEDI extends HeroFragmentsApi.Episode + } +} diff --git a/src/test/resources/starwars/HeroNameQuery.graphql b/src/test/resources/starwars/HeroNameQuery.graphql new file mode 100644 index 0000000..a8c9deb --- /dev/null +++ b/src/test/resources/starwars/HeroNameQuery.graphql @@ -0,0 +1,5 @@ +query HeroNameQuery { + hero { + name + } +} diff --git a/src/test/resources/starwars/HeroNameQuery.scala b/src/test/resources/starwars/HeroNameQuery.scala new file mode 100644 index 0000000..ff297c5 --- /dev/null +++ b/src/test/resources/starwars/HeroNameQuery.scala @@ -0,0 +1,7 @@ +object HeroNameQueryApi { + case class HeroNameQuery(hero: HeroNameQueryApi.HeroNameQuery.Hero) + object HeroNameQuery { + case class HeroNameQueryVariables() + case class Hero(name: Option[String]) + } +} diff --git a/src/test/resources/starwars/MultiQuery.graphql b/src/test/resources/starwars/MultiQuery.graphql new file mode 100644 index 0000000..b45c2a1 --- /dev/null +++ b/src/test/resources/starwars/MultiQuery.graphql @@ -0,0 +1,53 @@ +query HeroAndFriends { + hero { + name + friends { + name + } + } +} + +query HeroAndNestedFriends { + hero { + name + friends { + name + friends { + name + friends { + name + friends { + name + } + } + } + } + } +} + +query FragmentExample { + human(id: "1003") { + ...Common + homePlanet + } + + droid(id: "2001") { + ...Common + primaryFunction + } +} + +query VariableExample($humanId: String!){ + human(id: $humanId) { + name, + homePlanet, + friends { + name + } + } +} + +fragment Common on Character { + name + appearsIn +} diff --git a/src/test/resources/starwars/MultiQuery.scala b/src/test/resources/starwars/MultiQuery.scala new file mode 100644 index 0000000..9295ecc --- /dev/null +++ b/src/test/resources/starwars/MultiQuery.scala @@ -0,0 +1,41 @@ +object MultiQueryApi { + case class HeroAndFriends(hero: MultiQueryApi.HeroAndFriends.Hero) + object HeroAndFriends { + case class HeroAndFriendsVariables() + case class Hero(name: Option[String], friends: Option[List[Option[MultiQueryApi.HeroAndFriends.Hero.Friends]]]) + object Hero { case class Friends(name: Option[String]) } + } + case class HeroAndNestedFriends(hero: MultiQueryApi.HeroAndNestedFriends.Hero) + object HeroAndNestedFriends { + case class HeroAndNestedFriendsVariables() + case class Hero(name: Option[String], friends: Option[List[Option[MultiQueryApi.HeroAndNestedFriends.Hero.Friends]]]) + object Hero { + case class Friends(name: Option[String], friends: Option[List[Option[MultiQueryApi.HeroAndNestedFriends.Hero.Friends.Friends]]]) + object Friends { + case class Friends(name: Option[String], friends: Option[List[Option[MultiQueryApi.HeroAndNestedFriends.Hero.Friends.Friends.Friends]]]) + object Friends { + case class Friends(name: Option[String], friends: Option[List[Option[MultiQueryApi.HeroAndNestedFriends.Hero.Friends.Friends.Friends.Friends]]]) + object Friends { case class Friends(name: Option[String]) } + } + } + } + } + case class FragmentExample(human: Option[MultiQueryApi.FragmentExample.Human], droid: MultiQueryApi.FragmentExample.Droid) + object FragmentExample { + case class FragmentExampleVariables() + case class Human(name: Option[String], appearsIn: Option[List[Option[Episode]]], homePlanet: Option[String]) + case class Droid(name: Option[String], appearsIn: Option[List[Option[Episode]]], primaryFunction: Option[String]) + } + case class VariableExample(human: Option[MultiQueryApi.VariableExample.Human]) + object VariableExample { + case class VariableExampleVariables(humanId: String) + case class Human(name: Option[String], homePlanet: Option[String], friends: Option[List[Option[MultiQueryApi.VariableExample.Human.Friends]]]) + object Human { case class Friends(name: Option[String]) } + } + sealed trait Episode + object Episode { + case object NEWHOPE extends MultiQueryApi.Episode + case object EMPIRE extends MultiQueryApi.Episode + case object JEDI extends MultiQueryApi.Episode + } +} diff --git a/src/test/resources/starwars/schema.graphql b/src/test/resources/starwars/schema.graphql new file mode 100644 index 0000000..86d8734 --- /dev/null +++ b/src/test/resources/starwars/schema.graphql @@ -0,0 +1,83 @@ +# A character in the Star Wars Trilogy +interface Character { + # The id of the character. + id: String! + + # The name of the character. + name: String + + # The friends of the character, or an empty list if they have none. + friends: [Character] + + # Which movies they appear in. + appearsIn: [Episode] + + # Where are they from and how they came to be who they are. + secretBackstory: String +} + +# A mechanical creature in the Star Wars universe. +type Droid implements Character { + # The id of the droid. + id: String! + + # The name of the droid. + name: String + + # The friends of the droid, or an empty list if they have none. + friends: [Character] + + # Which movies they appear in. + appearsIn: [Episode] + + # The primary function of the droid. + primaryFunction: String + + # Where are they from and how they came to be who they are. + secretBackstory: String +} + +# One of the films in the Star Wars Trilogy +enum Episode { + # Released in 1977. + NEWHOPE + + # Released in 1980. + EMPIRE + + # Released in 1983. + JEDI +} + +# A humanoid creature in the Star Wars universe. +type Human implements Character { + # The id of the human. + id: String! + + # The name of the human. + name: String + + # The friends of the human, or an empty list if they have none. + friends: [Character] + + # Which movies they appear in. + appearsIn: [Episode] + + # The home planet of the human, or null if unknown. + homePlanet: String + + # Where are they from and how they came to be who they are. + secretBackstory: String +} + +type Query { + hero( + # If omitted, returns the hero of the whole saga. If provided, returns the hero of that particular episode. + episode: Episode): Character! + human( + # id of the character + id: String!): Human + droid( + # id of the character + id: String!): Droid! +} diff --git a/src/test/resources/starwars/schema.json b/src/test/resources/starwars/schema.json new file mode 100644 index 0000000..72e1b5f --- /dev/null +++ b/src/test/resources/starwars/schema.json @@ -0,0 +1,1424 @@ +{ + "data": { + "__schema": { + "queryType": { + "name": "Query" + }, + "mutationType": null, + "subscriptionType": null, + "types": [ + { + "kind": "INTERFACE", + "name": "Character", + "description": "A character in the Star Wars Trilogy", + "fields": [ + { + "name": "id", + "description": "The id of the character.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "The name of the character.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "friends", + "description": "The friends of the character, or an empty list if they have none.", + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appearsIn", + "description": "Which movies they appear in.", + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "Episode", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "secretBackstory", + "description": "Where are they from and how they came to be who they are.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "Droid", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Human", + "ofType": null + } + ] + }, + { + "kind": "OBJECT", + "name": "Droid", + "description": "A mechanical creature in the Star Wars universe.", + "fields": [ + { + "name": "id", + "description": "The id of the droid.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "The name of the droid.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "friends", + "description": "The friends of the droid, or an empty list if they have none.", + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appearsIn", + "description": "Which movies they appear in.", + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "Episode", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "primaryFunction", + "description": "The primary function of the droid.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "secretBackstory", + "description": "Where are they from and how they came to be who they are.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "Episode", + "description": "One of the films in the Star Wars Trilogy", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "NEWHOPE", + "description": "Released in 1977.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "EMPIRE", + "description": "Released in 1980.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "JEDI", + "description": "Released in 1983.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Human", + "description": "A humanoid creature in the Star Wars universe.", + "fields": [ + { + "name": "id", + "description": "The id of the human.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "The name of the human.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "friends", + "description": "The friends of the human, or an empty list if they have none.", + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appearsIn", + "description": "Which movies they appear in.", + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "Episode", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "homePlanet", + "description": "The home planet of the human, or null if unknown.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "secretBackstory", + "description": "Where are they from and how they came to be who they are.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Query", + "description": null, + "fields": [ + { + "name": "hero", + "description": null, + "args": [ + { + "name": "episode", + "description": "If omitted, returns the hero of the whole saga. If provided, returns the hero of that particular episode.", + "type": { + "kind": "ENUM", + "name": "Episode", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "human", + "description": null, + "args": [ + { + "name": "id", + "description": "id of the character", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Human", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "droid", + "description": null, + "args": [ + { + "name": "id", + "description": "id of the character", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Droid", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Directive", + "description": "A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document.\n\nIn some cases, you need to provide options to alter GraphQL’s execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.", + "fields": [ + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "locations", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__DirectiveLocation", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "args", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onOperation", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + }, + { + "name": "onFragment", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + }, + { + "name": "onField", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__DirectiveLocation", + "description": "A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "QUERY", + "description": "Location adjacent to a query operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MUTATION", + "description": "Location adjacent to a mutation operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SUBSCRIPTION", + "description": "Location adjacent to a subscription operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD", + "description": "Location adjacent to a field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_DEFINITION", + "description": "Location adjacent to a fragment definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_SPREAD", + "description": "Location adjacent to a fragment spread.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INLINE_FRAGMENT", + "description": "Location adjacent to an inline fragment.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCHEMA", + "description": "Location adjacent to a schema definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCALAR", + "description": "Location adjacent to a scalar definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Location adjacent to an object type definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD_DEFINITION", + "description": "Location adjacent to a field definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ARGUMENT_DEFINITION", + "description": "Location adjacent to an argument definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Location adjacent to an interface definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Location adjacent to a union definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Location adjacent to an enum definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM_VALUE", + "description": "Location adjacent to an enum value definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "INPUT_OBJECT", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_FIELD_DEFINITION", + "description": "Location adjacent to an input object field definition.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__EnumValue", + "description": "One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string.", + "fields": [ + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deprecationReason", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Field", + "description": "Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type.", + "fields": [ + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "args", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deprecationReason", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__InputValue", + "description": "Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value.", + "fields": [ + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "defaultValue", + "description": "A GraphQL-formatted string representing the default value for this input value.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Schema", + "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations.", + "fields": [ + { + "name": "types", + "description": "A list of all types supported by this server.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "queryType", + "description": "The type that query operations will be rooted at.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mutationType", + "description": "If this server supports mutation, the type that mutation operations will be rooted at.", + "args": [], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionType", + "description": "If this server support subscription, the type that subscription operations will be rooted at.", + "args": [], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "directives", + "description": "A list of all directives supported by this server.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Type", + "description": "The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.", + "fields": [ + { + "name": "kind", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__TypeKind", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fields", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interfaces", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "possibleTypes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "enumValues", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inputFields", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ofType", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__TypeKind", + "description": "An enum describing what kind of type a given `__Type` is.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "SCALAR", + "description": "Indicates this type is a scalar.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Indicates this type is a union. `possibleTypes` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Indicates this type is an enum. `enumValues` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "Indicates this type is an input object. `inputFields` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LIST", + "description": "Indicates this type is a list. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NON_NULL", + "description": "Indicates this type is a non-null. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "BigDecimal", + "description": "The `BigDecimal` scalar type represents signed fractional values with arbitrary precision.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "BigInt", + "description": "The `BigInt` scalar type represents non-fractional signed whole numeric values. BigInt can represent arbitrary big values.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Boolean", + "description": "The `Boolean` scalar type represents `true` or `false`.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Float", + "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "ID", + "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Int", + "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Long", + "description": "The `Long` scalar type represents non-fractional signed whole numeric values. Long can represent values between -(2^63) and 2^63 - 1.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "String", + "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + } + ], + "directives": [ + { + "name": "include", + "description": "Directs the executor to include this field or fragment only when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Included when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + } + ] + }, + { + "name": "skip", + "description": "Directs the executor to skip this field or fragment when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Included when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + } + ] + }, + { + "name": "deprecated", + "description": "Marks an element of a GraphQL schema as no longer supported.", + "locations": [ + "ENUM_VALUE", + "FIELD_DEFINITION" + ], + "args": [ + { + "name": "reason", + "description": "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formattedin [Markdown](https://daringfireball.net/projects/markdown/).", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": "\"No longer supported\"" + } + ] + } + ] + } + } +} diff --git a/src/test/scala/rocks/muki/graphql/codegen/CodegenBaseSpec.scala b/src/test/scala/rocks/muki/graphql/codegen/CodegenBaseSpec.scala new file mode 100644 index 0000000..039c4a8 --- /dev/null +++ b/src/test/scala/rocks/muki/graphql/codegen/CodegenBaseSpec.scala @@ -0,0 +1,67 @@ +/* + * Copyright 2017 Mediative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rocks.muki.graphql.codegen + +import org.scalatest.{EitherValues, WordSpec} +import java.io.File +import sbt._ + +import rocks.muki.graphql.schema.SchemaLoader + +import scala.io.Source +import scala.meta._ +import sangria.schema.Schema + +abstract class CodegenBaseSpec(name: String, + schema: Option[Schema[_, _]] = None) + extends WordSpec + with EitherValues { + def this(name: String, schema: Schema[_, _]) = this(name, Some(schema)) + + val inputDir = new File("src/test/resources", name) + + def contentOf(file: File) = + Source.fromFile(file).mkString + + "SangriaCodegen" should { + for { + input <- inputDir.listFiles() + if input.getName.endsWith(".graphql") + name = input.getName.replace(".graphql", "") + expected = new File(inputDir, s"$name.scala") + if expected.exists + } { + s"generate code for ${input.getName}" in { + val generator = ScalametaGenerator(s"${name}Api") + val schema = + SchemaLoader.fromFile(inputDir / "schema.graphql").loadSchema() + + val document = DocumentLoader.single(schema, input).right.value + val typedDocument = + TypedDocumentParser(schema, document).parse().right.value + val out = generator(typedDocument).right.value + + val actual = out.show[Syntax] + + if (actual.trim != contentOf(expected).trim) + println(actual) + + assert(actual.trim == contentOf(expected).trim) + } + } + } +} diff --git a/src/test/scala/rocks/muki/graphql/codegen/apollo/ApolloBlogCodegenSpec.scala b/src/test/scala/rocks/muki/graphql/codegen/apollo/ApolloBlogCodegenSpec.scala new file mode 100644 index 0000000..ff19401 --- /dev/null +++ b/src/test/scala/rocks/muki/graphql/codegen/apollo/ApolloBlogCodegenSpec.scala @@ -0,0 +1,14 @@ +package rocks.muki.graphql.codegen.apollo + +import rocks.muki.graphql.codegen.{ + ApolloSourceGenerator, + GraphQLQueryGenerator +} + +class ApolloBlogCodegenSpec + extends ApolloCodegenBaseSpec( + "apollo/blog", + (fileName: String) => + ApolloSourceGenerator(fileName, + GraphQLQueryGenerator.imports("com.example"), + GraphQLQueryGenerator.inits)) diff --git a/src/test/scala/rocks/muki/graphql/codegen/apollo/ApolloCodegenBaseSpec.scala b/src/test/scala/rocks/muki/graphql/codegen/apollo/ApolloCodegenBaseSpec.scala new file mode 100644 index 0000000..bcad98b --- /dev/null +++ b/src/test/scala/rocks/muki/graphql/codegen/apollo/ApolloCodegenBaseSpec.scala @@ -0,0 +1,68 @@ +/* + * Copyright 2017 Mediative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package rocks.muki.graphql.codegen.apollo + +import org.scalatest.{EitherValues, TryValues, WordSpec} +import java.io.File + +import rocks.muki.graphql.codegen.{ + DocumentLoader, + Generator, + TypedDocumentParser +} +import rocks.muki.graphql.schema.SchemaLoader + +import scala.io.{Source => IOSource, Codec} +import scala.meta._ +import sbt._ + +abstract class ApolloCodegenBaseSpec( + name: String, + generator: (String => Generator[List[Stat]])) + extends WordSpec + with EitherValues + with TryValues { + + val inputDir = new File(s"src/test/resources/$name") + + def contentOf(file: File): String = + IOSource.fromFile(file)(Codec.UTF8).mkString + + "Apollo Sangria Codegen" should { + for { + input <- inputDir.listFiles() + if input.getName.endsWith(".graphql") + name = input.getName.replace(".graphql", "") + expected = new File(inputDir, s"$name.scala") + if expected.exists + } { + s"generate code for ${input.getName}" in { + + val schema = + SchemaLoader.fromFile(inputDir / "schema.graphql").loadSchema() + val document = DocumentLoader.single(schema, input).right.value + val typedDocument = + TypedDocumentParser(schema, document).parse().right.value + val stats = generator(input.getName)(typedDocument).right.value + + val actual = stats.map(_.show[Syntax]).mkString("\n") + val expectedSource = contentOf(expected).parse[Source].get + + assert(actual === expectedSource.show[Syntax].trim, actual) + } + } + } +} diff --git a/src/test/scala/rocks/muki/graphql/codegen/apollo/ApolloStarWarsCodegenSpec.scala b/src/test/scala/rocks/muki/graphql/codegen/apollo/ApolloStarWarsCodegenSpec.scala new file mode 100644 index 0000000..1209122 --- /dev/null +++ b/src/test/scala/rocks/muki/graphql/codegen/apollo/ApolloStarWarsCodegenSpec.scala @@ -0,0 +1,14 @@ +package rocks.muki.graphql.codegen.apollo + +import rocks.muki.graphql.codegen.{ + ApolloSourceGenerator, + GraphQLQueryGenerator +} + +class ApolloStarWarsCodegenSpec + extends ApolloCodegenBaseSpec( + "apollo/starwars", + (fileName: String) => + ApolloSourceGenerator(fileName, + GraphQLQueryGenerator.imports("com.example"), + GraphQLQueryGenerator.inits)) diff --git a/src/test/scala/rocks/muki/graphql/codegen/blog/BlogCodegenSpec.scala b/src/test/scala/rocks/muki/graphql/codegen/blog/BlogCodegenSpec.scala new file mode 100644 index 0000000..3b51fec --- /dev/null +++ b/src/test/scala/rocks/muki/graphql/codegen/blog/BlogCodegenSpec.scala @@ -0,0 +1,20 @@ +/* + * Copyright 2017 Mediative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rocks.muki.graphql.codegen +package blog + +class BlogSpec extends CodegenBaseSpec("blog") diff --git a/src/test/scala/rocks/muki/graphql/codegen/starwars/StarWarsCodegenSpec.scala b/src/test/scala/rocks/muki/graphql/codegen/starwars/StarWarsCodegenSpec.scala new file mode 100644 index 0000000..aed9ae9 --- /dev/null +++ b/src/test/scala/rocks/muki/graphql/codegen/starwars/StarWarsCodegenSpec.scala @@ -0,0 +1,21 @@ +/* + * Copyright 2017 Mediative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rocks.muki.graphql.codegen +package starwars + +class StarWarsCodegenSpec + extends CodegenBaseSpec("starwars", TestSchema.StarWarsSchema) diff --git a/src/test/scala/rocks/muki/graphql/codegen/starwars/TestData.scala b/src/test/scala/rocks/muki/graphql/codegen/starwars/TestData.scala new file mode 100644 index 0000000..ed0ec27 --- /dev/null +++ b/src/test/scala/rocks/muki/graphql/codegen/starwars/TestData.scala @@ -0,0 +1,125 @@ +/* + * Copyright 2017 Oleg Ilyenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rocks.muki.graphql.codegen.starwars + +import sangria.execution.deferred.{Deferred, DeferredResolver} + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.Try + +object TestData { + object Episode extends Enumeration { + val NEWHOPE, EMPIRE, JEDI = Value + } + + trait Character { + def id: String + def name: Option[String] + def friends: List[String] + def appearsIn: List[Episode.Value] + } + + case class Human(id: String, + name: Option[String], + friends: List[String], + appearsIn: List[Episode.Value], + homePlanet: Option[String]) + extends Character + case class Droid(id: String, + name: Option[String], + friends: List[String], + appearsIn: List[Episode.Value], + primaryFunction: Option[String]) + extends Character + + case class DeferFriends(friends: List[String]) + extends Deferred[List[Option[Character]]] + + val characters = List[Character]( + Human( + id = "1000", + name = Some("Luke Skywalker"), + friends = List("1002", "1003", "2000", "2001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = Some("Tatooine") + ), + Human(id = "1001", + name = Some("Darth Vader"), + friends = List("1004"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = Some("Tatooine")), + Human(id = "1002", + name = Some("Han Solo"), + friends = List("1000", "1003", "2001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = None), + Human( + id = "1003", + name = Some("Leia Organa"), + friends = List("1000", "1002", "2000", "2001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = Some("Alderaan") + ), + Human(id = "1004", + name = Some("Wilhuff Tarkin"), + friends = List("1001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = None), + Droid( + id = "2000", + name = Some("C-3PO"), + friends = List("1000", "1002", "1003", "2001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + primaryFunction = Some("Protocol") + ), + Droid( + id = "2001", + name = Some("R2-D2"), + friends = List("1000", "1002", "1003"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + primaryFunction = Some("Astromech") + ) + ) + + class FriendsResolver extends DeferredResolver[Any] { + override def resolve(deferred: Vector[Deferred[Any]], + ctx: Any, + queryState: Any)(implicit ec: ExecutionContext) = + deferred map { + case DeferFriends(friendIds) ⇒ + Future.fromTry(Try(friendIds map (id ⇒ characters.find(_.id == id)))) + } + } + + class CharacterRepo { + def getHero(episode: Option[Episode.Value]) = + episode flatMap (_ ⇒ getHuman("1000")) getOrElse characters.last + + def getHuman(id: String): Option[Human] = + characters + .find(c ⇒ c.isInstanceOf[Human] && c.id == id) + .asInstanceOf[Option[Human]] + + def getDroid(id: String): Option[Droid] = + characters + .find(c ⇒ c.isInstanceOf[Droid] && c.id == id) + .asInstanceOf[Option[Droid]] + + def getCharacters(ids: Seq[String]): Seq[Character] = + ids.flatMap(id ⇒ characters.find(_.id == id)) + } +} diff --git a/src/test/scala/rocks/muki/graphql/codegen/starwars/TestSchema.scala b/src/test/scala/rocks/muki/graphql/codegen/starwars/TestSchema.scala new file mode 100644 index 0000000..3a9c815 --- /dev/null +++ b/src/test/scala/rocks/muki/graphql/codegen/starwars/TestSchema.scala @@ -0,0 +1,172 @@ +/* + * Copyright 2017 Oleg Ilyenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rocks.muki.graphql.codegen.starwars + +import sangria.execution.UserFacingError +import sangria.schema._ + +import scala.concurrent.Future + +object TestSchema { + import TestData._ + + case class PrivacyError(message: String) + extends Exception(message) + with UserFacingError + + val EpisodeEnum = EnumType( + "Episode", + Some("One of the films in the Star Wars Trilogy"), + List( + EnumValue("NEWHOPE", + value = TestData.Episode.NEWHOPE, + description = Some("Released in 1977.")), + EnumValue("EMPIRE", + value = TestData.Episode.EMPIRE, + description = Some("Released in 1980.")), + EnumValue("JEDI", + value = TestData.Episode.JEDI, + description = Some("Released in 1983.")) + ) + ) + + val Character: InterfaceType[Unit, TestData.Character] = + InterfaceType( + "Character", + "A character in the Star Wars Trilogy", + () ⇒ + fields[Unit, TestData.Character]( + Field("id", + StringType, + Some("The id of the character."), + resolve = _.value.id), + Field("name", + OptionType(StringType), + Some("The name of the character."), + resolve = _.value.name), + Field( + "friends", + OptionType(ListType(OptionType(Character))), + Some( + "The friends of the character, or an empty list if they have none."), + resolve = ctx ⇒ DeferFriends(ctx.value.friends) + ), + Field("appearsIn", + OptionType(ListType(OptionType(EpisodeEnum))), + Some("Which movies they appear in."), + resolve = _.value.appearsIn map (e ⇒ Some(e))), + Field( + "secretBackstory", + OptionType(StringType), + Some("Where are they from and how they came to be who they are."), + resolve = _ ⇒ throw PrivacyError("secretBackstory is secret.") + ) + ) + ) + + val Human = + ObjectType( + "Human", + "A humanoid creature in the Star Wars universe.", + interfaces[Unit, Human](PossibleInterface[Unit, Human](Character)), + fields[Unit, Human]( + Field("id", + StringType, + Some("The id of the human."), + resolve = _.value.id), + Field("name", + OptionType(StringType), + Some("The name of the human."), + resolve = _.value.name), + Field( + "friends", + OptionType(ListType(OptionType(Character))), + Some( + "The friends of the human, or an empty list if they have none."), + resolve = (ctx) ⇒ DeferFriends(ctx.value.friends) + ), + Field("appearsIn", + OptionType(ListType(OptionType(EpisodeEnum))), + Some("Which movies they appear in."), + resolve = _.value.appearsIn map (e ⇒ Some(e))), + Field("homePlanet", + OptionType(StringType), + Some("The home planet of the human, or null if unknown."), + resolve = _.value.homePlanet) + ) + ) + + val Droid = ObjectType( + "Droid", + "A mechanical creature in the Star Wars universe.", + interfaces[Unit, Droid](PossibleInterface[Unit, Droid](Character)), + fields[Unit, Droid]( + Field("id", + StringType, + Some("The id of the droid."), + tags = ProjectionName("_id") :: Nil, + resolve = _.value.id), + Field("name", + OptionType(StringType), + Some("The name of the droid."), + resolve = ctx ⇒ Future.successful(ctx.value.name)), + Field( + "friends", + OptionType(ListType(OptionType(Character))), + Some("The friends of the droid, or an empty list if they have none."), + resolve = ctx ⇒ DeferFriends(ctx.value.friends) + ), + Field("appearsIn", + OptionType(ListType(OptionType(EpisodeEnum))), + Some("Which movies they appear in."), + resolve = _.value.appearsIn map (e ⇒ Some(e))), + Field("primaryFunction", + OptionType(StringType), + Some("The primary function of the droid."), + resolve = _.value.primaryFunction) + ) + ) + + val ID = Argument("id", StringType, description = "id of the character") + + val EpisodeArg = Argument( + "episode", + OptionInputType(EpisodeEnum), + description = + "If omitted, returns the hero of the whole saga. If provided, returns the hero of that particular episode." + ) + + val Query = ObjectType[CharacterRepo, Unit]( + "Query", + fields[CharacterRepo, Unit]( + Field("hero", + Character, + arguments = EpisodeArg :: Nil, + resolve = (ctx) ⇒ ctx.ctx.getHero(ctx.arg(EpisodeArg))), + Field("human", + OptionType(Human), + arguments = ID :: Nil, + resolve = ctx ⇒ ctx.ctx.getHuman(ctx arg ID)), + Field("droid", + Droid, + arguments = ID :: Nil, + resolve = Projector((ctx, f) ⇒ ctx.ctx.getDroid(ctx arg ID).get)) + ) + ) + + val StarWarsSchema = Schema(Query) +} diff --git a/test-project/build.sbt b/test-project/build.sbt index 41d0a78..8d6fd43 100644 --- a/test-project/build.sbt +++ b/test-project/build.sbt @@ -1,25 +1,51 @@ -name := "graphql-test-project" -version := "0.4" +lazy val root = project.in(file(".")) + .aggregate(server, client) -enablePlugins(GraphQLSchemaPlugin, GraphQLQueryPlugin) +lazy val server = project.in(file("server")) + .enablePlugins(GraphQLSchemaPlugin, GraphQLQueryPlugin) + .configs(IntegrationTest) + .settings(commonSettings, Defaults.itSettings) + .settings( + graphqlSchemaSnippet := "example.StarWarsSchema.schema", + // integration settings + graphqlQueryDirectory in IntegrationTest := (sourceDirectory in IntegrationTest).value / "graphql" + ) + .settings( + addCommandAlias("validateStarWars", "graphqlValidateSchema build starwars") + ) -libraryDependencies ++= Seq( - "org.sangria-graphql" %% "sangria" % "1.3.0", - "org.sangria-graphql" %% "sangria-circe" % "1.1.0" -) - -graphqlSchemaSnippet := "example.StarWarsSchema.schema" +lazy val client = project.in(file("client")) + .enablePlugins(GraphQLCodegenPlugin, GraphQLQueryPlugin) + .settings(commonSettings) + .settings( + graphqlCodegenStyle := Sangria, + graphqlCodegenSchema := graphqlRenderSchema.toTask("starwars").value, + resourceDirectories in graphqlCodegen := List( + (sourceDirectory in Compile).value / "graphql", + ), + graphqlCodegenPackage := "rocks.muki.graphql", + name in graphqlCodegen := "Api", + // includeFilter in graphqlCodegen := "product.graphql" + ) -graphqlSchemas += GraphQLSchema( - "sangria-example", - "staging schema at http://try.sangria-graphql.org/graphql", - Def.task( - GraphQLSchemaLoader - .fromIntrospection("http://try.sangria-graphql.org/graphql", streams.value.log) - .withHeaders("User-Agent" -> "sbt-graphql/${version.value}") - .loadSchema() - ).taskValue +lazy val commonSettings = Seq( + version := "0.4", + scalaVersion := "2.12.4", + organization := "rocks.muki", + libraryDependencies ++= Seq( + "org.sangria-graphql" %% "sangria" % "1.3.2", + "org.sangria-graphql" %% "sangria-circe" % "1.1.0" + ), + // define schemas available in all builds + graphqlSchemas += GraphQLSchema( + "starwars", + "starwars schema at http://try.sangria-graphql.org/graphql", + Def.task( + GraphQLSchemaLoader + .fromIntrospection("http://try.sangria-graphql.org/graphql", streams.value.log) + .withHeaders("User-Agent" -> s"sbt-graphql/${version.value}") + .loadSchema() + ).taskValue + ) ) - -addCommandAlias("validateSangriaExample", "graphqlValidateSchema build sangria-example") diff --git a/test-project/src/main/graphql/heroAndFriends.graphql b/test-project/client/src/main/graphql/HeroAndFriends.graphql similarity index 100% rename from test-project/src/main/graphql/heroAndFriends.graphql rename to test-project/client/src/main/graphql/HeroAndFriends.graphql diff --git a/test-project/client/src/main/graphql/InputVariables.graphql b/test-project/client/src/main/graphql/InputVariables.graphql new file mode 100644 index 0000000..d895d4d --- /dev/null +++ b/test-project/client/src/main/graphql/InputVariables.graphql @@ -0,0 +1,6 @@ +query InputVariables($humanId: String!) { + human(id: $humanId) { + name, + homePlanet + } +} diff --git a/test-project/project/build.properties b/test-project/project/build.properties index b7dd3cb..8b697bb 100644 --- a/test-project/project/build.properties +++ b/test-project/project/build.properties @@ -1 +1 @@ -sbt.version=1.0.2 +sbt.version=1.1.0 diff --git a/test-project/src/main/graphql/broken.graphql b/test-project/server/src/it/graphql/broken.graphql similarity index 100% rename from test-project/src/main/graphql/broken.graphql rename to test-project/server/src/it/graphql/broken.graphql diff --git a/test-project/server/src/it/graphql/heroAndFriends.graphql b/test-project/server/src/it/graphql/heroAndFriends.graphql new file mode 100644 index 0000000..bee15c5 --- /dev/null +++ b/test-project/server/src/it/graphql/heroAndFriends.graphql @@ -0,0 +1,8 @@ +query HeroAndFriends { + hero { + name + friends { + name + } + } +} \ No newline at end of file diff --git a/test-project/src/main/graphql/product.graphql b/test-project/server/src/it/graphql/product.graphql similarity index 100% rename from test-project/src/main/graphql/product.graphql rename to test-project/server/src/it/graphql/product.graphql diff --git a/test-project/src/main/graphql/specificProduct.graphql b/test-project/server/src/it/graphql/specificProduct.graphql similarity index 100% rename from test-project/src/main/graphql/specificProduct.graphql rename to test-project/server/src/it/graphql/specificProduct.graphql diff --git a/test-project/src/main/resources/schema.graphql b/test-project/server/src/main/resources/schema.graphql similarity index 100% rename from test-project/src/main/resources/schema.graphql rename to test-project/server/src/main/resources/schema.graphql diff --git a/test-project/src/main/scala/example/ProductSchema.scala b/test-project/server/src/main/scala/example/ProductSchema.scala similarity index 99% rename from test-project/src/main/scala/example/ProductSchema.scala rename to test-project/server/src/main/scala/example/ProductSchema.scala index 6ed8c30..1f1dcb9 100644 --- a/test-project/src/main/scala/example/ProductSchema.scala +++ b/test-project/server/src/main/scala/example/ProductSchema.scala @@ -69,4 +69,3 @@ class ProductRepo { def products: List[Product] = Products } - diff --git a/test-project/src/main/scala/example/StarWarsSchema.scala b/test-project/server/src/main/scala/example/StarWarsSchema.scala similarity index 57% rename from test-project/src/main/scala/example/StarWarsSchema.scala rename to test-project/server/src/main/scala/example/StarWarsSchema.scala index ef18596..8c38848 100644 --- a/test-project/src/main/scala/example/StarWarsSchema.scala +++ b/test-project/server/src/main/scala/example/StarWarsSchema.scala @@ -13,14 +13,14 @@ object StarWarsSchema { Some("One of the films in the Star Wars Trilogy"), List( EnumValue("NEWHOPE", - value = TestData.Episode.NEWHOPE, - description = Some("Released in 1977.")), + value = TestData.Episode.NEWHOPE, + description = Some("Released in 1977.")), EnumValue("EMPIRE", - value = TestData.Episode.EMPIRE, - description = Some("Released in 1980.")), + value = TestData.Episode.EMPIRE, + description = Some("Released in 1980.")), EnumValue("JEDI", - value = TestData.Episode.JEDI, - description = Some("Released in 1983.")) + value = TestData.Episode.JEDI, + description = Some("Released in 1983.")) ) ) @@ -29,26 +29,26 @@ object StarWarsSchema { "Character", "A character in the Star Wars Trilogy", () ⇒ - fields[Unit, TestData.Character]( - Field("id", - StringType, - Some("The id of the character."), - resolve = _.value.id), - Field("name", - OptionType(StringType), - Some("The name of the character."), - resolve = _.value.name), - Field( - "friends", - OptionType(ListType(OptionType(Character))), - Some( - "The friends of the character, or an empty list if they have none."), - resolve = ctx ⇒ DeferFriends(ctx.value.friends) - ), - Field("appearsIn", - OptionType(ListType(OptionType(EpisodeEnum))), - Some("Which movies they appear in."), - resolve = _.value.appearsIn map (e ⇒ Some(e))) + fields[Unit, TestData.Character]( + Field("id", + StringType, + Some("The id of the character."), + resolve = _.value.id), + Field("name", + OptionType(StringType), + Some("The name of the character."), + resolve = _.value.name), + Field( + "friends", + OptionType(ListType(OptionType(Character))), + Some( + "The friends of the character, or an empty list if they have none."), + resolve = ctx ⇒ DeferFriends(ctx.value.friends) + ), + Field("appearsIn", + OptionType(ListType(OptionType(EpisodeEnum))), + Some("Which movies they appear in."), + resolve = _.value.appearsIn map (e ⇒ Some(e))) ) ) @@ -58,28 +58,28 @@ object StarWarsSchema { "A humanoid creature in the Star Wars universe.", interfaces[Unit, Human](Character), fields[Unit, Human]( - Field("id", - StringType, - Some("The id of the human."), - resolve = _.value.id), - Field("name", - OptionType(StringType), - Some("The name of the human."), - resolve = _.value.name), - Field( - "friends", - OptionType(ListType(OptionType(Character))), - Some("The friends of the human, or an empty list if they have none."), - resolve = ctx ⇒ DeferFriends(ctx.value.friends) - ), - Field("appearsIn", - OptionType(ListType(OptionType(EpisodeEnum))), - Some("Which movies they appear in."), - resolve = _.value.appearsIn map (e ⇒ Some(e))), - Field("homePlanet", - OptionType(StringType), - Some("The home planet of the human, or null if unknown."), - resolve = _.value.homePlanet) + Field("id", + StringType, + Some("The id of the human."), + resolve = _.value.id), + Field("name", + OptionType(StringType), + Some("The name of the human."), + resolve = _.value.name), + Field( + "friends", + OptionType(ListType(OptionType(Character))), + Some("The friends of the human, or an empty list if they have none."), + resolve = ctx ⇒ DeferFriends(ctx.value.friends) + ), + Field("appearsIn", + OptionType(ListType(OptionType(EpisodeEnum))), + Some("Which movies they appear in."), + resolve = _.value.appearsIn map (e ⇒ Some(e))), + Field("homePlanet", + OptionType(StringType), + Some("The home planet of the human, or null if unknown."), + resolve = _.value.homePlanet) ) ) @@ -89,28 +89,28 @@ object StarWarsSchema { interfaces[Unit, Droid](Character), fields[Unit, Droid]( Field("id", - StringType, - Some("The id of the droid."), - tags = ProjectionName("_id") :: Nil, - resolve = _.value.id), + StringType, + Some("The id of the droid."), + tags = ProjectionName("_id") :: Nil, + resolve = _.value.id), Field("name", - OptionType(StringType), - Some("The name of the droid."), - resolve = ctx ⇒ Future.successful(ctx.value.name)), + OptionType(StringType), + Some("The name of the droid."), + resolve = ctx ⇒ Future.successful(ctx.value.name)), Field( - "friends", - OptionType(ListType(OptionType(Character))), - Some("The friends of the droid, or an empty list if they have none."), - resolve = ctx ⇒ DeferFriends(ctx.value.friends) + "friends", + OptionType(ListType(OptionType(Character))), + Some("The friends of the droid, or an empty list if they have none."), + resolve = ctx ⇒ DeferFriends(ctx.value.friends) ), Field("appearsIn", - OptionType(ListType(OptionType(EpisodeEnum))), - Some("Which movies they appear in."), - resolve = _.value.appearsIn map (e ⇒ Some(e))), + OptionType(ListType(OptionType(EpisodeEnum))), + Some("Which movies they appear in."), + resolve = _.value.appearsIn map (e ⇒ Some(e))), Field("primaryFunction", - OptionType(StringType), - Some("The primary function of the droid."), - resolve = _.value.primaryFunction) + OptionType(StringType), + Some("The primary function of the droid."), + resolve = _.value.primaryFunction) ) ) @@ -127,17 +127,17 @@ object StarWarsSchema { "Query", fields[CharacterRepo, Unit]( Field("hero", - Character, - arguments = EpisodeArg :: Nil, - resolve = ctx ⇒ ctx.ctx.getHero(ctx.arg(EpisodeArg))), + Character, + arguments = EpisodeArg :: Nil, + resolve = ctx ⇒ ctx.ctx.getHero(ctx.arg(EpisodeArg))), Field("human", - OptionType(Human), - arguments = ID :: Nil, - resolve = ctx ⇒ ctx.ctx.getHuman(ctx arg ID)), + OptionType(Human), + arguments = ID :: Nil, + resolve = ctx ⇒ ctx.ctx.getHuman(ctx arg ID)), Field("droid", - Droid, - arguments = ID :: Nil, - resolve = Projector((ctx, f) ⇒ ctx.ctx.getDroid(ctx arg ID).get)) + Droid, + arguments = ID :: Nil, + resolve = Projector((ctx, f) ⇒ ctx.ctx.getDroid(ctx arg ID).get)) ) ) @@ -217,7 +217,7 @@ object TestData { class FriendsResolver extends DeferredResolver[Any] { override def resolve(deferred: Vector[Deferred[Any]], ctx: Any, queryState: Any)(implicit ec: ExecutionContext) = deferred map { case DeferFriends(friendIds) ⇒ - Future.fromTry(Try(friendIds map (id ⇒ characters.find(_.id == id)))) + Future.fromTry(Try(friendIds map (id ⇒ characters.find(_.id == id)))) } }