Skip to content

Instantly share code, notes, and snippets.

@cgbystrom
Created July 16, 2015 17:31
Show Gist options
  • Save cgbystrom/448fd0f2aad91f28c45e to your computer and use it in GitHub Desktop.
Save cgbystrom/448fd0f2aad91f28c45e to your computer and use it in GitHub Desktop.
Testing ways of mapping a GraphQL schema on to Scala's type system. See https://github.com/hrosenhorn/graphql-scala for more details.
// An example for how to map a GraphQL schema on to Scala's type system.
// Work in progress and still lots of unknowns but the great benefit we are after is true type safety at compile time.
// Tries to mimic the original Star Wars schema found in graphql-js reference impl.
// The schema below does not deal with Futures at all but it something we definitively need to support.
import scala.annotation.StaticAnnotation
// Test fixture data
object Data {
val luke = new Human(
id = "1000",
name = "Luke Skywalker",
friendIds = List("1002", "1003", "2000", "2001"),
appearsInIds = List(4, 5, 6),
_homePlanet = Some("Tatooine")
)
val vader = new Human(
id = "1001",
name = "Darth Vader",
friendIds = List("1004"),
appearsInIds = List(4, 5, 6),
_homePlanet = Some("Tatooine")
)
val han = new Human(
id = "1002",
name = "Han Solo",
friendIds = List("1000", "1003", "2001"),
appearsInIds = List(4, 5, 6)
)
val leia = new Human(
id = "1002",
name = "Leia Organa",
friendIds = List("1000", "1002", "2000", "2001"),
appearsInIds = List(4, 5, 6),
_homePlanet = Some("Alderaan")
)
val tarkin = new Human(
id = "1004",
name = "Wilhuff Tarkin",
friendIds = List("1001"),
appearsInIds = List(4)
)
val humanData = Map(
"1000" -> luke,
"1001" -> vader,
"1002" -> han,
"1003" -> leia,
"1004" -> tarkin
)
val threepio = new Droid(
id = "2000",
name = "C-3PO",
friendIds = List("1000", "1002", "1003", "2001"),
appearsInIds = List(4, 5, 6),
_primaryFunction = Some("Protocol")
)
val artoo = new Droid(
id = "2001",
name = "R2-D2",
friendIds = List("1000", "1002", "1003"),
appearsInIds = List(4, 5, 6),
_primaryFunction = Some("Astromech")
)
val droidData = Map(
"2000" -> threepio,
"2001" -> artoo
)
/** Helper function to get a character by ID. */
def getCharacter(id: String) = Option(humanData.getOrElse(id, droidData.getOrElse(id, null)))
/** Helper function to get a character by ID. */
def getEpisode(id: Int) = Option(Episode.lookup(id))
/** Allows us to query for a character's friends. */
def getFriends(friendsIds: List[String]): List[Character] = friendsIds.flatMap(getCharacter)
/** Allows us to query for a character's friends. */
def getEpisodes(episodeIds: List[Int]) = episodeIds.flatMap(getEpisode)
}
// Placeholders for now. For Scala to retain annotations at runtime, they need to be declared in Java.
// Annotations can be used to express metadata needed by the GraphQL introspection.
case class Interface(desc: String = "__notset") extends StaticAnnotation
case class Field(desc: String = "__notset") extends StaticAnnotation
case class Object(desc: String = "__notset") extends StaticAnnotation
// A NonNull annotation might be useful over Scala's Option since GraphQL can guarantee
// that input are for example, non-null. Hence no need for handling it in end-user code at all.
case class NonNull() extends StaticAnnotation
// Ghetto implementation of a GraphQL Enum. Consider it a placeholder.
// TBD: Use Scala Enumeration or a GraphQLEnum?
case class Episode(value: Any, description: String)
object Episode {
val NEWHOPE = Episode(4, "Released in 1977.")
val EMPIRE = Episode(5, "Released in 1980.")
val JEDI = Episode(6, "Released in 1983.")
val lookup = Map(NEWHOPE.value -> NEWHOPE, EMPIRE.value -> EMPIRE, JEDI.value -> JEDI)
}
// Example below uses Scala native data types instead of GraphQLString, GraphQLList etc.
// Still something we need to figure out.
@Interface(desc="A character in the Star Wars Trilogy")
trait Character {
@Field(desc="The id of the character.")
def id: String
@Field(desc="The name of the character.")
def name: String
@Field(desc="The friends of the character, or an empty list if they have none.")
def friends: List[Character]
@Field(desc="Which movies they appear in.")
def appearsIn: List[Episode]
// TBD: How do handle resolveType?
// In graphql-js, Character is aware of its implementors (why?).
}
// Human and Droid are actual classes and instances unlike graphql-js. The reason for this is to enforce type safety.
// Rather than relying on the untyped "source" parameter JS object/Scala Map,
// the data required for Human/Droid to do it's job is passed to the constructor.
// It is of course a fine balance how much data you should require in the constructor.
// Preferably as little a possible to avoid overfetching from underlying data sources (other services or a database).
// Additional nesting of fields/objects will likely help address this problem.
@Object(desc="A humanoid creature in the Star Wars universe.")
case class Human(id: String, name: String, friendIds: List[String], appearsInIds: List[Int], _homePlanet: Option[String] = None) extends Character {
// Note how friends and episodes will only be resolved when asked for (by calling the function, aka the resolver)
// Right now, these are fast in-memory lookups but they can be something slow, say a database query.
override def friends = Data.getFriends(friendIds)
override def appearsIn = Data.getEpisodes(appearsInIds)
@Field(desc="The home planet of the human, or null if unknown.")
def homePlanet: String = _homePlanet.orNull
}
@Object(desc="A mechanical creature in the Star Wars universe.")
case class Droid(id: String, name: String, friendIds: List[String], appearsInIds: List[Int], _primaryFunction: Option[String] = None) extends Character {
// Same as in Human. The reason for these being duplicated in each GraphQL object class (Human and Droid)
// is that a GraphQLInterface cannot have resolve functions.
override def friends = Data.getFriends(friendIds)
override def appearsIn = Data.getEpisodes(appearsInIds)
@Field(desc="The primary function of the droid.")
def primaryFunction: String = _primaryFunction.orNull
}
@Object
class Query {
def hero: Character = Data.artoo
def human(@NonNull @Field("ID of the human") id: String): Human = {
// Not ideal null checking, but got type erasure if matching on Option[Character].
val c = Data.getCharacter(id).orNull
c match {
case h: Human => h
case _ => throw new IllegalArgumentException("Invalid human ID given")
}
}
def droid(@NonNull id: String): Droid = {
val c = Data.getCharacter(id).orNull
c match {
case d: Droid => d
case _ => throw new IllegalArgumentException("Invalid droid ID given")
}
}
}
object Test {
def main(args: Array[String]) {
val q = new Query()
println(s"The true hero in Star Wars is ${q.hero.name}")
println(s"A friend of the hero is ${q.hero.friends(0).name}")
println(s"Darth Vader appears in ${q.human("1001").appearsIn}")
println(s"C3-PO's primary function is ${q.droid("2000").primaryFunction}")
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment