Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvements #1

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ project/plugins/project/
.history
.cache
.lib/
.idea
3 changes: 2 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ description := "TODO"
scalaVersion := "2.12.1"
scalacOptions ++= Seq("-unchecked", "-deprecation","-feature")

libraryDependencies += "org.sangria-graphql" %% "sangria" % "1.0.0"
libraryDependencies += "org.sangria-graphql" %% "sangria" % "1.1.0"
libraryDependencies += "org.sangria-graphql" %% "sangria-spray-json" % "1.0.0"
libraryDependencies += "com.typesafe.akka" %% "akka-http" % "10.0.1"
libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % "10.0.1"
libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.1" % Test
1 change: 1 addition & 0 deletions project/build.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sbt.version=0.13.13
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm very much unaware of Scala tooling as I'm a complete beginner. Any specific reason why you added this + the .idea in the .gitignore file?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sbt.version locks down the SBT version. If you don't have this defines, SBT launcher will take whatever is installed in your system and it is quite unreliable. I, for instance, haven't updated SBT setup for a long time and project failed to start because of this. build.properties fixed it.

.idea folder is just an intellij idea project files. You don't really want them in a git repo, so i ignored them :)

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re:  sbt.version, gotcha, added in 4bfd1d2.

Re: .idea, I prefer to keep OS-specific and IDE-specific files out of my projects and use a global ignore list instead. For example, see this and that. That's a personal preference but I prefer that over having to duplicate/maintain these in all project gitignore files :)

4 changes: 2 additions & 2 deletions src/main/resources/graphiql.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
copy them directly into your environment, or perhaps include them in your
favored resource bundler.
-->
<link rel="stylesheet" href="//cdn.jsdelivr.net/graphiql/0.8.0/graphiql.css" />
<script src="//cdn.jsdelivr.net/graphiql/0.8.0/graphiql.min.js"></script>
<link rel="stylesheet" href="//cdn.jsdelivr.net/graphiql/0.9.3/graphiql.css" />
<script src="//cdn.jsdelivr.net/graphiql/0.9.3/graphiql.min.js"></script>
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the rest of the HTML need to be updated as well or just the JS/CSS?

As a side note, I'll probably end up embedding these files in the repo instead of relying on a CDN in case I lose Internet connection during the live demo 😅.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, the rest should work just fine with the new version of GraphiQL

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sweet, done in 762db15. Thanks!


</head>
<body>
Expand Down
67 changes: 48 additions & 19 deletions src/main/scala/Models.scala
Original file line number Diff line number Diff line change
@@ -1,21 +1,38 @@
import spray.json._
import DefaultJsonProtocol._
import sangria.execution.deferred.HasId
import sangria.schema._
import sangria.macros.derive._

case class Category(
id: Int,
name: String
) {
def styles(allStyles: List[Style]): List[Style] =
allStyles.filter(_.category_id == id)
name: String)

object Category {
implicit val categoryHasId = HasId[Category, Int](_.id)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see HasId lives here, do you mind explaining what it's for?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HasId is a type-class needed for Fetch API to be able to cache and handle entities. It just provides an evidence/knowledge how to get an ID from your domain object (Category in this case)

implicit val categoryFormat = jsonFormat2(Category.apply)

implicit val graphqlType: ObjectType[Repository, Category] = deriveObjectType(
ObjectTypeDescription("A category"),
AddFields(Field("styles", ListType(Style.graphqlType),
resolve = c => c.ctx.styleFetcher.deferRelSeq(c.ctx.styleByCategory, c.value.id))))
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to keep my models GraphQL-free, separating layers sort of, so that case classes contain the business logic and the schema file focuses on the GraphQL integration. Was that a stupid idea?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The domain models in this case still completely unaware of JSON serialization or GraphQL. I guess it's just very convenient way to pack all type-class instances for particular case class in its companion object. They are indeed co-located in a single file, but from application's perspective, they are sill loosely coupled.


case class Style(
id: Int,
category_id: Int,
name: String
) {
def beers(allBeers: List[Beer]): List[Beer] =
allBeers.filter(_.style_id == id)
name: String)

object Style {
implicit val styleHasId = HasId[Style, Int](_.id)
implicit val styleFormat = jsonFormat3(Style.apply)

def category(allCategories: List[Category]): Category =
allCategories.find(_.id == category_id).get
implicit val graphqlType: ObjectType[Repository, Style] = deriveObjectType(
ObjectTypeDescription("A style"),
ReplaceField("category_id", Field("category", Category.graphqlType,
resolve = c => c.ctx.categoryFetcher.defer(c.value.category_id))),
AddFields(Field("beers", ListType(Beer.graphqlType),
resolve = c => c.ctx.beerFetcher.deferRelSeq(c.ctx.beerByStyle, c.value.id))))
}

case class Brewery(
Expand All @@ -28,10 +45,16 @@ case class Brewery(
country: String,
phone: String,
website: String,
description: String
) {
def beers(allBeers: List[Beer]): List[Beer] =
allBeers.filter(_.brewery_id == id)
description: String)

object Brewery {
implicit val breweryHasId = HasId[Brewery, Int](_.id)
implicit val breweryFormat = jsonFormat10(Brewery.apply)

implicit val graphqlType: ObjectType[Repository, Brewery] = deriveObjectType(
ObjectTypeDescription("A brewery"),
AddFields(Field("beers", ListType(Beer.graphqlType),
resolve = c => c.ctx.beerFetcher.deferRelSeq(c.ctx.beerByBrewery, c.value.id))))
}

case class Beer(
Expand All @@ -41,10 +64,16 @@ case class Beer(
name: String,
abv: Double,
description: String
) {
def brewery(allBreweries: List[Brewery]): Brewery =
allBreweries.find(_.id == brewery_id).get
)

object Beer {
implicit val beerHasId = HasId[Beer, Int](_.id)
implicit val beerFormat = jsonFormat6(Beer.apply)

def style(allStyles: List[Style]): Style =
allStyles.find(_.id == style_id).get
implicit val graphqlType: ObjectType[Repository, Beer] = deriveObjectType(
ObjectTypeDescription("A beer"),
ReplaceField("brewery_id", Field("brewery", Brewery.graphqlType,
resolve = c => c.ctx.breweryFetcher.defer(c.value.brewery_id))),
ReplaceField("style_id", Field("style", Style.graphqlType,
resolve = c => c.ctx.styleFetcher.defer(c.value.style_id))))
}
90 changes: 18 additions & 72 deletions src/main/scala/ProjectSchema.scala
Original file line number Diff line number Diff line change
@@ -1,92 +1,38 @@
import sangria.macros.derive._
import sangria.schema._

object ProjectSchema {
val Id = Argument("id", IntType)

val CategoryType: ObjectType[Repository, Category] = deriveObjectType(
ObjectTypeDescription("A category"),
AddFields(
Field("styles", ListType(StyleType),
resolve = c => c.value.styles(c.ctx.styles)
)
)
)

val StyleType: ObjectType[Repository, Style] = deriveObjectType(
ObjectTypeDescription("A style"),
ExcludeFields("category_id"),
AddFields(
Field("beers", ListType(BeerType),
resolve = c => c.value.beers(c.ctx.beers)
),
Field("category", CategoryType,
resolve = c => c.value.category(c.ctx.categories)
)
)
)

val BreweryType: ObjectType[Repository, Brewery] = deriveObjectType(
ObjectTypeDescription("A brewery"),
AddFields(
Field("beers", ListType(BeerType),
resolve = c => c.value.beers(c.ctx.beers)
)
)
)

// Type must be specified because of recursion: beer > brewery > beers > ...
val BeerType: ObjectType[Repository, Beer] = deriveObjectType(
ObjectTypeDescription("A beer"),
ExcludeFields("brewery_id", "style_id"),
AddFields(
Field("style", StyleType,
resolve = c => c.value.style(c.ctx.styles)
),
Field("brewery", BreweryType,
resolve = c => c.value.brewery(c.ctx.breweries)
)
)
)


val QueryType = ObjectType("Query", fields[Repository, Unit](
Field("categories", ListType(CategoryType),
Field("categories", ListType(Category.graphqlType),
description = Some("Returns a list of all categories"),
resolve = _.ctx.categories
),
Field("styles", ListType(StyleType),
resolve = _.ctx.categories),
Field("styles", ListType(Style.graphqlType),
description = Some("Returns a list of all styles"),
resolve = _.ctx.styles
),
Field("beers", ListType(BeerType),
resolve = _.ctx.styles),
Field("beers", ListType(Beer.graphqlType),
description = Some("Returns a list of all beers"),
resolve = _.ctx.beers
),
Field("breweries", ListType(BreweryType),
resolve = _.ctx.beers),
Field("breweries", ListType(Brewery.graphqlType),
description = Some("Returns a list of all breweries"),
resolve = _.ctx.breweries
),
Field("category", OptionType(CategoryType),
resolve = _.ctx.breweries),

Field("category", OptionType(Category.graphqlType),
description = Some("Returns a category"),
arguments = Id :: Nil,
resolve = c => c.ctx.category(c.arg(Id))
),
Field("style", OptionType(StyleType),
resolve = c => c.ctx.categoryFetcher.deferOpt(c.arg(Id))),
Field("style", OptionType(Style.graphqlType),
description = Some("Returns a style"),
arguments = Id :: Nil,
resolve = c => c.ctx.style(c.arg(Id))
),
Field("brewery", OptionType(BreweryType),
resolve = c => c.ctx.styleFetcher.deferOpt(c.arg(Id))),
Field("brewery", OptionType(Brewery.graphqlType),
description = Some("Returns a brewery"),
arguments = Id :: Nil,
resolve = c => c.ctx.brewery(c.arg(Id))
),
Field("beer", OptionType(BeerType),
resolve = c => c.ctx.breweryFetcher.deferOpt(c.arg(Id))),
Field("beer", OptionType(Beer.graphqlType),
description = Some("Returns a beer"),
arguments = Id :: Nil,
resolve = c => c.ctx.beer(c.arg(Id))
)
))
resolve = c => c.ctx.beerFetcher.deferOpt(c.arg(Id)))))

val schema = Schema(QueryType)
}
65 changes: 35 additions & 30 deletions src/main/scala/Repository.scala
Original file line number Diff line number Diff line change
@@ -1,44 +1,49 @@
import spray.json._
import DefaultJsonProtocol._
import sangria.execution.deferred.{DeferredResolver, Fetcher, Relation, RelationIds}

object Repository {
println("Data loaded:")
import scala.concurrent.Future.successful

val categorySource = io.Source.fromFile("src/main/resources/categories.json")("UTF-8").mkString.parseJson
implicit val categoryFormat = jsonFormat2(Category.apply)
val categories = categorySource.convertTo[List[Category]]
class Repository extends Fetchers {
val categories = loadFile[Category]("categories.json")
val styles = loadFile[Style]("styles.json")
val breweries = loadFile[Brewery]("breweries.json")
val beers = loadFile[Beer]("beers.json")

println(s" - ${categories.length} categories")

val styleSource = io.Source.fromFile("src/main/resources/styles.json")("UTF-8").mkString.parseJson
implicit val styleFormat = jsonFormat3(Style.apply)
val styles = styleSource.convertTo[List[Style]]
private def loadFile[T : JsonFormat](fileName: String): List[T] =
io.Source.fromResource(fileName)("UTF-8").mkString.parseJson.convertTo[List[T]]
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! I did try exactly that but I got confused with the required implicit definition of jsonFormatN, brilliant!


println(s" - ${styles.length} styles")
trait Fetchers {
val categoryFetcher = Fetcher.caching(
(repo: Repository, ids: Seq[Int]) =>
successful(repo.categories.filter(c => ids contains c.id)))

val brewerySource = io.Source.fromFile("src/main/resources/breweries.json")("UTF-8").mkString.parseJson
implicit val breweryFormat = jsonFormat10(Brewery.apply)
val breweries = brewerySource.convertTo[List[Brewery]]
val styleByCategory = Relation("byCategory", (s: Style) => Seq(s.category_id))

println(s" - ${breweries.length} breweries")
val styleFetcher = Fetcher.relCaching(
(repo: Repository, ids: Seq[Int]) =>
successful(repo.styles.filter(s => ids contains s.id)),
(repo: Repository, ids: RelationIds[Style]) =>
successful(repo.styles.filter(s => ids(styleByCategory) contains s.category_id)))

val beerSource = io.Source.fromFile("src/main/resources/beers.json")("UTF-8").mkString.parseJson
implicit val beerFormat = jsonFormat6(Beer.apply)
val beers = beerSource.convertTo[List[Beer]]
val breweryFetcher = Fetcher.caching(
(repo: Repository, ids: Seq[Int]) =>
successful(repo.breweries.filter(b => ids contains b.id)))

println(s" - ${beers.length} beers")
}
val beerByBrewery = Relation("byBrewery", (b: Beer) => Seq(b.brewery_id))
val beerByStyle = Relation("byStyle", (b: Beer) => Seq(b.style_id))

class Repository {
import Repository._
val beerFetcher = Fetcher.relCaching(
(repo: Repository, ids: Seq[Int]) =>
successful(repo.beers.filter(b => ids contains b.id)),
(repo: Repository, ids: RelationIds[Beer]) => {
val breweryIds = ids(beerByBrewery)
val styleIds = ids(beerByStyle)

val categories = Repository.categories
val styles = Repository.styles
val beers = Repository.beers
val breweries = Repository.breweries
successful(repo.beers.filter(b => breweryIds.contains(b.brewery_id) || styleIds.contains(b.style_id)))
})

def style(id: Int): Option[Style] = Repository.styles.find(_.id == id)
def category(id: Int): Option[Category] = Repository.categories.find(_.id == id)
def beer(id: Int): Option[Beer] = Repository.beers.find(_.id == id)
def brewery(id: Int): Option[Brewery] = Repository.breweries.find(_.id == id)
val deferredResolver = DeferredResolver.fetchers(
categoryFetcher, styleFetcher, breweryFetcher, beerFetcher)
}
30 changes: 15 additions & 15 deletions src/main/scala/Server.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,22 @@ object Server extends App {
}

QueryParser.parse(query) match {
// Query parsed successfully, time to execute it!
case Success(queryAst) => complete(
Executor.execute(
ProjectSchema.schema,
queryAst,
new Repository,
variables = vars,
operationName = operation
).map(OK -> _)
.recover {
case error: QueryAnalysisError => BadRequest -> error.resolveError
case error: ErrorWithResolver => InternalServerError -> error.resolveError
}
)
case Success(queryAst) =>
val reposiroty = new Repository

complete(
Executor.execute(
ProjectSchema.schema,
queryAst,
reposiroty,
variables = vars,
operationName = operation,
deferredResolver = reposiroty.deferredResolver
).map(OK -> _).recover {
case error: QueryAnalysisError => BadRequest -> error.resolveError
case error: ErrorWithResolver => InternalServerError -> error.resolveError
})

// Cannot parse GraphQL query, return error
case Failure(error) =>
complete(BadRequest, JsObject("error" -> JsString(error.getMessage)))
}
Expand Down
Loading