-
-
Save mmollaverdi/de79ede5d9054f75b72a to your computer and use it in GitHub Desktop.
// The following, models a HAL Resource based on HAL specification: | |
// http://stateless.co/hal_specification.html | |
// And provides Argonaut JSON encoders for that model | |
// (Argonaut is a purely functional Scala JSON library) | |
// http://argonaut.io/ | |
import shapeless._ | |
import shapeless.ops.hlist.{ToTraversable, Mapper} | |
import argonaut._, Argonaut._ | |
import scala.language.existentials | |
import scala.language.higherKinds | |
/////////////////////////// | |
// The model (case classes) | |
/////////////////////////// | |
// A HAL Resource has some links, some state and a list of embedded resources. | |
// http://stateless.co/info-model.png | |
// Embedded resources can each have different types of state, hence the use of shapeless Heterogenous lists. | |
// The implicit LUBConstraint value puts a constraint on the elements of HList to be subtypes of HalEmbeddedResource. | |
case class HalResource[T, L <: HList](links: List[HalLink], state: T, | |
embeddedResources: L = HNil)(implicit c: LUBConstraint[L, HalEmbeddedResource[_, _]]) | |
// TODO Add support for link array. Can also be extended further to support templated links, as well as | |
// other link attributes such as name, title, type, etc. | |
case class HalLink(rel: String, href: String) | |
// Each embedded resource has a "rel" (relation) attribute which is used as the key name for that resource | |
// inside "_embedded" tag in a HAL resource. | |
case class HalEmbeddedResource[T, L <: HList](rel: String, embedded: EmbeddedResource[T, L]) | |
// An embedded resource can be either a single resource (e.g. a single customer doucment embedded within an order document), | |
// or an array of resources (e.g. order items) | |
sealed trait EmbeddedResource[T, L] | |
case class SingleEmbeddedResource[T, L <: HList](embedded: HalResource[T, L]) extends EmbeddedResource[T, L] | |
case class ArrayEmbeddedResource[T, L <: HList](embedded: List[HalResource[T, L]]) extends EmbeddedResource[T, L] | |
object HalResource { | |
// This provides the implicit evidence that an empty HList (HNil) contains only elements which are of type HalEmbeddedResource[_] !!!!! | |
implicit val hnilLUBConstraint: LUBConstraint[HNil.type, HalEmbeddedResource[_, _]] = | |
new LUBConstraint[HNil.type, HalEmbeddedResource[_, _]] {} | |
} | |
///////////////////////// | |
// Argonaut Json Encoders | |
///////////////////////// | |
object HalJsonEncoders { | |
private def halLinkJsonAssoc: HalLink => JsonAssoc = { case HalLink(rel, href) => rel := Json.obj("href" := href) } | |
implicit def HalLinkJsonEncoder: EncodeJson[HalLink] = EncodeJson[HalLink] { | |
halLink => halLinkJsonAssoc(halLink) ->: jEmptyObject | |
} | |
object HalEmbeddedResourceJsonAssoc extends Poly1 { | |
implicit def default[T: EncodeJson, L <: HList, H[U, M <: HList] <: HalEmbeddedResource[U, M]] | |
(implicit halResourceEncoder: EncodeJson[HalResource[T, L]]) = at[H[T, L]] { | |
halEmbeddedResource => { | |
halEmbeddedResource match { | |
case HalEmbeddedResource(rel, SingleEmbeddedResource(embedded)) => rel := embedded | |
case HalEmbeddedResource(rel, ArrayEmbeddedResource(embedded)) => rel := embedded | |
} | |
} | |
} | |
} | |
implicit def HalResourceWithNoEmbeddedResourcesJsonEncoder[T: EncodeJson, L <: HNil] | |
: EncodeJson[HalResource[T, L]] = EncodeJson[HalResource[T, L]] { | |
halResource => { | |
val linksJson = jObjectAssocList(halResource.links.map(halLinkJsonAssoc)) | |
val stateJsonAssociations = implicitly[EncodeJson[T]].apply(halResource.state).assoc.getOrElse(List()) | |
Json.obj(("_links" -> linksJson :: stateJsonAssociations): _*) | |
} | |
} | |
implicit def HalResourceWithEmbeddedResourcesJsonEncoder[T: EncodeJson, L <: HList, M <: HList] | |
(implicit m: Mapper[HalEmbeddedResourceJsonAssoc.type, L] { type Out = M}, | |
n: ToTraversable.Aux[M , List, JsonAssoc]): EncodeJson[HalResource[T, L]] = EncodeJson[HalResource[T, L]] { | |
halResource => { | |
val embeddedResourcesJson = jObjectAssocList(halResource.embeddedResources.map(HalEmbeddedResourceJsonAssoc).toList) | |
val linksJson = jObjectAssocList(halResource.links.map(halLinkJsonAssoc)) | |
val stateJsonAssociations = implicitly[EncodeJson[T]].apply(halResource.state).assoc.getOrElse(List()) | |
Json.obj(("_embedded" -> embeddedResourcesJson :: "_links" -> linksJson :: stateJsonAssociations): _*) | |
} | |
} | |
} | |
///////////////////////////// | |
// And this is how you use it | |
///////////////////////////// | |
// First you need to define different type of States which you need in your HAL resource and embedded resources | |
case class Property(id: String, address: String) | |
case class Agent(id: String, name: String) | |
case class Image(title: String) | |
case class Agency(id: String, name: String, address: String) | |
// Then provide Argonaut encoders for those types | |
object StateJsonEncoders { | |
implicit def PropertyEncoder = EncodeJson[Property] { p => ("id" := p.id) ->: ("address" := p.address) ->: jEmptyObject } | |
implicit def AgentEncoder = EncodeJson[Agent] { a => ("id" := a.id) ->: ("name" := a.name) ->: jEmptyObject } | |
implicit def ImageEncoder = EncodeJson[Image] { i => ("title" := i.title) ->: jEmptyObject } | |
implicit def AgencyEncoder = EncodeJson[Agency] { a => ("id" := a.id) ->: ("name" := a.name) ->: ("address" := a.address) ->: jEmptyObject } | |
} | |
// And at the end, create your HAL Resource object and use Argonaut to generate your HAL JSON String | |
object Test extends App { | |
import StateJsonEncoders._ | |
import HalResource._ | |
import HalJsonEncoders._ | |
val secondLevelEmbedded = HalResource(links = List(HalLink("self", "/agency/1")), | |
state = Agency("1", "Ray White", "Hawthorn")) | |
val halSecondLevelEmbeddedResource = HalEmbeddedResource(rel = "agency", embedded = SingleEmbeddedResource( | |
secondLevelEmbedded)) | |
val embeddedOne = HalResource(links = List(HalLink("self", "/lister/1")), state = Agent("1", "Jim Smith"), | |
embeddedResources = halSecondLevelEmbeddedResource :: HNil) | |
val embeddedTwo = HalResource(links = List(HalLink("self", "/lister/2")), state = Agent("2", "Joe Bird"), | |
embeddedResources = halSecondLevelEmbeddedResource :: HNil) | |
val halEmbeddedResourceOne = HalEmbeddedResource(rel = "listers", embedded = ArrayEmbeddedResource( | |
List(embeddedOne, embeddedTwo))) | |
val embeddedThree = HalResource(links = List(HalLink("self", "/image/1")), state = Image("Floor Plan")) | |
val halEmbeddedResourceTwo = HalEmbeddedResource(rel = "image", embedded = SingleEmbeddedResource(embeddedThree)) | |
val halResource = HalResource(links = List(HalLink("self", "/property/1")), | |
state = Property("1", "511 Church St, Richmond"), | |
embeddedResources = halEmbeddedResourceOne :: halEmbeddedResourceTwo :: HNil) | |
val json = halResource.asJson.spaces2 | |
println(json) | |
// Will result in: | |
/* | |
{ | |
"_embedded" : { | |
"listers" : [ | |
{ | |
"_embedded" : { | |
"agency" : { | |
"_links" : { | |
"self" : { | |
"href" : "/agency/1" | |
} | |
}, | |
"id" : "1", | |
"name" : "Ray White", | |
"address" : "Hawthorn" | |
} | |
}, | |
"_links" : { | |
"self" : { | |
"href" : "/lister/1" | |
} | |
}, | |
"id" : "1", | |
"name" : "Jim Smith" | |
}, | |
{ | |
"_embedded" : { | |
"agency" : { | |
"_links" : { | |
"self" : { | |
"href" : "/agency/1" | |
} | |
}, | |
"id" : "1", | |
"name" : "Ray White", | |
"address" : "Hawthorn" | |
} | |
}, | |
"_links" : { | |
"self" : { | |
"href" : "/lister/2" | |
} | |
}, | |
"id" : "2", | |
"name" : "Joe Bird" | |
} | |
], | |
"image" : { | |
"_links" : { | |
"self" : { | |
"href" : "/image/1" | |
} | |
}, | |
"title" : "Floor Plan" | |
} | |
}, | |
"_links" : { | |
"self" : { | |
"href" : "/property/1" | |
} | |
}, | |
"id" : "1", | |
"address" : "511 Church St, Richmond" | |
} | |
*/ | |
} | |
@benhutchison As discussed, an array embedded resource is modeled in a way that all the items in the array are of the same type, e.g. array of listers within a property/listing document, but as demonstrated in the example, you can still have different HalEmbeddedResource's of different type within your document (e.g. a single Image and a list of Agents).
Right. Got to admit it works. I think my discomfort comes from not fully understanding what shapeless is doing to make it work. Somewhere there must be a traversal of the hlist resolving all the component json typeclasses, and that traversal isnt explicitly visible in your solution
Good stuff!
@benhutchinson, the traversal is via the Mapper
and Poly1
used in HalResourceWithEmbeddedResourcesJsonEncoder
above.
@milessabin yep, we figured that out after watching one of your talks 😄
I think I have found a problem, shown in this fork: https://gist.github.com/benhutchison/0bab46ac6f0eaf5d9c77